├── .clj-kondo └── config.edn ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── deps.edn ├── doc ├── cljdoc.edn └── components.md ├── logo-text.png ├── pom.xml ├── resources └── .keep ├── src └── thlack │ ├── surfs.clj │ └── surfs │ ├── blocks │ ├── components.clj │ ├── components │ │ └── spec.clj │ ├── spec.clj │ └── spec │ │ ├── actions.clj │ │ ├── context.clj │ │ ├── divider.clj │ │ ├── file.clj │ │ ├── header.clj │ │ ├── image.clj │ │ ├── input.clj │ │ └── section.clj │ ├── composition │ ├── components.clj │ ├── components │ │ └── spec.clj │ ├── spec.clj │ └── spec │ │ ├── confirm.clj │ │ ├── conversation.clj │ │ ├── dispatch_action_config.clj │ │ ├── mrkdwn.clj │ │ ├── option.clj │ │ ├── option_group.clj │ │ ├── plain_text.clj │ │ └── text.clj │ ├── elements │ ├── components.clj │ ├── components │ │ └── spec.clj │ ├── spec.clj │ └── spec │ │ ├── button.clj │ │ ├── channels_select.clj │ │ ├── checkboxes.clj │ │ ├── conversations_select.clj │ │ ├── datepicker.clj │ │ ├── external_select.clj │ │ ├── image.clj │ │ ├── multi_channels_select.clj │ │ ├── multi_conversations_select.clj │ │ ├── multi_external_select.clj │ │ ├── multi_select.clj │ │ ├── multi_static_select.clj │ │ ├── multi_users_select.clj │ │ ├── overflow.clj │ │ ├── plain_text_input.clj │ │ ├── radio_buttons.clj │ │ ├── select.clj │ │ ├── static_select.clj │ │ ├── timepicker.clj │ │ └── users_select.clj │ ├── messages │ ├── components.clj │ ├── components │ │ └── spec.clj │ └── spec.clj │ ├── props.clj │ ├── props │ └── spec.clj │ ├── render.clj │ ├── repl.clj │ ├── repl │ └── impl.clj │ ├── strings │ └── spec.clj │ ├── validation.clj │ └── views │ ├── components.clj │ ├── components │ └── spec.clj │ ├── spec.clj │ └── spec │ ├── home.clj │ └── modal.clj ├── surfs.gif └── test └── thlack └── surfs ├── blocks └── components_test.clj ├── composition └── components_test.clj ├── elements └── components_test.clj ├── examples_test.clj ├── messages └── components_test.clj ├── render_test.clj ├── test_utils.clj └── views └── components_test.clj /.clj-kondo/config.edn: -------------------------------------------------------------------------------- 1 | {:linters {} 2 | :lint-as {clojure.test.check.clojure-test/defspec clj-kondo.lint-as/def-catch-all 3 | thlack.surfs.test-utils/defcheck clj-kondo.lint-as/def-catch-all 4 | thlack.surfs.test-utils/defrendertest clj-kondo.lint-as/def-catch-all}} 5 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | - uses: actions/setup-java@v1 15 | with: 16 | java-version: 8 17 | - uses: DeLaGuardo/setup-clojure@master 18 | with: 19 | cli: "1.10.1.469" 20 | - name: lint 21 | run: clojure -A:test:format check 22 | - name: Run tests 23 | run: clojure -A:test:runner 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /classes 3 | /checkouts 4 | *.jar 5 | *.class 6 | /.cpcache 7 | /.lein-* 8 | /.nrepl-history 9 | /.nrepl-port 10 | .hgignore 11 | .hg/ 12 | .calva 13 | .clj-kondo/.cache/* 14 | .lsp 15 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | All notable changes to this project will be documented in this file. This change log follows the conventions of [keepachangelog.com](http://keepachangelog.com/). 3 | 4 | ## [Unreleased] 5 | ### Changed 6 | - Add a new arity to `make-widget-async` to provide a different widget shape. 7 | 8 | ## [0.1.1] - 2020-10-31 9 | ### Changed 10 | - Documentation on how to make the widgets. 11 | 12 | ### Removed 13 | - `make-widget-sync` - we're all async, all the time. 14 | 15 | ### Fixed 16 | - Fixed widget maker to keep working when daylight savings switches over. 17 | 18 | ## 0.1.0 - 2020-10-31 19 | ### Added 20 | - Files from the new template. 21 | - Widget maker public API - `make-widget-sync`. 22 | 23 | [Unreleased]: https://github.com/your-name/thlack.surfs/compare/0.1.1...HEAD 24 | [0.1.1]: https://github.com/your-name/thlack.surfs/compare/0.1.0...0.1.1 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE PUBLIC 2 | LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION OF THE PROGRAM 3 | CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. 4 | 5 | 1. DEFINITIONS 6 | 7 | "Contribution" means: 8 | 9 | a) in the case of the initial Contributor, the initial code and 10 | documentation distributed under this Agreement, and 11 | 12 | b) in the case of each subsequent Contributor: 13 | 14 | i) changes to the Program, and 15 | 16 | ii) additions to the Program; 17 | 18 | where such changes and/or additions to the Program originate from and are 19 | distributed by that particular Contributor. A Contribution 'originates' from 20 | a Contributor if it was added to the Program by such Contributor itself or 21 | anyone acting on such Contributor's behalf. Contributions do not include 22 | additions to the Program which: (i) are separate modules of software 23 | distributed in conjunction with the Program under their own license 24 | agreement, and (ii) are not derivative works of the Program. 25 | 26 | "Contributor" means any person or entity that distributes the Program. 27 | 28 | "Licensed Patents" mean patent claims licensable by a Contributor which are 29 | necessarily infringed by the use or sale of its Contribution alone or when 30 | combined with the Program. 31 | 32 | "Program" means the Contributions distributed in accordance with this 33 | Agreement. 34 | 35 | "Recipient" means anyone who receives the Program under this Agreement, 36 | including all Contributors. 37 | 38 | 2. GRANT OF RIGHTS 39 | 40 | a) Subject to the terms of this Agreement, each Contributor hereby grants 41 | Recipient a non-exclusive, worldwide, royalty-free copyright license to 42 | reproduce, prepare derivative works of, publicly display, publicly perform, 43 | distribute and sublicense the Contribution of such Contributor, if any, and 44 | such derivative works, in source code and object code form. 45 | 46 | b) Subject to the terms of this Agreement, each Contributor hereby grants 47 | Recipient a non-exclusive, worldwide, royalty-free patent license under 48 | Licensed Patents to make, use, sell, offer to sell, import and otherwise 49 | transfer the Contribution of such Contributor, if any, in source code and 50 | object code form. This patent license shall apply to the combination of the 51 | Contribution and the Program if, at the time the Contribution is added by the 52 | Contributor, such addition of the Contribution causes such combination to be 53 | covered by the Licensed Patents. The patent license shall not apply to any 54 | other combinations which include the Contribution. No hardware per se is 55 | licensed hereunder. 56 | 57 | c) Recipient understands that although each Contributor grants the licenses 58 | to its Contributions set forth herein, no assurances are provided by any 59 | Contributor that the Program does not infringe the patent or other 60 | intellectual property rights of any other entity. Each Contributor disclaims 61 | any liability to Recipient for claims brought by any other entity based on 62 | infringement of intellectual property rights or otherwise. As a condition to 63 | exercising the rights and licenses granted hereunder, each Recipient hereby 64 | assumes sole responsibility to secure any other intellectual property rights 65 | needed, if any. For example, if a third party patent license is required to 66 | allow Recipient to distribute the Program, it is Recipient's responsibility 67 | to acquire that license before distributing the Program. 68 | 69 | d) Each Contributor represents that to its knowledge it has sufficient 70 | copyright rights in its Contribution, if any, to grant the copyright license 71 | set forth in this Agreement. 72 | 73 | 3. REQUIREMENTS 74 | 75 | A Contributor may choose to distribute the Program in object code form under 76 | its own license agreement, provided that: 77 | 78 | a) it complies with the terms and conditions of this Agreement; and 79 | 80 | b) its license agreement: 81 | 82 | i) effectively disclaims on behalf of all Contributors all warranties and 83 | conditions, express and implied, including warranties or conditions of title 84 | and non-infringement, and implied warranties or conditions of merchantability 85 | and fitness for a particular purpose; 86 | 87 | ii) effectively excludes on behalf of all Contributors all liability for 88 | damages, including direct, indirect, special, incidental and consequential 89 | damages, such as lost profits; 90 | 91 | iii) states that any provisions which differ from this Agreement are offered 92 | by that Contributor alone and not by any other party; and 93 | 94 | iv) states that source code for the Program is available from such 95 | Contributor, and informs licensees how to obtain it in a reasonable manner on 96 | or through a medium customarily used for software exchange. 97 | 98 | When the Program is made available in source code form: 99 | 100 | a) it must be made available under this Agreement; and 101 | 102 | b) a copy of this Agreement must be included with each copy of the Program. 103 | 104 | Contributors may not remove or alter any copyright notices contained within 105 | the Program. 106 | 107 | Each Contributor must identify itself as the originator of its Contribution, 108 | if any, in a manner that reasonably allows subsequent Recipients to identify 109 | the originator of the Contribution. 110 | 111 | 4. COMMERCIAL DISTRIBUTION 112 | 113 | Commercial distributors of software may accept certain responsibilities with 114 | respect to end users, business partners and the like. While this license is 115 | intended to facilitate the commercial use of the Program, the Contributor who 116 | includes the Program in a commercial product offering should do so in a 117 | manner which does not create potential liability for other Contributors. 118 | Therefore, if a Contributor includes the Program in a commercial product 119 | offering, such Contributor ("Commercial Contributor") hereby agrees to defend 120 | and indemnify every other Contributor ("Indemnified Contributor") against any 121 | losses, damages and costs (collectively "Losses") arising from claims, 122 | lawsuits and other legal actions brought by a third party against the 123 | Indemnified Contributor to the extent caused by the acts or omissions of such 124 | Commercial Contributor in connection with its distribution of the Program in 125 | a commercial product offering. The obligations in this section do not apply 126 | to any claims or Losses relating to any actual or alleged intellectual 127 | property infringement. In order to qualify, an Indemnified Contributor must: 128 | a) promptly notify the Commercial Contributor in writing of such claim, and 129 | b) allow the Commercial Contributor tocontrol, and cooperate with the 130 | Commercial Contributor in, the defense and any related settlement 131 | negotiations. The Indemnified Contributor may participate in any such claim 132 | at its own expense. 133 | 134 | For example, a Contributor might include the Program in a commercial product 135 | offering, Product X. That Contributor is then a Commercial Contributor. If 136 | that Commercial Contributor then makes performance claims, or offers 137 | warranties related to Product X, those performance claims and warranties are 138 | such Commercial Contributor's responsibility alone. Under this section, the 139 | Commercial Contributor would have to defend claims against the other 140 | Contributors related to those performance claims and warranties, and if a 141 | court requires any other Contributor to pay any damages as a result, the 142 | Commercial Contributor must pay those damages. 143 | 144 | 5. NO WARRANTY 145 | 146 | EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, THE PROGRAM IS PROVIDED ON 147 | AN "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EITHER 148 | EXPRESS OR IMPLIED INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR 149 | CONDITIONS OF TITLE, NON-INFRINGEMENT, MERCHANTABILITY OR FITNESS FOR A 150 | PARTICULAR PURPOSE. Each Recipient is solely responsible for determining the 151 | appropriateness of using and distributing the Program and assumes all risks 152 | associated with its exercise of rights under this Agreement , including but 153 | not limited to the risks and costs of program errors, compliance with 154 | applicable laws, damage to or loss of data, programs or equipment, and 155 | unavailability or interruption of operations. 156 | 157 | 6. DISCLAIMER OF LIABILITY 158 | 159 | EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, NEITHER RECIPIENT NOR ANY 160 | CONTRIBUTORS SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT, INCIDENTAL, 161 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING WITHOUT LIMITATION 162 | LOST PROFITS), HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 163 | CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 164 | ARISING IN ANY WAY OUT OF THE USE OR DISTRIBUTION OF THE PROGRAM OR THE 165 | EXERCISE OF ANY RIGHTS GRANTED HEREUNDER, EVEN IF ADVISED OF THE POSSIBILITY 166 | OF SUCH DAMAGES. 167 | 168 | 7. GENERAL 169 | 170 | If any provision of this Agreement is invalid or unenforceable under 171 | applicable law, it shall not affect the validity or enforceability of the 172 | remainder of the terms of this Agreement, and without further action by the 173 | parties hereto, such provision shall be reformed to the minimum extent 174 | necessary to make such provision valid and enforceable. 175 | 176 | If Recipient institutes patent litigation against any entity (including a 177 | cross-claim or counterclaim in a lawsuit) alleging that the Program itself 178 | (excluding combinations of the Program with other software or hardware) 179 | infringes such Recipient's patent(s), then such Recipient's rights granted 180 | under Section 2(b) shall terminate as of the date such litigation is filed. 181 | 182 | All Recipient's rights under this Agreement shall terminate if it fails to 183 | comply with any of the material terms or conditions of this Agreement and 184 | does not cure such failure in a reasonable period of time after becoming 185 | aware of such noncompliance. If all Recipient's rights under this Agreement 186 | terminate, Recipient agrees to cease use and distribution of the Program as 187 | soon as reasonably practicable. However, Recipient's obligations under this 188 | Agreement and any licenses granted by Recipient relating to the Program shall 189 | continue and survive. 190 | 191 | Everyone is permitted to copy and distribute copies of this Agreement, but in 192 | order to avoid inconsistency the Agreement is copyrighted and may only be 193 | modified in the following manner. The Agreement Steward reserves the right to 194 | publish new versions (including revisions) of this Agreement from time to 195 | time. No one other than the Agreement Steward has the right to modify this 196 | Agreement. The Eclipse Foundation is the initial Agreement Steward. The 197 | Eclipse Foundation may assign the responsibility to serve as the Agreement 198 | Steward to a suitable separate entity. Each new version of the Agreement will 199 | be given a distinguishing version number. The Program (including 200 | Contributions) may always be distributed subject to the version of the 201 | Agreement under which it was received. In addition, after a new version of 202 | the Agreement is published, Contributor may elect to distribute the Program 203 | (including its Contributions) under the new version. Except as expressly 204 | stated in Sections 2(a) and 2(b) above, Recipient receives no rights or 205 | licenses to the intellectual property of any Contributor under this 206 | Agreement, whether expressly, by implication, estoppel or otherwise. All 207 | rights in the Program not expressly granted under this Agreement are 208 | reserved. 209 | 210 | This Agreement is governed by the laws of the State of New York and the 211 | intellectual property laws of the United States of America. No party to this 212 | Agreement will bring a legal action under this Agreement more than one year 213 | after the cause of action arose. Each party waives its rights to a jury trial 214 | in any resulting litigation. 215 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |
4 | 5 |

6 | 7 | Surfs is a library for creating user interfaces in [Slack](https://api.slack.com/) applications. It aims to make creating Slack [surfaces](https://api.slack.com/surfaces) enjoyable. 8 | 9 | [![cljdoc badge](https://cljdoc.org/badge/thlack/surfs)](https://cljdoc.org/d/thlack/surfs/CURRENT) [![Clojars Project](https://img.shields.io/clojars/v/thlack/surfs.svg)](https://clojars.org/thlack/surfs) 10 | 11 | 12 | ## Table of Contents 13 | 14 | - [Rationale](#rationale) 15 | - [Using Surfs](#using-surfs) 16 | - [Component reference](doc/components.md) 17 | - [API Docs](https://cljdoc.org/d/thlack/surfs/CURRENT) 18 | - [Rendering components](#rendering-components) 19 | - [Defining custom components](#defining-custom-components) 20 | - [Children](#children) 21 | - [Acknowledgements](#acknowledgements) 22 | 23 | ## Rationale 24 | 25 | Slack's [Block Kit](https://api.slack.com/block-kit) is great! Writing blocks as hash maps is less great! Writing vectors is better! The goal of Surfs is to be a high quality template library for defining the user interface of a Slack application. It should be: 26 | 27 | * Familiar - Like [hiccup](https://github.com/weavejester/hiccup), [reagent](https://github.com/reagent-project/reagent), and [rum](https://github.com/tons) 28 | * Spec driven 29 | * There are specs for the whole block kit! 30 | * All component functions have specs via `(s/fdef)` 31 | * All results are run through `(s/assert)` 32 | * Learn about components through their specs! 33 | * Easier than writing hash maps 34 | * Easier to create reusable templates 35 | 36 | ## Using Surfs 37 | 38 | Surfs renders Slack blocks, elements, and composition objects from vectors. It also 39 | supports defining and using custom components (to organize and encapsulate Slack elements). 40 | 41 | ### Development 42 | 43 | Surfs uses [clojure.spec.alpha/assert](https://clojuredocs.org/clojure.spec.alpha/assert) on all rendered results. All component functions contain function specs. This can greatly improve the development experience at the repl. 44 | 45 | See: 46 | * [clojure.spec.alpha/check-asserts](https://clojuredocs.org/clojure.spec.alpha/check-asserts) 47 | * [clojure.spec.test.alpha/instrument](https://clojure.github.io/spec.alpha/clojure.spec.test.alpha-api.html#clojure.spec.test.alpha/instrument) 48 | 49 | #### repl uilities 50 | 51 | The `thlack.surfs.repl` namespace contains some useful utilities for developing with Surfs. 52 | 53 | * `(describe :tag)` 54 | Get render function metadata and `fspec`. 55 | * `(doc :tag)` 56 | Print component signatures, docstrings, and examples right to the repl! 57 | * `(props :tag)` 58 | Get the prop spec for a component (if it has one). 59 | 60 | ### Rendering components 61 | 62 | The heart of Surfs is the `render` function. 63 | 64 | ```clojure 65 | (require '[thlack.surfs :as surfs]) 66 | 67 | (surfs/render [:button {:action_id "A123"} "Click Me!"]) 68 | 69 | ({:action_id "A123", 70 | :type :button, 71 | :text {:type :plain_text, :text "Click Me!"}}) 72 | 73 | (surfs/render [:section {:block_id "B123"} 74 | [:text "Important Text"]] 75 | [:section {:block_id "B456"} 76 | [:markdown "# More Text!"]]) 77 | 78 | ({:block_id "B123", 79 | :type :section, 80 | :text {:type :plain_text, :text "Important Text"}} 81 | {:block_id "B456", 82 | :type :section, 83 | :text {:type :mrkdwn, :text "# More Text!", :verbatim false}}) 84 | ``` 85 | 86 | The results of render are typically attached to a Slack API payload in the `blocks` attribute. 87 | 88 | ```clojure 89 | (require '[thlack.surfs :as surfs]) 90 | (require '[clojure.data.json :as json]) 91 | 92 | (let [blocks (surfs/render 93 | [:section {:block_id "B123"} 94 | [:text "Important Text"]] 95 | [:section {:block_id "B456"} 96 | [:markdown "# More Text!"]])] 97 | (post-message some-token (json/write-string {:channel "C123" 98 | :blocks blocks}))) 99 | ``` 100 | 101 | ### Defining custom components 102 | 103 | Custom components can be defined two different ways: 104 | 105 | * As a function 106 | * Using the `defc` macro 107 | 108 | ```clojure 109 | ;;; Using a function 110 | 111 | (defn greeting 112 | [first-name] 113 | [:text (str "Hello " first-name "!")]) 114 | 115 | ;;; Using defc 116 | 117 | (defc greeting 118 | [first-name] 119 | [:text (str "Hello " first-name "!")]) 120 | ``` 121 | 122 | Both types of custom component can be used in the head position of a vector 123 | within a group of components. 124 | 125 | ```clojure 126 | (render [:section {:block_id "B123"} 127 | [greeting "Brian"]]) 128 | ``` 129 | 130 | Custom components defined via `defc` can be called as render functions themselves: 131 | 132 | ```clojure 133 | (defc greeting 134 | [first-name] 135 | [:text (str "Hello " first-name "!")]) 136 | 137 | (greeting "Brian") 138 | ``` 139 | 140 | The result of rendering a component defined via defc is either a single result or a sequence of results. 141 | A single result is only returned in the event that thlack.surfs/render would return a sequence containing a single item. This is to support scenarios where a component would suffice as the render function for the entire payload to Slack - as is the case with publishing views like home tabs and modals. 142 | 143 | ### Children 144 | 145 | Children are one or more nested components - such as `:option` elements placed in a `:static-select`. 146 | 147 | ```clojure 148 | [:static-select {:action_id "A123"} 149 | [:placeholder "Choose a topping"] 150 | [:option {:value "pepperoni"} "Pepperoni"] 151 | [:option {:value "pineapple"} "Pineapple"]] 152 | ``` 153 | 154 | If a child is encountered as a sequence, it will be flattened. This is useful for things like generating options: 155 | 156 | ```clojure 157 | [:static-select {:action_id "A123"} 158 | [:placeholder "Options"] 159 | (map (fn [value label] [:option {:value value} label]) data)] 160 | ``` 161 | 162 | Or separating items: 163 | 164 | ```clojure 165 | [:home 166 | (drop 1 (interleave (repeat [:divider]) [[:section {:block_id "1"} 167 | [:text "One!"]] 168 | [:section {:block_id "2"} 169 | [:text "Two!"]] 170 | [:section {:block_id "3"} 171 | [:text "Three!"]]]))] 172 | ``` 173 | 174 | ## Acknowledgements 175 | 176 | Concepts are borrowed from and inspired by the following libraries: 177 | 178 | * [Hiccup](https://github.com/weavejester/hiccup) 179 | * [Reagent](https://github.com/reagent-project/reagent) 180 | * [Rum](https://github.com/tonsky/rum) 181 | 182 | Many thanks to these authors and contributors. 183 | 184 | ## License 185 | 186 | Copyright © 2020 Brian Scaturro 187 | 188 | Distributed under the Eclipse Public License (see [LICENSE](LICENSE)) 189 | -------------------------------------------------------------------------------- /deps.edn: -------------------------------------------------------------------------------- 1 | {:paths ["src" "resources"] 2 | :deps {org.clojure/clojure {:mvn/version "1.10.1"}} 3 | :aliases 4 | {:test {:extra-paths ["test"] 5 | :extra-deps {org.clojure/test.check {:mvn/version "1.0.0"} 6 | metosin/jsonista {:mvn/version "0.2.7"} 7 | expound/expound {:mvn/version "0.8.6"}}} 8 | 9 | :runner 10 | {:extra-deps {com.cognitect/test-runner 11 | {:git/url "https://github.com/cognitect-labs/test-runner" 12 | :sha "f7ef16dc3b8332b0d77bc0274578ad5270fbfedd"}} 13 | :main-opts ["-m" "cognitect.test-runner" 14 | "-d" "test"]} 15 | 16 | :format {:extra-deps {cljfmt/cljfmt {:mvn/version "0.7.0"}} 17 | :main-opts ["-m" "cljfmt.main"]} 18 | 19 | :jar {:extra-deps {seancorfield/depstar {:mvn/version "1.1.104"}} 20 | :main-opts ["-m" "hf.depstar.jar" "thlack.surfs.jar"]} 21 | 22 | :install {:extra-deps {deps-deploy/deps-deploy {:mvn/version "0.0.9"}} 23 | :main-opts ["-m" "deps-deploy.deps-deploy" "install" "thlack.surfs.jar"]} 24 | 25 | :deploy {:extra-deps {deps-deploy/deps-deploy {:mvn/version "0.0.9"}} 26 | :main-opts ["-m" "deps-deploy.deps-deploy" "deploy" "thlack.surfs.jar"]}}} 27 | -------------------------------------------------------------------------------- /doc/cljdoc.edn: -------------------------------------------------------------------------------- 1 | {:cljdoc.doc/tree [["Readme" {:file "README.md"}] 2 | ["Component reference" {:file "doc/components.md"}]]} 3 | -------------------------------------------------------------------------------- /logo-text.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thlack/surfs/e03d137d6d43c4b73a45a71984cf084d2904c4b0/logo-text.png -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4.0.0 4 | thlack 5 | surfs 6 | 2021.01.25 7 | thlack.surfs 8 | Surfs is a library for creating user interfaces in Slack applications. It aims to make creating Slack surfaces enjoyable. It follows the spirit of libraries like hiccup, reagent, and rum. 9 | https://github.com/thlack/surfs 10 | 11 | 12 | Eclipse Public License 13 | http://www.eclipse.org/legal/epl-v10.html 14 | 15 | 16 | 17 | 18 | Brian 19 | 20 | 21 | 22 | https://github.com/thlack/surfs.git 23 | scm:git:git://github.com/thlack/surfs.git 24 | scm:git:ssh://git@github.com/thlack/surfs.git 25 | HEAD 26 | 27 | 28 | 29 | org.clojure 30 | clojure 31 | 1.10.1 32 | 33 | 34 | 35 | src 36 | 37 | 38 | 39 | clojars 40 | https://repo.clojars.org/ 41 | 42 | 43 | 44 | 45 | clojars 46 | Clojars repository 47 | https://clojars.org/repo 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /resources/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thlack/surfs/e03d137d6d43c4b73a45a71984cf084d2904c4b0/resources/.keep -------------------------------------------------------------------------------- /src/thlack/surfs.clj: -------------------------------------------------------------------------------- 1 | (ns thlack.surfs 2 | "A hiccup-like interface for creating views in Slack applications via blocks. 3 | https://api.slack.com/reference/block-kit/blocks" 4 | (:require [thlack.surfs.render :as surfs.render] 5 | [thlack.surfs.props :as props])) 6 | 7 | (defn render 8 | "Render one or more surfs components into a data structure 9 | fit for use by Slack. A component is a vector that follows a hiccup-like syntax. 10 | 11 | ```clojure 12 | (render 13 | [:section {:block_id \"B123\"} 14 | [:text \"Section text\"] 15 | [:datepicker {:action_id \"A123\" :initial_date \"2020-11-30\"} 16 | [:placeholder \"The date\"]]] 17 | 18 | [:actions {:block_id \"B456\"} 19 | [:radio-buttons {:action_id \"A456\"} 20 | [:option {:value \"1\"} \"Pepperoni\"] 21 | [:option {:value \"2\" :selected? true} \"Pineapple\"] 22 | [:option {:value \"3\"} \"Mushrooms\"]] 23 | [:channels-select {:action_id \"A789\" :initial_channel \"C123\"} 24 | [:placeholder \"Select channel\"]]]) 25 | ``` 26 | 27 | Custom components can be written as functions, and then used as the head of the vector: 28 | 29 | ```clojure 30 | (defn fun-text 31 | [str] 32 | [:text str]) 33 | 34 | (defn custom 35 | [props & children] 36 | [:section {:block_id \"B123\"} 37 | [fun-text (:text props)] 38 | children]) 39 | 40 | (render [custom {:text \"Such fun\"}]) 41 | ``` 42 | Note: The returned data structure must be serialized to json (not included) before being sent to Slack." 43 | [& components] 44 | (->> components 45 | (map surfs.render/render) 46 | (props/flatten-children))) 47 | 48 | (defn- build-defc-body 49 | [[bindings & body]] 50 | `(~bindings 51 | (let [result# (thlack.surfs/render ~@body)] 52 | (if (= 1 (count result#)) 53 | (first result#) 54 | result#)))) 55 | 56 | (defn- defc' 57 | "Helper for generating the body of defc" 58 | [body] 59 | (let [head (first body)] 60 | (if (string? head) 61 | (into [head] (build-defc-body (rest body))) 62 | (build-defc-body body)))) 63 | 64 | (defmacro defc 65 | "``` 66 | (defc name doc-string? render-body) 67 | ``` 68 | 69 | Define a reusable surfs component. 70 | 71 | Defc creates a function that wraps the body in a call to thlack.surfs/render 72 | and returns a single result or a sequence of results. A single result is only returned 73 | in the event that thlack.surfs/render would return a sequence containing a single item. This 74 | is to support scenarios where a component would suffice as the render function for the entire 75 | payload to slack - as is the case with publishing views like home tabs and modals. 76 | 77 | This macro is great for defining modals, home tabs, and messages - really 78 | any kind of slack view you want to encapsulate or reuse. 79 | 80 | The generated function will have the same name and semantics as the macro defntion, 81 | so function specs can be written against it easily. 82 | 83 | Components created via defc can also be reused within other components. 84 | 85 | Usage: 86 | 87 | ```clojure 88 | (defc my-modal 89 | [title] 90 | [:modal {:title title 91 | :close \"Close\" 92 | :submit \"Submit\"} 93 | [:section {:block_id \"B123\"} 94 | [:text \"Some text\"]]]) 95 | 96 | ;; Defines function my-modal which can be called 97 | ;; to return rendered blocks/elements. 98 | 99 | (my-modal \"Great Modal\") 100 | 101 | ;; Components can be used as custom elements as well. 102 | 103 | (defc special-text 104 | [txt] 105 | [:text txt]) 106 | 107 | (render [:context {:block_id \"B123\"} 108 | [special-text \"So special!\"]]) 109 | ```" 110 | [name & body] 111 | `(defn ~name 112 | ~@(defc' body))) 113 | -------------------------------------------------------------------------------- /src/thlack/surfs/blocks/components.clj: -------------------------------------------------------------------------------- 1 | (ns thlack.surfs.blocks.components 2 | "A hiccup-like interface for creating views in Slack applications via blocks. 3 | https://api.slack.com/reference/block-kit/blocks" 4 | (:require [clojure.spec.alpha :as s] 5 | [thlack.surfs.blocks.spec :as blocks.spec] 6 | [thlack.surfs.blocks.spec.section :as section.spec] 7 | [thlack.surfs.blocks.components.spec :as bc.spec] 8 | [thlack.surfs.composition.components :as comp] 9 | [thlack.surfs.composition.spec :as comp.spec] 10 | [thlack.surfs.elements.components :as elements] 11 | [thlack.surfs.props :as props] 12 | [thlack.surfs.validation :refer [validated]])) 13 | 14 | (defn actions 15 | "A block that is used to hold interactive elements. 16 | 17 | Component usage: 18 | 19 | ```clojure 20 | [:actions {:block_id \"B123\"} 21 | [:radio-buttons {:action_id \"A123\"} 22 | [:option {:value \"1\"} \"Pepperoni\"] 23 | [:option {:value \"2\" :selected? true} \"Pineapple\"] 24 | [:option {:value \"3\"} \"Mushrooms\"]] 25 | [:channels-select {:action_id \"A456\" :initial_channel \"C123\"} 26 | [:placeholder \"Select channel\"]]] 27 | ``` 28 | 29 | Without props: 30 | 31 | ```clojure 32 | [:actions 33 | [:radio-buttons {:action_id \"A123\"} 34 | [:option {:value \"1\"} \"Pepperoni\"] 35 | [:option {:value \"2\" :selected? true} \"Pineapple\"] 36 | [:option {:value \"3\"} \"Mushrooms\"]] 37 | [:channels-select {:action_id \"A456\" :initial_channel \"C123\"} 38 | [:placeholder \"Select channel\"]]] 39 | ```" 40 | [& args] 41 | (let [[props & children] (props/parse-args args ::bc.spec/block.props*)] 42 | (-> props 43 | (assoc :elements (props/flatten-children children) :type :actions) 44 | (validated ::blocks.spec/actions)))) 45 | 46 | (s/fdef actions 47 | :args (s/alt :props-and-children (s/cat :props ::bc.spec/block.props :children ::bc.spec/actions.children) 48 | :children (s/cat :children ::bc.spec/actions.children)) 49 | :ret ::blocks.spec/actions) 50 | 51 | (defn fields 52 | "Not technically an element provided by Slack, but this component 53 | is useful for adding semantic value to section blocks. 54 | 55 | Component usage: 56 | 57 | ```clojure 58 | [:fields 59 | [:markdown \"# Field 1\"] 60 | [:plain-text \"Field 2\"]] 61 | ```" 62 | [& texts] 63 | {:fields 64 | (props/flatten-children texts)}) 65 | 66 | (s/fdef fields 67 | :args (s/cat :texts (s/+ ::comp.spec/text)) 68 | :ret ::blocks.spec/fields) 69 | 70 | (defn- with-section-child 71 | "Conform a value to a spec describing a section child that may be 72 | one of several values" 73 | [props x] 74 | (if-not (some? x) 75 | props 76 | (let [child (if (and (seq x) (not (map? x))) (first x) x) 77 | [tag _] (s/conform ::bc.spec/section.child child)] 78 | (cond-> props 79 | (= :text tag) (assoc :text child) 80 | (= :accessory tag) (assoc :accessory child) 81 | (= :fields tag) (merge child))))) 82 | 83 | (defn section 84 | "Can be used as a simple text block, or in combination with multiple text fields. Can contain 85 | a single accessory block element. 86 | 87 | The section block is unique in how it handles text and fields. When 88 | rendering a section block with 3 arguments - that is a props map and 2 children, 89 | some care must be taken. See the fdef for the section function to see the permutation of 90 | arguments supported. 91 | 92 | Component usage: 93 | 94 | ```clojure 95 | [:section {:block_id \"B123\"} 96 | [:text \"This is an important action\"] 97 | [:datepicker {:action_id \"A123\" :initial_date \"2020-11-30\"} 98 | [:placeholder \"The date\"] 99 | [:confirm {:confirm \"Ok!\" :deny \"Nah!\" :title \"You sure?!?!?\"} 100 | [:text \"This is irreversible!\"]]]] 101 | ``` 102 | 103 | Without props: 104 | 105 | ```clojure 106 | [:section 107 | [:text \"This is an important action\"] 108 | [:datepicker {:action_id \"A123\" :initial_date \"2020-11-30\"} 109 | [:placeholder \"The date\"] 110 | [:confirm {:confirm \"Ok!\" :deny \"Nah!\" :title \"You sure?!?!?\"} 111 | [:text \"This is irreversible!\"]]]] 112 | ```" 113 | [& args] 114 | (let [[props & children] (props/parse-args args ::bc.spec/block.props*)] 115 | (reduce with-section-child (assoc props :type :section) children))) 116 | 117 | (s/fdef section 118 | :args (s/alt :props-and-text (s/cat :props ::bc.spec/block.props 119 | :text ::section.spec/text) 120 | :props-and-fields (s/cat :props ::bc.spec/block.props 121 | :fields ::blocks.spec/fields) 122 | :props-and-text-and-accessory (s/cat :props ::bc.spec/block.props 123 | :text ::section.spec/text 124 | :accessory ::section.spec/accessory) 125 | :props-and-accessory-and-text (s/cat :props ::bc.spec/block.props 126 | :accessory ::section.spec/accessory 127 | :text ::section.spec/text) 128 | :props-and-fields-and-accessory (s/cat :props ::bc.spec/block.props 129 | :fields ::blocks.spec/fields 130 | :accessory ::section.spec/accessory) 131 | :props-and-accessory-and-fields (s/cat :props ::bc.spec/block.props 132 | :accessory ::section.spec/accessory 133 | :fields ::blocks.spec/fields) 134 | :props-and-text-and-fields (s/cat :props ::bc.spec/block.props 135 | :text ::section.spec/text 136 | :fields ::blocks.spec/fields) 137 | :props-and-fields-and-text (s/cat :props ::bc.spec/block.props 138 | :fields ::blocks.spec/fields 139 | :text ::section.spec/text) 140 | 141 | :text (s/cat :text ::section.spec/text) 142 | :fields (s/cat :fields ::blocks.spec/fields) 143 | :text-and-accessory (s/cat :text ::section.spec/text 144 | :accessory ::section.spec/accessory) 145 | :accessory-and-text (s/cat :accessory ::section.spec/accessory 146 | :text ::section.spec/text) 147 | :fields-and-accessory (s/cat :fields ::blocks.spec/fields 148 | :accessory ::section.spec/accessory) 149 | :accessory-and-fields (s/cat :accessory ::section.spec/accessory 150 | :fields ::blocks.spec/fields) 151 | :text-and-fields (s/cat :text ::section.spec/text 152 | :fields ::blocks.spec/fields) 153 | :fields-and-text (s/cat :fields ::blocks.spec/fields 154 | :text ::section.spec/text) 155 | :all (s/cat :props ::bc.spec/block.props 156 | :text ::section.spec/text 157 | :fields ::blocks.spec/fields 158 | :accessory ::section.spec/accessory)) 159 | :ret ::blocks.spec/section) 160 | 161 | (defn context 162 | "Displays message context, which can include both images and text. 163 | 164 | Component usage: 165 | 166 | ```clojure 167 | [:context {:block_id \"B123\"} 168 | [:image {:alt_text \"It's Bill\" :image_url \"http://www.fillmurray.com/200/300\"}] 169 | [:text \"This is some text\"]] 170 | ``` 171 | 172 | Without props: 173 | 174 | ```clojure 175 | [:context 176 | [:image {:alt_text \"It's Bill\" :image_url \"http://www.fillmurray.com/200/300\"}] 177 | [:text \"This is some text\"]] 178 | ```" 179 | [& args] 180 | (let [[props & children] (props/parse-args args ::bc.spec/block.props*)] 181 | (-> props 182 | (assoc :elements (props/flatten-children children) :type :context) 183 | (validated ::blocks.spec/context)))) 184 | 185 | (s/fdef context 186 | :args (s/alt :props-and-children (s/cat :props ::bc.spec/block.props :children ::bc.spec/context.children) 187 | :children (s/cat :children ::bc.spec/context.children)) 188 | :ret ::blocks.spec/context) 189 | 190 | (defn divider 191 | "A content divider. Functions much like HTML's
element. 192 | 193 | Component usage: 194 | 195 | ```clojure 196 | [:divider] 197 | ```" 198 | ([props] 199 | (assoc props :type :divider)) 200 | ([] 201 | (divider {}))) 202 | 203 | (s/fdef divider 204 | :args (s/cat :props (s/? ::bc.spec/block.props)) 205 | :ret ::blocks.spec/divider) 206 | 207 | (defn header 208 | "A plain-text block that displays in a larger, bold font. 209 | 210 | Component usage: 211 | 212 | ```clojure 213 | [:header {:block_id \"B123\"} \"Hello\"] 214 | ``` 215 | 216 | Without props: 217 | 218 | ```clojure 219 | [:header \"Hello\"] 220 | ```" 221 | ([props text] 222 | (-> props 223 | (assoc :type :header) 224 | (assoc :text (comp/text text)) 225 | (validated ::blocks.spec/header))) 226 | ([text] 227 | (header {} text))) 228 | 229 | (s/fdef header 230 | :args (s/alt :props-and-children (s/cat :props ::bc.spec/block.props :text :thlack.surfs.blocks.components.spec.header-child/text) 231 | :children (s/cat :text :thlack.surfs.blocks.components.spec.header-child/text)) 232 | :ret ::blocks.spec/header) 233 | 234 | (defn image 235 | "A simple image block. 236 | 237 | Component usage: 238 | 239 | ```clojure 240 | [:image {:image_url \"http://www.fillmurray.com/200/300\" 241 | :alt_text \"It's Bill\" 242 | :block_id \"B123\"} 243 | [:title \"Wowzers!\"]] 244 | ```" 245 | ([props title] 246 | (-> props 247 | (elements/img) 248 | (cond-> 249 | (some? title) (assoc :title (comp/text title))) 250 | (validated ::blocks.spec/image))) 251 | ([props] 252 | (image props nil))) 253 | 254 | (s/fdef image 255 | :args (s/cat :props ::bc.spec/image.props :title (s/? :thlack.surfs.blocks.components.spec.image-child/title)) 256 | :ret ::blocks.spec/image) 257 | 258 | (defn input 259 | "A block that collects information from users. In order to distinguish 260 | between hint and label children, the label child (or a child that evaluates to a plain text element) MUST be the first child 261 | included in the component. 262 | 263 | Component usage: 264 | 265 | ```clojure 266 | [:input {:block_id \"B123\" :dispatch_action false :optional false} 267 | [:label \"Some input\"] 268 | [:hint \"Do something radical\"] 269 | [:plain-text-input {:action_id \"A123\" 270 | :initial_value \"hello\"} 271 | [:placeholder \"Greeting\"]]] 272 | ``` 273 | 274 | Without props: 275 | 276 | ```clojure 277 | [:input 278 | [:label \"Some input\"] 279 | [:hint \"Do something radical\"] 280 | [:plain-text-input {:action_id \"A123\" 281 | :initial_value \"hello\"} 282 | [:placeholder \"Greeting\"]]] 283 | ```" 284 | [& args] 285 | (let [[props label & children] (props/parse-args args bc.spec/input-props?)] 286 | (-> (assoc props :type :input) 287 | (assoc :label (comp/text label)) 288 | (props/with-children children ::bc.spec/input.child) 289 | (validated ::blocks.spec/input)))) 290 | 291 | (s/fdef input 292 | :args (s/alt :props-and-children (s/cat :props ::bc.spec/input.props :label :thlack.surfs.blocks.components.spec.input-child/label :children ::bc.spec/input.children) 293 | :children (s/cat :label :thlack.surfs.blocks.components.spec.input-child/label :children ::bc.spec/input.children)) 294 | :ret ::blocks.spec/input) 295 | -------------------------------------------------------------------------------- /src/thlack/surfs/blocks/components/spec.clj: -------------------------------------------------------------------------------- 1 | (ns ^:no-doc thlack.surfs.blocks.components.spec 2 | (:require [clojure.spec.alpha :as s] 3 | [clojure.spec.gen.alpha :as gen] 4 | [thlack.surfs.blocks.spec :as blocks.spec] 5 | [thlack.surfs.blocks.spec.actions :as actions] 6 | [thlack.surfs.blocks.spec.context :as context] 7 | [thlack.surfs.blocks.spec.header :as header] 8 | [thlack.surfs.blocks.spec.image :as image] 9 | [thlack.surfs.blocks.spec.input :as input] 10 | [thlack.surfs.blocks.spec.section :as section] 11 | [thlack.surfs.composition.spec :as comp.spec] 12 | [thlack.surfs.strings.spec :as strings.spec :refer [deftext]])) 13 | 14 | (s/def ::block.props (s/keys :opt-un [::strings.spec/block_id])) 15 | 16 | ; Internal use only - used for validating prop maps that MUST contain block_id in order to be considered props 17 | (s/def ::block.props* (s/keys :req-un [::strings.spec/block_id])) 18 | 19 | ;;; [:actions] 20 | 21 | (s/def ::actions.children 22 | (s/with-gen 23 | (s/+ ::actions/element) 24 | #(s/gen ::actions/elements))) 25 | 26 | ;;; [:section] 27 | 28 | (s/def ::section.child (s/or :text ::section/text 29 | :fields ::blocks.spec/fields 30 | :accessory ::section/accessory)) 31 | 32 | ;;; [:context] 33 | 34 | (s/def ::context.children 35 | (s/with-gen 36 | (s/+ ::context/element) 37 | #(s/gen ::context/elements))) 38 | 39 | ;;; [:header] 40 | 41 | (deftext :thlack.surfs.blocks.components.spec.header-child/text (s/or :string ::strings.spec/string :text ::header/text) 150) 42 | 43 | ;;; [:image] 44 | 45 | (deftext :thlack.surfs.blocks.components.spec.image-child/title (s/or :string ::strings.spec/string :text ::image/title) 2000) 46 | 47 | (s/def ::image.props (s/merge ::block.props (s/keys :req-un [::image/image_url ::image/alt_text]))) 48 | 49 | ;;; [:input] 50 | 51 | (deftext :thlack.surfs.blocks.components.spec.input-child/label (s/or :string ::strings.spec/string :text ::input/label) 2000) 52 | 53 | (s/def ::input.child 54 | (s/or :hint ::comp.spec/plain-text 55 | :element ::input/element)) 56 | 57 | (s/def ::input.children 58 | (s/with-gen 59 | (s/+ ::input.child) 60 | #(gen/fmap 61 | (fn [input] 62 | (vals (select-keys input [:hint :element]))) 63 | (s/gen ::blocks.spec/input)))) 64 | 65 | (s/def ::input.props (s/merge ::block.props (s/keys :opt-un [::input/dispatch_action ::input/optional]))) 66 | 67 | (defn input-props? 68 | "Predicate for seeing if the given props map constitutes input block props" 69 | [props] 70 | (if (map? props) 71 | (-> props 72 | (select-keys [:block_id :dispatch_action :optional]) 73 | (seq) 74 | (some?)) 75 | false)) 76 | 77 | (s/fdef input-props? 78 | :args any? 79 | :ret boolean?) 80 | -------------------------------------------------------------------------------- /src/thlack/surfs/blocks/spec.clj: -------------------------------------------------------------------------------- 1 | (ns ^:no-doc thlack.surfs.blocks.spec 2 | "https://api.slack.com/reference/block-kit/block-elements#checkboxes" 3 | (:require [clojure.spec.alpha :as s] 4 | [thlack.surfs.blocks.spec.actions :as actions] 5 | [thlack.surfs.blocks.spec.context :as context] 6 | [thlack.surfs.blocks.spec.divider :as divider] 7 | [thlack.surfs.blocks.spec.file :as file] 8 | [thlack.surfs.blocks.spec.header :as header] 9 | [thlack.surfs.blocks.spec.image :as image] 10 | [thlack.surfs.blocks.spec.input :as input] 11 | [thlack.surfs.blocks.spec.section :as section] 12 | [thlack.surfs.strings.spec :as strings.spec])) 13 | 14 | (s/def ::fields (s/keys :req-un [::section/fields])) 15 | 16 | (s/def ::section (s/or 17 | :text (s/keys :req-un [::section/type ::section/text] :opt-un [::strings.spec/block_id ::section/accessory ::section/fields]) 18 | :fields (s/keys :req-un [::section/type ::section/fields] :opt-un [::strings.spec/block_id ::section/accessory ::section/text]))) 19 | 20 | (s/def ::context (s/keys :req-un [::context/type ::context/elements] :opt-un [::strings.spec/block_id])) 21 | 22 | (s/def ::actions (s/keys :req-un [::actions/type ::actions/elements] :opt-un [::strings.spec/block_id])) 23 | 24 | (s/def ::divider (s/keys :req-un [::divider/type] :opt-un [::strings.spec/block_id])) 25 | 26 | ;;; file blocks are not directly instantiable at the moment - the spec is defined here even though no component will 27 | ;;; be defined for rendering. 28 | 29 | (s/def ::file (s/keys :req-un [::file/type ::file/external_id ::file/source] :opt-un [::strings.spec/block_id])) 30 | 31 | (s/def ::header (s/keys :req-un [::header/type ::header/text] :opt-un [::strings.spec/block_id])) 32 | 33 | (s/def ::image (s/keys :req-un [::image/type ::image/image_url ::image/alt_text] :opt-un [::image/title ::strings.spec/block_id])) 34 | 35 | (s/def ::input (s/keys :req-un [::input/type ::input/label ::input/element] :opt-un [::input/dispatch_action ::strings.spec/block_id ::input/hint ::input/optional])) 36 | 37 | (s/def ::block 38 | (s/or :actions ::actions 39 | :context ::context 40 | :divider ::divider 41 | :file ::file 42 | :header ::header 43 | :image ::image 44 | :input ::input 45 | :section ::section)) 46 | -------------------------------------------------------------------------------- /src/thlack/surfs/blocks/spec/actions.clj: -------------------------------------------------------------------------------- 1 | (ns ^:no-doc thlack.surfs.blocks.spec.actions 2 | (:require [clojure.spec.alpha :as s] 3 | [thlack.surfs.elements.spec :as elements.spec])) 4 | 5 | (s/def ::type #{:actions}) 6 | 7 | (s/def ::element 8 | (s/or :button ::elements.spec/button 9 | :checkboxes ::elements.spec/checkboxes 10 | :plain-text-input ::elements.spec/plain-text-input 11 | :radio-buttons ::elements.spec/radio-buttons 12 | :overflow ::elements.spec/overflow 13 | :datepicker ::elements.spec/datepicker 14 | :timepicker ::elements.spec/timepicker 15 | :static-select ::elements.spec/static-select 16 | :external-select ::elements.spec/external-select 17 | :users-select ::elements.spec/users-select 18 | :conversations-select ::elements.spec/conversations-select 19 | :channels-select ::elements.spec/channels-select)) 20 | 21 | (s/def ::elements (s/coll-of ::element :into [] :min-count 1 :max-count 5 :gen-max 5)) 22 | -------------------------------------------------------------------------------- /src/thlack/surfs/blocks/spec/context.clj: -------------------------------------------------------------------------------- 1 | (ns ^:no-doc thlack.surfs.blocks.spec.context 2 | (:require [clojure.spec.alpha :as s] 3 | [thlack.surfs.composition.spec :as comp.spec] 4 | [thlack.surfs.elements.spec :as elements.spec])) 5 | 6 | (s/def ::type #{:context}) 7 | 8 | (s/def ::element (s/or :image ::elements.spec/image :text ::comp.spec/text)) 9 | 10 | (s/def ::elements (s/coll-of ::element :into [] :min-count 1 :max-count 10 :gen-max 5)) 11 | -------------------------------------------------------------------------------- /src/thlack/surfs/blocks/spec/divider.clj: -------------------------------------------------------------------------------- 1 | (ns ^:no-doc thlack.surfs.blocks.spec.divider 2 | (:require [clojure.spec.alpha :as s])) 3 | 4 | (s/def ::type #{:divider}) 5 | -------------------------------------------------------------------------------- /src/thlack/surfs/blocks/spec/file.clj: -------------------------------------------------------------------------------- 1 | (ns ^:no-doc thlack.surfs.blocks.spec.file 2 | (:require [clojure.spec.alpha :as s] 3 | [thlack.surfs.strings.spec :as strings.spec])) 4 | 5 | (s/def ::type #{:file}) 6 | 7 | (s/def ::external_id ::strings.spec/string) 8 | 9 | (s/def ::source #{:remote}) 10 | -------------------------------------------------------------------------------- /src/thlack/surfs/blocks/spec/header.clj: -------------------------------------------------------------------------------- 1 | (ns ^:no-doc thlack.surfs.blocks.spec.header 2 | (:require [clojure.spec.alpha :as s] 3 | [thlack.surfs.composition.spec :as comp.spec] 4 | [thlack.surfs.strings.spec :refer [deftext]])) 5 | 6 | (s/def ::type #{:header}) 7 | 8 | (deftext ::text ::comp.spec/plain-text 150) 9 | -------------------------------------------------------------------------------- /src/thlack/surfs/blocks/spec/image.clj: -------------------------------------------------------------------------------- 1 | (ns ^:no-doc thlack.surfs.blocks.spec.image 2 | (:require [clojure.spec.alpha :as s] 3 | [thlack.surfs.composition.spec :as comp.spec] 4 | [thlack.surfs.strings.spec :as strings.spec :refer [deftext]])) 5 | 6 | (s/def ::type #{:image}) 7 | 8 | (deftext ::image_url ::strings.spec/url-string 3000) 9 | 10 | (deftext ::alt_text ::strings.spec/string 2000) 11 | 12 | (deftext ::title ::comp.spec/plain-text 2000) 13 | -------------------------------------------------------------------------------- /src/thlack/surfs/blocks/spec/input.clj: -------------------------------------------------------------------------------- 1 | (ns ^:no-doc thlack.surfs.blocks.spec.input 2 | (:require [clojure.spec.alpha :as s] 3 | [thlack.surfs.composition.spec :as comp.spec] 4 | [thlack.surfs.elements.spec :as elements.spec] 5 | [thlack.surfs.strings.spec :refer [deftext]])) 6 | 7 | (s/def ::type #{:input}) 8 | 9 | (deftext ::label ::comp.spec/plain-text 2000) 10 | 11 | (s/def ::element 12 | (s/or :plain-text-input ::elements.spec/plain-text-input 13 | :checkboxes ::elements.spec/checkboxes 14 | :radio-buttons ::elements.spec/radio-buttons 15 | :datepicker ::elements.spec/datepicker 16 | :timepicker ::elements.spec/timepicker 17 | :multi-static-select ::elements.spec/multi-static-select 18 | :multi-external-select ::elements.spec/multi-external-select 19 | :multi-users-select ::elements.spec/multi-users-select 20 | :multi-conversations-select ::elements.spec/multi-conversations-select 21 | :multi-channels-select ::elements.spec/multi-channels-select 22 | :plain-text-input ::elements.spec/plain-text-input 23 | :radio-buttons ::elements.spec/radio-buttons 24 | :static-select ::elements.spec/static-select 25 | :external-select ::elements.spec/external-select 26 | :users-select ::elements.spec/users-select 27 | :conversations-select ::elements.spec/conversations-select 28 | :channels-select ::elements.spec/channels-select)) 29 | 30 | (s/def ::dispatch_action boolean?) 31 | 32 | (deftext ::hint ::comp.spec/plain-text 2000) 33 | 34 | (s/def ::optional boolean?) 35 | -------------------------------------------------------------------------------- /src/thlack/surfs/blocks/spec/section.clj: -------------------------------------------------------------------------------- 1 | (ns ^:no-doc thlack.surfs.blocks.spec.section 2 | (:require [clojure.spec.alpha :as s] 3 | [thlack.surfs.composition.spec :as comp.spec] 4 | [thlack.surfs.elements.spec :as elements.spec] 5 | [thlack.surfs.strings.spec :as strings.spec :refer [deftext]])) 6 | 7 | (s/def ::type #{:section}) 8 | 9 | (deftext ::text ::comp.spec/text 3000) 10 | 11 | (s/def ::fields (s/coll-of (s/and ::comp.spec/text (strings.spec/max-len 2000)) :into [] :min-count 1 :max-count 10 :gen-max 5)) 12 | 13 | (s/def ::accessory 14 | (s/or :checkboxes ::elements.spec/checkboxes 15 | :datepicker ::elements.spec/datepicker 16 | :timepicker ::elements.spec/timepicker 17 | :multi-static-select ::elements.spec/multi-static-select 18 | :multi-external-select ::elements.spec/multi-external-select 19 | :multi-users-select ::elements.spec/multi-users-select 20 | :multi-conversations-select ::elements.spec/multi-conversations-select 21 | :multi-channels-select ::elements.spec/multi-channels-select 22 | :plain-text-input ::elements.spec/plain-text-input 23 | :radio-buttons ::elements.spec/radio-buttons 24 | :static-select ::elements.spec/static-select 25 | :external-select ::elements.spec/external-select 26 | :users-select ::elements.spec/users-select 27 | :conversations-select ::elements.spec/conversations-select 28 | :channels-select ::elements.spec/channels-select 29 | :overflow ::elements.spec/overflow 30 | :button ::elements.spec/button 31 | :image ::elements.spec/image)) 32 | -------------------------------------------------------------------------------- /src/thlack/surfs/composition/components.clj: -------------------------------------------------------------------------------- 1 | (ns thlack.surfs.composition.components 2 | "A hiccup-like interface for creating views in Slack applications via blocks. 3 | https://api.slack.com/reference/block-kit/blocks" 4 | (:require [clojure.spec.alpha :as s] 5 | [thlack.surfs.props :as props] 6 | [thlack.surfs.props.spec :as props.spec] 7 | [thlack.surfs.validation :refer [validated]] 8 | [thlack.surfs.composition.spec :as comp.spec] 9 | [thlack.surfs.composition.spec.option-group :as option-group] 10 | [thlack.surfs.composition.components.spec :as cc.spec] 11 | [thlack.surfs.strings.spec :as strings.spec])) 12 | 13 | (defn- create-text 14 | [props-or-string] 15 | (if (string? props-or-string) 16 | {:type :plain_text :text props-or-string} 17 | props-or-string)) 18 | 19 | (defn text 20 | "An object containing some text, formatted either as plain_text or using mrkdwn. 21 | 22 | If a map of props is given, it will be used to construct a text object. 23 | Otherwise a string literal is assumed and a default plain text object will be created. 24 | 25 | Component usage: 26 | 27 | ```clojure 28 | [:text \"Hello\"] 29 | 30 | [:text {:type :mrkdwn :verbatim false :text \"# Hello\"}] 31 | 32 | [:text {:type :plain_text :emoji true :text \"Hello\"}] 33 | ```" 34 | [props] 35 | (-> props 36 | (create-text) 37 | (validated ::comp.spec/text))) 38 | 39 | (s/fdef text 40 | :args (s/cat :props ::props.spec/text) 41 | :ret ::comp.spec/text) 42 | 43 | (defn plain-text 44 | "Explicitly creates a plain text object. 45 | 46 | Component usage: 47 | 48 | ```clojure 49 | [:plain-text \"Hello\"] 50 | 51 | [:plain-text \"Goodbye\" false] 52 | 53 | [:plain-text {:text \"Greetings\" :emoji false}] 54 | ```" 55 | ([txt emoji?] 56 | (-> txt 57 | (create-text) 58 | (assoc :type :plain_text) 59 | (assoc :emoji emoji?) 60 | (validated ::comp.spec/plain-text))) 61 | ([text] 62 | (plain-text text (get text :emoji true)))) 63 | 64 | (s/fdef plain-text 65 | :args (s/cat :txt ::props.spec/text :emoji? (s/? boolean?)) 66 | :ret ::comp.spec/plain-text) 67 | 68 | (defn markdown 69 | "Explicitly creates a markdown text object. 70 | 71 | Component usage: 72 | 73 | ```clojure 74 | [:markdown \"# Hello\"] 75 | 76 | [:markdown \"# Goodbye\" true] 77 | 78 | [:markdown {:text \"# Greetings\" :verbatim true}] 79 | ```" 80 | ([txt verbatim?] 81 | (-> txt 82 | (create-text) 83 | (assoc :type :mrkdwn) 84 | (assoc :verbatim verbatim?) 85 | (validated ::comp.spec/mrkdwn))) 86 | ([text] 87 | (markdown text (get text :verbatim false)))) 88 | 89 | (s/fdef markdown 90 | :args (s/cat :txt ::props.spec/text :verbatim? (s/? boolean?)) 91 | :ret ::comp.spec/mrkdwn) 92 | 93 | (defn with-text 94 | "Updates the given keys to conform to a text element. Will run the given keys through 95 | the (text) function if they are present in the set of props." 96 | [props ks] 97 | (reduce-kv 98 | (fn [m k v] 99 | (if (and (some? (ks k))) 100 | (assoc m k (text v)) 101 | (assoc m k v))) 102 | {} 103 | props)) 104 | 105 | (defn confirm 106 | "An object that defines a dialog that provides a confirmation step to any interactive element. 107 | 108 | Component usage: 109 | 110 | ```clojure 111 | [:confirm {:confirm \"Ok!\" :deny \"Nah!\" :title \"This is a title!\"} 112 | [:text \"Are you sure?\"]] 113 | 114 | [:confirm {:confirm \"Ok!\" :deny \"Nah!\" :title \"This is a title!\"} \"Are you sure?\"] 115 | ```" 116 | [props txt] 117 | (-> props 118 | (props/with-plain-text #{:confirm :deny :title}) 119 | (assoc :text (text txt)) 120 | (assoc :style (get props :style :primary)) 121 | (validated ::comp.spec/confirm))) 122 | 123 | (s/fdef confirm 124 | :args (s/cat :props ::cc.spec/confirm.props :txt (strings.spec/with-max-gen ::props.spec/text 300)) 125 | :ret ::comp.spec/confirm) 126 | 127 | (defn option 128 | "An object that represents a single selectable item in a select menu, multi-select menu, 129 | checkbox group, radio button group, or overflow menu. 130 | 131 | Component usage: 132 | 133 | ```clojure 134 | [:option {:value \"1\"} \"Label\"] 135 | 136 | [:option {:value \"1\"} {:type :plain_text :text \"Label\"}] 137 | 138 | [:option {:value \"1\" :description \"Oh hello\"} \"Label\"] 139 | ``` 140 | 141 | Options used in elements supporting initial_option(s), also support a :selected? 142 | property." 143 | [props txt] 144 | (-> props 145 | (props/with-plain-text #{:description}) 146 | (assoc :text (text txt)) 147 | (validated ::comp.spec/option))) 148 | 149 | (s/fdef option 150 | :args (s/cat :props ::cc.spec/option.props :txt (strings.spec/with-max-gen ::props.spec/plain-text 75)) 151 | :ret ::comp.spec/option) 152 | 153 | (defn option-group 154 | "Provides a way to group options in a select menu or multi-select menu. 155 | 156 | Component usage: 157 | 158 | ```clojure 159 | [:option-group 160 | [:label \"Pizza Toppings\"] 161 | [:option {:value \"1\"} \"Mushrooms\"] 162 | [:option {:value \"2\"} \"Pepperoni\"]] 163 | ```" 164 | [label & children] 165 | (validated {:label label 166 | :options (props/flatten-children children)} ::comp.spec/option-group)) 167 | 168 | (s/fdef option-group 169 | :args (s/cat :label ::option-group/label :options ::cc.spec/option-group.children) 170 | :ret ::comp.spec/option-group) 171 | -------------------------------------------------------------------------------- /src/thlack/surfs/composition/components/spec.clj: -------------------------------------------------------------------------------- 1 | (ns ^:no-doc thlack.surfs.composition.components.spec 2 | (:require [clojure.spec.alpha :as s] 3 | [thlack.surfs.composition.spec :as comp.spec] 4 | [thlack.surfs.composition.spec.confirm :as confirm] 5 | [thlack.surfs.composition.spec.option :as option] 6 | [thlack.surfs.composition.spec.option-group :as option-group] 7 | [thlack.surfs.props.spec :as props.spec] 8 | [thlack.surfs.strings.spec :as strings.spec :refer [deftext]])) 9 | 10 | ;;; [:confirm] 11 | 12 | (deftext :thlack.surfs.composition.components.spec.confirm-props/confirm ::strings.spec/string 30) 13 | 14 | (deftext :thlack.surfs.composition.components.spec.confirm-props/deny ::strings.spec/string 30) 15 | 16 | (deftext :thlack.surfs.composition.components.spec.confirm-props/title ::strings.spec/string 100) 17 | 18 | (s/def ::confirm.props (s/keys :req-un [:thlack.surfs.composition.components.spec.confirm-props/confirm 19 | :thlack.surfs.composition.components.spec.confirm-props/deny 20 | :thlack.surfs.composition.components.spec.confirm-props/title] 21 | :opt-un [::confirm/style 22 | ::props.spec/disable_emoji_for])) 23 | 24 | ;;; [:option] 25 | 26 | (deftext :thlack.surfs.composition.components.spec.option-props/description ::strings.spec/string 75) 27 | 28 | (s/def ::option.props (s/keys :req-un [::option/value] :opt-un [:thlack.surfs.composition.components.spec.option-props/description 29 | ::props.spec/disable_emoji_for])) 30 | (s/def ::option-group.children 31 | (s/with-gen 32 | (s/+ ::comp.spec/option) 33 | #(s/gen ::option-group/options))) 34 | -------------------------------------------------------------------------------- /src/thlack/surfs/composition/spec.clj: -------------------------------------------------------------------------------- 1 | (ns ^:no-doc thlack.surfs.composition.spec 2 | "This namespace contains specs for what Slack refers to as 3 | composition objects" 4 | (:require [clojure.spec.alpha :as s] 5 | [thlack.surfs.composition.spec.plain-text :as plain-text] 6 | [thlack.surfs.composition.spec.mrkdwn :as mrkdwn] 7 | [thlack.surfs.composition.spec.confirm :as confirm] 8 | [thlack.surfs.composition.spec.dispatch-action-config :as dispatch-action-config] 9 | [thlack.surfs.composition.spec.option :as option] 10 | [thlack.surfs.composition.spec.option-group :as option-group])) 11 | 12 | (s/def ::plain-text ::plain-text/plain-text) 13 | 14 | (s/def ::mrkdwn ::mrkdwn/mrkdwn) 15 | 16 | (s/def ::text (s/or :plain_text ::plain-text :mrkdwn ::mrkdwn)) 17 | 18 | (s/def ::confirm (s/keys :req-un [::confirm/title ::confirm/text ::confirm/confirm ::confirm/deny] :opt-un [::confirm/style])) 19 | 20 | (s/def ::dispatch_action_config (s/keys :req-un [::dispatch-action-config/trigger_actions_on])) 21 | 22 | (s/def ::option ::option/option) 23 | 24 | (s/def ::option-group (s/keys :req-un [::option-group/label ::option-group/options])) 25 | -------------------------------------------------------------------------------- /src/thlack/surfs/composition/spec/confirm.clj: -------------------------------------------------------------------------------- 1 | (ns ^:no-doc thlack.surfs.composition.spec.confirm 2 | (:require [clojure.spec.alpha :as s] 3 | [thlack.surfs.composition.spec.plain-text :as plain-text] 4 | [thlack.surfs.composition.spec.mrkdwn :as mrkdwn] 5 | [thlack.surfs.strings.spec :refer [deftext]])) 6 | 7 | (s/def ::text* (s/or :plain_text ::plain-text/plain-text :mrkdwn ::mrkdwn/mrkdwn)) 8 | 9 | (deftext ::title ::plain-text/plain-text 100) 10 | 11 | (deftext ::text ::text* 300) 12 | 13 | (deftext ::confirm ::plain-text/plain-text 30) 14 | 15 | (deftext ::deny ::plain-text/plain-text 30) 16 | 17 | (s/def ::style #{:primary :danger}) 18 | -------------------------------------------------------------------------------- /src/thlack/surfs/composition/spec/conversation.clj: -------------------------------------------------------------------------------- 1 | (ns ^:no-doc thlack.surfs.composition.spec.conversation 2 | (:require [clojure.spec.alpha :as s])) 3 | 4 | (s/def ::include (s/coll-of #{:im :mpim :private :public} :distinct true :into [] :gen-max 4 :min-count 1)) 5 | 6 | (s/def ::exclude_external_shared_channels boolean?) 7 | 8 | (s/def ::exclude_bot_users boolean?) 9 | 10 | (s/def ::filter (s/keys :opt-un [::include ::exclude_external_shared_channels ::exclude_bot_users])) 11 | -------------------------------------------------------------------------------- /src/thlack/surfs/composition/spec/dispatch_action_config.clj: -------------------------------------------------------------------------------- 1 | (ns ^:no-doc thlack.surfs.composition.spec.dispatch-action-config 2 | (:require [clojure.spec.alpha :as s])) 3 | 4 | (s/def ::trigger_actions_on (s/coll-of #{:on_enter_pressed :on_character_entered} :min-count 1 :distinct true :gen-max 2 :into [])) 5 | -------------------------------------------------------------------------------- /src/thlack/surfs/composition/spec/mrkdwn.clj: -------------------------------------------------------------------------------- 1 | (ns ^:no-doc thlack.surfs.composition.spec.mrkdwn 2 | (:require [clojure.spec.alpha :as s] 3 | [thlack.surfs.composition.spec.text :as text])) 4 | 5 | (s/def ::type #{:mrkdwn}) 6 | 7 | (s/def ::mrkdwn (s/keys :req-un [::type ::text/text] :opt-un [::text/verbatim])) 8 | -------------------------------------------------------------------------------- /src/thlack/surfs/composition/spec/option.clj: -------------------------------------------------------------------------------- 1 | (ns ^:no-doc thlack.surfs.composition.spec.option 2 | (:require [clojure.spec.alpha :as s] 3 | [thlack.surfs.composition.spec.plain-text :as plain-text] 4 | [thlack.surfs.strings.spec :as strings.spec :refer [deftext]])) 5 | 6 | (deftext ::text ::plain-text/plain-text 75) 7 | 8 | (deftext ::value ::strings.spec/string 75) 9 | 10 | (deftext ::description ::plain-text/plain-text 75) 11 | 12 | (s/def ::option (s/keys :req-un [::text ::value] :opt-un [::description])) 13 | -------------------------------------------------------------------------------- /src/thlack/surfs/composition/spec/option_group.clj: -------------------------------------------------------------------------------- 1 | (ns ^:no-doc thlack.surfs.composition.spec.option-group 2 | (:require [clojure.spec.alpha :as s] 3 | [thlack.surfs.composition.spec.plain-text :as plain-text] 4 | [thlack.surfs.composition.spec.option :as option] 5 | [thlack.surfs.strings.spec :refer [deftext]])) 6 | 7 | (deftext ::label ::plain-text/plain-text 75) 8 | 9 | (s/def ::options (s/coll-of ::option/option :min-count 1 :max-count 100 :into [] :gen-max 10)) 10 | -------------------------------------------------------------------------------- /src/thlack/surfs/composition/spec/plain_text.clj: -------------------------------------------------------------------------------- 1 | (ns ^:no-doc thlack.surfs.composition.spec.plain-text 2 | (:require [clojure.spec.alpha :as s] 3 | [thlack.surfs.composition.spec.text :as text])) 4 | 5 | (s/def ::type #{:plain_text}) 6 | 7 | (s/def ::plain-text (s/keys :req-un [::type ::text/text] :opt-un [::text/emoji])) 8 | -------------------------------------------------------------------------------- /src/thlack/surfs/composition/spec/text.clj: -------------------------------------------------------------------------------- 1 | (ns ^:no-doc thlack.surfs.composition.spec.text 2 | (:require [clojure.spec.alpha :as s] 3 | [thlack.surfs.strings.spec :as strings.spec])) 4 | 5 | (s/def ::text ::strings.spec/string) 6 | 7 | (s/def ::emoji boolean?) 8 | 9 | (s/def ::verbatim boolean?) 10 | -------------------------------------------------------------------------------- /src/thlack/surfs/elements/components.clj: -------------------------------------------------------------------------------- 1 | (ns thlack.surfs.elements.components 2 | "A hiccup-like interface for creating views in Slack applications via blocks. 3 | https://api.slack.com/reference/block-kit/blocks" 4 | (:require [clojure.spec.alpha :as s] 5 | [clojure.walk :as walk] 6 | [thlack.surfs.props :as props] 7 | [thlack.surfs.validation :refer [validated]] 8 | [thlack.surfs.elements.spec :as elements.spec] 9 | [thlack.surfs.elements.components.spec :as ec.spec] 10 | [thlack.surfs.composition.components :as comp] 11 | [thlack.surfs.composition.spec]) 12 | (:import [java.util UUID])) 13 | 14 | ;;; Action IDs 15 | 16 | (defn- with-action-id 17 | "Provides a random uuid string if not already present in props." 18 | [{:keys [action_id] :as props}] 19 | (if (some? action_id) 20 | props 21 | (assoc props :action_id (str (UUID/randomUUID))))) 22 | 23 | ;;; options and option-groups 24 | 25 | (defn- assoc-initial-options 26 | "Look for options that have a key of :selected? set to true. Options marked as selected 27 | will be used to create initial_option(s)." 28 | [{:keys [type options option_groups] :as element :or {options [] option_groups []}}] 29 | (if-let [selected (seq (filter :selected? (concat options (mapcat :options option_groups))))] 30 | (condp some [type] 31 | #{:static_select :external_select :radio_buttons} 32 | (assoc element :initial_option (first selected)) 33 | #{:checkboxes :multi_static_select :multi_external_select} 34 | (assoc element :initial_options selected)) 35 | element)) 36 | 37 | (defn- conform-options 38 | "Removers userland keys such as :selected? so Slack doesn't 39 | error on us for unknown keys." 40 | [option-element] 41 | (walk/postwalk 42 | (fn [x] 43 | (if (map? x) 44 | (dissoc x :selected?) 45 | x)) 46 | option-element)) 47 | 48 | (defn- force-selected 49 | "Forces all options to be selected. This is the case for options provided 50 | to external selects." 51 | [option-element] 52 | (cond-> option-element 53 | (contains? option-element :options) 54 | (update :options (partial map #(assoc % :selected? true))) 55 | (contains? option-element :option_groups) 56 | (update :option_groups (partial map #(force-selected %))) 57 | :always 58 | (identity))) 59 | 60 | ;;; Elements 61 | 62 | (defn button 63 | "An interactive component that inserts a button. The button can be a trigger for 64 | anything from opening a simple link to starting a complex workflow. 65 | 66 | Component usage: 67 | 68 | ```clojure 69 | [:button {:action_id \"A123\" :value \"1\"} \"Click Me!\"] 70 | ``` 71 | 72 | Without props: 73 | 74 | ```clojure 75 | [:button \"Click Me!\"] 76 | ``` 77 | 78 | Omitting an action id for a button almost never makes sense. The action id may be 79 | ignored when using a url button." 80 | [& args] 81 | (let [[props & children] (props/parse-args args)] 82 | (-> (assoc props :type :button) 83 | (with-action-id) 84 | (props/with-children children ::ec.spec/button.child) 85 | (comp/with-text #{:text}) 86 | (validated ::elements.spec/button)))) 87 | 88 | (s/fdef button 89 | :args (s/alt :props-and-children (s/cat :props ::ec.spec/button.props :children ::ec.spec/button.children) 90 | :children (s/cat :children ::ec.spec/button.children)) 91 | :ret ::elements.spec/button) 92 | 93 | (defn checkboxes 94 | "A checkbox group that allows a user to choose multiple items from a list of possible options. 95 | 96 | Component usage: 97 | 98 | ```clojure 99 | [:checkboxes {:action_id \"A123\"} 100 | [:option {:value \"1\"} \"Mushrooms\"] 101 | [:option {:value \"2\" :selected? true} \"Pepperoni\"]] 102 | ```" 103 | [props & children] 104 | (-> (assoc props :type :checkboxes) 105 | (props/with-children children ::ec.spec/checkboxes.child) 106 | (assoc-initial-options) 107 | (validated ::elements.spec/checkboxes) 108 | (conform-options))) 109 | 110 | (s/fdef checkboxes 111 | :args (s/cat :props ::ec.spec/checkboxes.props :children ::ec.spec/checkboxes.children) 112 | :ret ::elements.spec/checkboxes) 113 | 114 | (defn datepicker 115 | "An element which lets users easily select a date from a calendar style UI. 116 | 117 | Component usage: 118 | 119 | ```clojure 120 | [:datepicker {:action_id \"A123\" :initial_date \"2020-11-30\"} 121 | [:placeholder \"The date\"]] 122 | ```" 123 | [props & children] 124 | (-> (assoc props :type :datepicker) 125 | (props/with-children children ::ec.spec/datepicker.child) 126 | (validated ::elements.spec/datepicker))) 127 | 128 | (s/fdef datepicker 129 | :args (s/cat :props ::ec.spec/datepicker.props :children (s/* ::ec.spec/datepicker.child)) 130 | :ret ::elements.spec/datepicker) 131 | 132 | (defn timepicker 133 | "An element which allows selection of a time of day. 134 | 135 | Component usage: 136 | 137 | ```clojure 138 | [:timepicker {:action_id \"A123\" :initial_time \"12:30\"} 139 | [:placeholder \"The time\"]] 140 | ```" 141 | [props & children] 142 | (-> (assoc props :type :timepicker) 143 | (props/with-children children ::ec.spec/timepicker.child) 144 | (validated ::elements.spec/timepicker))) 145 | 146 | (s/fdef timepicker 147 | :args (s/cat :props ::ec.spec/timepicker.props :children (s/* ::ec.spec/timepicker.child)) 148 | :ret ::elements.spec/timepicker) 149 | 150 | (defn img 151 | "Render function for the image element - not the image layout block. Block kit 152 | defines both, and this function is named after the img html element to differentiate 153 | between the two. 154 | 155 | An element to insert an image as part of a larger block of content. 156 | 157 | Component usage: 158 | 159 | ```clojure 160 | [:img {:image_url \"http://www.fillmurray.com/200/300\" :alt_text \"It's Bill Murray\"}] 161 | ```" 162 | [props] 163 | (-> props 164 | (assoc :type :image) 165 | (validated ::elements.spec/image))) 166 | 167 | (s/fdef img 168 | :args (s/cat :props ::ec.spec/img.props) 169 | :ret ::elements.spec/image) 170 | 171 | (defn multi-static-select 172 | "A multi-select menu allows a user to select multiple items from a list of options. 173 | 174 | Component usage: 175 | 176 | ```clojure 177 | [:multi-static-select {:action_id \"A123\" :max_selected_items 5} 178 | [:placeholder \"Pizza Toppings\"] 179 | [:option {:value \"1\"} \"Mushrooms\"] 180 | [:option {:value \"2\" :selected? true} \"Pepperoni\"] 181 | [:option {:value \"3\" :selected? true} \"Cheese\"]] 182 | ``` 183 | 184 | Supports option groups as well: 185 | 186 | ```clojure 187 | [:multi-static-select {:action_id \"A123\" :max_selected_items 5} 188 | [:placeholder \"Pizza Toppings\"] 189 | [:option-group 190 | [:label \"Veggies\"] 191 | [:option {:value \"1\"} \"Mushrooms\"] 192 | [:option {:value \"2\" :selected? true} \"Peppers\"]] 193 | [:option-group 194 | [:label \"Meats\"] 195 | [:option {:value \"3\"} \"Pepperoni\"] 196 | [:option {:value \"4\" :selected? true} \"Ham\"]]] 197 | ```" 198 | [props & children] 199 | (-> (assoc props :type :multi_static_select) 200 | (props/with-children children ::ec.spec/multi-static-select.child) 201 | (assoc-initial-options) 202 | (validated ::elements.spec/multi-static-select) 203 | (conform-options))) 204 | 205 | (s/fdef multi-static-select 206 | :args (s/cat :props ::ec.spec/multi-select.props :children ::ec.spec/multi-static-select.children) 207 | :ret ::elements.spec/multi-static-select) 208 | 209 | (defn multi-external-select 210 | "This menu will load its options from an external data source, allowing for a dynamic list of options. 211 | External selects will treat all options as initial_options, regardless of whether or not the :selected? 212 | prop is given. 213 | 214 | Component usage: 215 | 216 | ```clojure 217 | [:multi-external-select {:action_id \"A123\" :max_selected_items 5 :min_query_length 3} 218 | [:placeholder \"Pizza Toppings\"] 219 | [:option {:value \"1\"} \"Pepperoni\"] 220 | [:option {:value \"2\"} \"Mushrooms\"]] 221 | ```" 222 | [props & children] 223 | (-> (assoc props :type :multi_external_select) 224 | (props/with-children children ::ec.spec/multi-static-select.child) 225 | (force-selected) 226 | (assoc-initial-options) 227 | (dissoc :option_groups :options) 228 | (validated ::elements.spec/multi-external-select) 229 | (conform-options))) 230 | 231 | (s/fdef multi-external-select 232 | :args (s/cat :props ::ec.spec/multi-external-select.props :children ::ec.spec/multi-static-select.children) 233 | :ret ::elements.spec/multi-external-select) 234 | 235 | (defn multi-users-select 236 | "This multi-select menu will populate its options with a list of Slack users visible to the current user in the active workspace. 237 | 238 | Component usage: 239 | 240 | ```clojure 241 | [:multi-users-select {:action_id \"A123\" :max_selected_items 3 :initial_users [\"U123\" \"U456\"]} 242 | [:placeholder \"Team captains\"]] 243 | ```" 244 | [props & children] 245 | (-> (assoc props :type :multi_users_select) 246 | (props/with-children children ::ec.spec/slack-select.child) 247 | (validated ::elements.spec/multi-users-select))) 248 | 249 | (s/fdef multi-users-select 250 | :args (s/cat :props ::ec.spec/multi-users-select.props :children ::ec.spec/slack-select.children) 251 | :ret ::elements.spec/multi-users-select) 252 | 253 | (defn multi-conversations-select 254 | "This multi-select menu will populate its options with a list of public and private channels, DMs, and MPIMs visible to the current user in the active workspace. 255 | 256 | Component usage: 257 | 258 | ```clojure 259 | [:multi-conversations-select {:action_id \"A123\" 260 | :max_selected_items 3 261 | :default_to_current_conversation true 262 | :initial_conversations [\"C123\" \"C456\"] 263 | :filter {:include #{:private} 264 | :exclude_bot_users true 265 | :exclude_external_shared_channels true}} 266 | [:placeholder \"Select conversation\"]] 267 | ```" 268 | [props & children] 269 | (-> (assoc props :type :multi_conversations_select) 270 | (props/with-children children ::ec.spec/slack-select.child) 271 | (validated ::elements.spec/multi-conversations-select))) 272 | 273 | (s/fdef multi-conversations-select 274 | :args (s/cat :props ::ec.spec/multi-conversations-select.props :children ::ec.spec/slack-select.children) 275 | :ret ::elements.spec/multi-conversations-select) 276 | 277 | (defn multi-channels-select 278 | "This multi-select menu will populate its options with a list of public channels visible to the current user in the active workspace. 279 | 280 | Component usage: 281 | 282 | ```clojure 283 | [:multi-channels-select {:action_id \"A123\" :max_selected_items 3 :initial_channels [\"C123\" \"C456\"]} 284 | [:placeholder \"Select channel\"]] 285 | ```" 286 | [props & children] 287 | (-> (assoc props :type :multi_channels_select) 288 | (props/with-children children ::ec.spec/slack-select.child) 289 | (validated ::elements.spec/multi-channels-select))) 290 | 291 | (s/fdef multi-channels-select 292 | :args (s/cat :props ::ec.spec/multi-channels-select.props :children ::ec.spec/slack-select.children) 293 | :ret ::elements.spec/multi-channels-select) 294 | 295 | (defn static-select 296 | "This is the simplest form of select menu, with a static list of options passed in when defining the element. 297 | 298 | Component usage: 299 | 300 | ```clojure 301 | [:static-select {:action_id \"A123\"} 302 | [:placeholder \"Pizza Toppings\"] 303 | [:option {:value \"1\"} \"Mushrooms\"] 304 | [:option {:value \"2\" :selected? true} \"Pepperoni\"] 305 | [:option {:value \"3\"} \"Cheese\"]] 306 | ``` 307 | 308 | Supports option groups as well: 309 | 310 | ```clojure 311 | [:static-select {:action_id \"A123\"} 312 | [:placeholder \"Pizza Toppings\"] 313 | [:option-group 314 | [:label \"Veggies\"] 315 | [:option {:value \"1\"} \"Mushrooms\"] 316 | [:option {:value \"2\" :selected? true} \"Peppers\"]] 317 | [:option-group 318 | [:label \"Meats\"] 319 | [:option {:value \"3\"} \"Pepperoni\"] 320 | [:option {:value \"4\"} \"Ham\"]]] 321 | ```" 322 | [props & children] 323 | (-> (assoc props :type :static_select) 324 | (props/with-children children ::ec.spec/static-select.child) 325 | (assoc-initial-options) 326 | (validated ::elements.spec/static-select) 327 | (conform-options))) 328 | 329 | (s/fdef static-select 330 | :args (s/cat :props ::ec.spec/static-select.props :children ::ec.spec/static-select.children) 331 | :ret ::elements.spec/static-select) 332 | 333 | (defn external-select 334 | "This select menu will load its options from an external data source, allowing for a dynamic list of options. 335 | External selects will treat all options as initial_options, regardless of whether or not the :selected? 336 | prop is given. 337 | 338 | Component usage: 339 | 340 | ```clojure 341 | [:external-select {:action_id \"A123\" :min_query_length 3} 342 | [:placeholder \"Pizza Toppings\"] 343 | [:option {:value \"1\"} \"Pepperoni\"]] 344 | ```" 345 | [props & children] 346 | (-> (assoc props :type :external_select) 347 | (props/with-children children ::ec.spec/static-select.child) 348 | (force-selected) 349 | (assoc-initial-options) 350 | (dissoc :options) 351 | (validated ::elements.spec/external-select) 352 | (conform-options))) 353 | 354 | (s/fdef external-select 355 | :args (s/cat :props ::ec.spec/external-select.props :children ::ec.spec/static-select.children) 356 | :ret ::elements.spec/external-select) 357 | 358 | (defn users-select 359 | "This select menu will populate its options with a list of Slack users visible to the current user in the active workspace. 360 | 361 | Component usage: 362 | 363 | ```clojure 364 | [:users-select {:action_id \"A123\" :initial_user \"U123\"} 365 | [:placeholder \"Team captain\"]] 366 | ```" 367 | [props & children] 368 | (-> (assoc props :type :users_select) 369 | (props/with-children children ::ec.spec/slack-select.child) 370 | (validated ::elements.spec/users-select))) 371 | 372 | (s/fdef users-select 373 | :args (s/cat :props ::ec.spec/users-select.props :children ::ec.spec/slack-select.children) 374 | :ret ::elements.spec/users-select) 375 | 376 | (defn conversations-select 377 | "This select menu will populate its options with a list of public and private channels, DMs, and MPIMs visible to the current user in the active workspace. 378 | 379 | Component usage: 380 | 381 | ```clojure 382 | [:conversations-select {:action_id \"A123\" 383 | :default_to_current_conversation true 384 | :initial_conversation \"C123\" 385 | :filter {:include #{:private} 386 | :exclude_bot_users true 387 | :exclude_external_shared_channels true}} 388 | [:placeholder \"Select conversation\"]] 389 | ```" 390 | [props & children] 391 | (-> (assoc props :type :conversations_select) 392 | (props/with-children children ::ec.spec/slack-select.child) 393 | (validated ::elements.spec/conversations-select))) 394 | 395 | (s/fdef conversations-select 396 | :args (s/cat :props ::ec.spec/conversations-select.props :children ::ec.spec/slack-select.children) 397 | :ret ::elements.spec/conversations-select) 398 | 399 | (defn channels-select 400 | "This select menu will populate its options with a list of public channels visible to the current user in the active workspace. 401 | 402 | Component usage: 403 | 404 | ```clojure 405 | [:channels-select {:action_id \"A123\" :initial_channel \"C123\"} 406 | [:placeholder \"Select channel\"]] 407 | ```" 408 | [props & children] 409 | (-> (assoc props :type :channels_select) 410 | (props/with-children children ::ec.spec/slack-select.child) 411 | (validated ::elements.spec/channels-select))) 412 | 413 | (s/fdef channels-select 414 | :args (s/cat :props ::ec.spec/channels-select.props :children ::ec.spec/slack-select.children) 415 | :ret ::elements.spec/channels-select) 416 | 417 | (defn overflow 418 | "This is like a cross between a button and a select menu - when a user clicks on this overflow button, 419 | they will be presented with a list of options to choose from. 420 | 421 | Component usage: 422 | 423 | ```clojure 424 | [:overflow {:action_id \"A123\"} 425 | [:option {:value \"1\" :url \"https://google.com\"} \"Google\"] 426 | [:option {:value \"2\" :url \"https://bing.com\"} \"Bing\"] 427 | [:option {:value \"3\" :url \"https://duckduckgo.com\"} \"DuckDuckGo\"]] 428 | ``` 429 | 430 | Without props: 431 | 432 | ```clojure 433 | [:overflow 434 | [:option {:value \"1\" :url \"https://google.com\"} \"Google\"] 435 | [:option {:value \"2\" :url \"https://bing.com\"} \"Bing\"] 436 | [:option {:value \"3\" :url \"https://duckduckgo.com\"} \"DuckDuckGo\"]] 437 | ```" 438 | [& args] 439 | (let [[props & children] (props/parse-args args ::ec.spec/overflow.props*)] 440 | (-> (assoc props :type :overflow) 441 | (with-action-id) 442 | (props/with-children children ::ec.spec/overflow.child) 443 | (validated ::elements.spec/overflow)))) 444 | 445 | (s/fdef overflow 446 | :args (s/alt :props-and-children (s/cat :props ::ec.spec/overflow.props :children ::ec.spec/overflow.children) 447 | :children (s/cat :children ::ec.spec/overflow.children)) 448 | :ret ::elements.spec/overflow) 449 | 450 | (defn plain-text-input 451 | "A plain-text input, similar to the HTML tag, creates a field where a user can enter freeform data. 452 | It can appear as a single-line field or a larger textarea using the multiline flag. 453 | 454 | Component usage: 455 | 456 | ```clojure 457 | [:plain-text-input {:action_id \"A123\" 458 | :initial_value \"hello\" 459 | :multiline true 460 | :min_length 1 461 | :max_length 100 462 | :dispatch_action_config {:trigger_actions_on [:on_enter_pressed]}} 463 | [:placeholder \"Greeting\"]] 464 | ```" 465 | [props & children] 466 | (-> (assoc props :type :plain_text_input) 467 | (props/with-children children ::ec.spec/plain-text-input.child) 468 | (validated ::elements.spec/plain-text-input))) 469 | 470 | (s/fdef plain-text-input 471 | :args (s/cat :props ::ec.spec/plain-text-input.props :children ::ec.spec/plain-text-input.children) 472 | :ret ::elements.spec/plain-text-input) 473 | 474 | (defn radio-buttons 475 | "A radio button group that allows a user to choose one item from a list of possible options. 476 | 477 | Component usage: 478 | 479 | ```clojure 480 | [:radio-buttons {:action_id \"A123\"} 481 | [:option {:value \"1\"} \"Pepperoni\"] 482 | [:option {:value \"2\" :selected? true} \"Pineapple\"] 483 | [:option {:value \"3\"} \"Mushrooms\"]] 484 | ```" 485 | [props & children] 486 | (-> (assoc props :type :radio_buttons) 487 | (props/with-children children ::ec.spec/radio-buttons.child) 488 | (assoc-initial-options) 489 | (validated ::elements.spec/radio-buttons) 490 | (conform-options))) 491 | 492 | (s/fdef radio-buttons 493 | :args (s/cat :props ::ec.spec/radio-buttons.props :children ::ec.spec/radio-buttons.children) 494 | :ret ::elements.spec/radio-buttons) 495 | -------------------------------------------------------------------------------- /src/thlack/surfs/elements/components/spec.clj: -------------------------------------------------------------------------------- 1 | (ns ^:no-doc thlack.surfs.elements.components.spec 2 | (:require [clojure.spec.alpha :as s] 3 | [clojure.spec.gen.alpha :as gen] 4 | [thlack.surfs.composition.spec :as comp.spec] 5 | [thlack.surfs.composition.spec.conversation :as conversation] 6 | [thlack.surfs.elements.spec :as elements.spec] 7 | [thlack.surfs.elements.spec.button :as button] 8 | [thlack.surfs.elements.spec.datepicker :as datepicker] 9 | [thlack.surfs.elements.spec.overflow :as overflow] 10 | [thlack.surfs.elements.spec.timepicker :as timepicker] 11 | [thlack.surfs.elements.spec.image :as image] 12 | [thlack.surfs.elements.spec.conversations-select :as conversations-select] 13 | [thlack.surfs.elements.spec.multi-select :as multi-select] 14 | [thlack.surfs.elements.spec.external-select :as external-select] 15 | [thlack.surfs.elements.spec.multi-conversations-select :as multi-conversations-select] 16 | [thlack.surfs.elements.spec.multi-users-select :as multi-users-select] 17 | [thlack.surfs.elements.spec.multi-channels-select :as multi-channels-select] 18 | [thlack.surfs.elements.spec.users-select :as users-select] 19 | [thlack.surfs.elements.spec.select :as select] 20 | [thlack.surfs.elements.spec.channels-select :as channels-select] 21 | [thlack.surfs.elements.spec.plain-text-input :as plain-text-input] 22 | [thlack.surfs.props.spec :as props.spec] 23 | [thlack.surfs.strings.spec :as strings.spec :refer [deftext]])) 24 | 25 | ;;; Specs for options that support the userland :selected? option 26 | 27 | (s/def ::option (s/merge ::comp.spec/option (s/keys :opt-un [::props.spec/selected?]))) 28 | 29 | (s/def ::options (s/coll-of ::option :min-count 1 :max-count 100 :into [] :gen-max 10)) 30 | 31 | (s/def ::option-group (s/keys :req-un [:option-group/label ::options])) 32 | 33 | ;;; [:button] 34 | 35 | (deftext :thlack.surfs.elements.components.spec.button-child/text ::props.spec/plain-text 75) 36 | 37 | (s/def ::button.child 38 | (s/or :plain-text :thlack.surfs.elements.components.spec.button-child/text 39 | :confirm ::comp.spec/confirm)) 40 | 41 | (s/def ::button.children 42 | (s/with-gen 43 | (s/* ::button.child) 44 | #(gen/tuple (s/gen :thlack.surfs.elements.components.spec.button-child/text) (s/gen ::comp.spec/confirm)))) 45 | 46 | (s/def ::button.props (s/keys :opt-un [::button/url ::button/style ::button/value ::strings.spec/action_id])) 47 | 48 | ;;; [:checkboxes] 49 | 50 | (s/def ::checkboxes.child 51 | (s/or :option ::option 52 | :confirm ::comp.spec/confirm)) 53 | 54 | (s/def ::checkboxes.children 55 | (s/with-gen 56 | (s/* ::checkboxes.child) 57 | #(gen/tuple (s/gen ::option) (s/gen ::option) (s/gen ::comp.spec/confirm)))) 58 | 59 | (s/def ::checkboxes.props (s/keys :req-un [::strings.spec/action_id])) 60 | 61 | ;;; [:datepicker] 62 | 63 | (s/def ::datepicker.child 64 | (s/or :placeholder ::comp.spec/plain-text 65 | :confirm ::comp.spec/confirm)) 66 | 67 | (s/def ::datepicker.props (s/keys :req-un [::strings.spec/action_id] :opt-un [::datepicker/initial_date])) 68 | 69 | ;;; [:timepicker] 70 | 71 | (s/def ::timepicker.child 72 | (s/or :placeholder ::comp.spec/plain-text 73 | :confirm ::comp.spec/confirm)) 74 | 75 | (s/def ::timepicker.props (s/keys :req-un [::strings.spec/action_id] :opt-un [::timepicker/initial_time])) 76 | 77 | ;;; [:img] 78 | 79 | (s/def ::img.props (s/keys :req-un [::image/image_url ::image/alt_text])) 80 | 81 | (defn gen-option-parent 82 | "Returns a generator that yields samples 83 | of elements that contain :option or :option-group children. 84 | The extra-keys parameter specifies which properties to preserve 85 | in addition to :options and :option-groups." 86 | [spec extra-keys] 87 | (gen/fmap 88 | (fn [select] 89 | (-> select 90 | (select-keys [:options :option_groups]) 91 | (vals) 92 | (flatten) 93 | (into 94 | (filter some? (vals (select-keys select extra-keys)))))) 95 | (s/gen spec))) 96 | 97 | ;;; [:multi-static-select] 98 | 99 | (s/def ::multi-static-select.child 100 | (s/or :placeholder ::comp.spec/plain-text 101 | :option ::option 102 | :option-group ::option-group 103 | :confirm ::comp.spec/confirm)) 104 | 105 | (s/def ::multi-static-select.children 106 | (s/with-gen 107 | (s/* ::multi-static-select.child) 108 | #(gen-option-parent ::elements.spec/multi-static-select [:confirm :placeholder]))) 109 | 110 | (s/def ::multi-select.props (s/keys :req-un [::strings.spec/action_id] :opt-un [::multi-select/max_selected_items])) 111 | 112 | (s/def ::slack-select.child 113 | (s/or :placeholder ::comp.spec/plain-text 114 | :confirm ::comp.spec/confirm)) 115 | 116 | (s/def ::slack-select.children 117 | (s/with-gen 118 | (s/* ::slack-select.child) 119 | #(gen/tuple (s/gen ::comp.spec/plain-text) (s/gen ::comp.spec/confirm)))) 120 | 121 | ;;; [:multi-external-select] 122 | 123 | (s/def ::multi-external-select.props (s/merge ::multi-select.props (s/keys :opt-un [::external-select/min_query_length]))) 124 | 125 | ;;; [:multi-users-select] 126 | 127 | (s/def ::multi-users-select.props (s/merge ::multi-select.props (s/keys :opt-un [::multi-users-select/initial_users]))) 128 | 129 | ;;; [:multi-conversations-select] 130 | 131 | (s/def ::multi-conversations-select.props (s/merge ::multi-select.props (s/keys :opt-un [::multi-conversations-select/initial_conversations ::conversations-select/default_to_current_conversation ::conversation/filter]))) 132 | 133 | ;;; [:multi-channels-select] 134 | 135 | (s/def ::multi-channels-select.props (s/merge ::multi-select.props (s/keys :opt-un [::multi-channels-select/initial_channels]))) 136 | 137 | ;;; [:static-select] 138 | 139 | (s/def ::static-select.child 140 | (s/or :confirm ::comp.spec/confirm 141 | :placeholder ::comp.spec/plain-text 142 | :option ::option 143 | :option-group ::option-group)) 144 | 145 | (s/def ::static-select.children 146 | (s/with-gen 147 | (s/* ::static-select.child) 148 | #(gen-option-parent ::elements.spec/static-select [:confirm :placeholder]))) 149 | 150 | (s/def ::static-select.props (s/keys :req-un [::strings.spec/action_id])) 151 | 152 | ;;; [:external-select] 153 | 154 | (s/def ::external-select.props (s/keys :req-un [::strings.spec/action_id] :opt-un [::external-select/min_query_length])) 155 | 156 | ;;; [:users-select] 157 | 158 | (s/def ::users-select.props (s/keys :req-un [::strings.spec/action_id] :opt-un [::users-select/initial_user])) 159 | 160 | ;;; [:conversations-select] 161 | 162 | (s/def ::conversations-select.props (s/keys :req-un [::strings.spec/action_id] :opt-un [::conversations-select/initial_conversation ::conversations-select/default_to_current_conversation ::select/response_url_enabled ::conversation/filter])) 163 | 164 | ;;; [:channels-select] 165 | 166 | (s/def ::channels-select.props (s/keys :req-un [::strings.spec/action_id] :opt-un [::select/response_url_enabled ::channels-select/initial_channel])) 167 | 168 | ;;; [:overflow] 169 | 170 | (s/def ::overflow.child 171 | (s/or :confirm ::comp.spec/confirm 172 | :option ::overflow/option)) 173 | 174 | (s/def ::overflow.children 175 | (s/with-gen 176 | (s/* ::overflow.child) 177 | #(gen-option-parent ::elements.spec/overflow [:confirm]))) 178 | 179 | (s/def ::overflow.props (s/keys :opt-un [::strings.spec/action_id])) 180 | 181 | ; Internal use only - used for validating overflow prop maps that MUST contain action_id in order to be considered props 182 | (s/def ::overflow.props* (s/keys :req-un [::strings.spec/action_id])) 183 | 184 | ;;; [:plain-text-input] 185 | 186 | (s/def ::plain-text-input.child 187 | (s/or :placeholder ::comp.spec/plain-text)) 188 | 189 | (s/def ::plain-text-input.children 190 | (s/with-gen 191 | (s/* ::plain-text-input.child) 192 | #(gen/fmap 193 | (fn [props] 194 | (->> (select-keys props [:placeholder]) 195 | (vals) 196 | (filter some?))) 197 | (s/gen ::elements.spec/plain-text-input)))) 198 | 199 | (s/def ::plain-text-input.props* (s/keys :req-un [::strings.spec/action_id] :opt-un [::plain-text-input/initial_value ::plain-text-input/multiline ::plain-text-input/min_length ::plain-text-input/max_length ::comp.spec/dispatch_action_config])) 200 | 201 | (s/def ::plain-text-input.props (s/with-gen 202 | ::plain-text-input.props* 203 | #(gen/fmap 204 | (fn [props] 205 | (select-keys props [:action_id :initial_value :multiline :min_length :max_length :dispatch_action_config])) 206 | (s/gen ::elements.spec/plain-text-input)))) 207 | 208 | ;;; [:radio-buttons] 209 | 210 | (s/def ::radio-buttons.child 211 | (s/or :option ::comp.spec/option 212 | :confirm ::comp.spec/confirm)) 213 | 214 | (s/def ::radio-buttons.children 215 | (s/with-gen 216 | (s/* ::radio-buttons.child) 217 | #(gen-option-parent ::elements.spec/radio-buttons [:confirm]))) 218 | 219 | (s/def ::radio-buttons.props (s/keys :req-un [::strings.spec/action_id])) 220 | -------------------------------------------------------------------------------- /src/thlack/surfs/elements/spec.clj: -------------------------------------------------------------------------------- 1 | (ns ^:no-doc thlack.surfs.elements.spec 2 | (:require [clojure.spec.alpha :as s] 3 | [clojure.spec.gen.alpha :as gen] 4 | [thlack.surfs.composition.spec :as comp.spec] 5 | [thlack.surfs.composition.spec.conversation :as conversation] 6 | [thlack.surfs.elements.spec.button :as button] 7 | [thlack.surfs.elements.spec.checkboxes :as checkboxes] 8 | [thlack.surfs.elements.spec.datepicker :as datepicker] 9 | [thlack.surfs.elements.spec.select :as select] 10 | [thlack.surfs.elements.spec.external-select :as external-select] 11 | [thlack.surfs.elements.spec.conversations-select :as conversations-select] 12 | [thlack.surfs.elements.spec.multi-select :as multi-select] 13 | [thlack.surfs.elements.spec.multi-static-select :as multi-static-select] 14 | [thlack.surfs.elements.spec.multi-external-select :as multi-external-select] 15 | [thlack.surfs.elements.spec.multi-users-select :as multi-users-select] 16 | [thlack.surfs.elements.spec.multi-conversations-select :as multi-conversations-select] 17 | [thlack.surfs.elements.spec.multi-channels-select :as multi-channels-select] 18 | [thlack.surfs.elements.spec.overflow :as overflow] 19 | [thlack.surfs.elements.spec.plain-text-input :as plain-text-input] 20 | [thlack.surfs.elements.spec.radio-buttons :as radio-buttons] 21 | [thlack.surfs.elements.spec.static-select :as static-select] 22 | [thlack.surfs.elements.spec.users-select :as users-select] 23 | [thlack.surfs.elements.spec.channels-select :as channels-select] 24 | [thlack.surfs.elements.spec.timepicker :as timepicker] 25 | [thlack.surfs.elements.spec.image :as image] 26 | [thlack.surfs.strings.spec :as strings.spec])) 27 | 28 | (defn generate 29 | [spec] 30 | (gen/generate (s/gen spec))) 31 | 32 | ;;; Interactive components 33 | 34 | (s/def ::button (s/keys :req-un [::button/type ::button/text ::strings.spec/action_id] :opt-un [::button/url ::button/value ::button/style ::comp.spec/confirm])) 35 | 36 | (s/def ::checkboxes* (s/keys :req-un [::checkboxes/type ::strings.spec/action_id ::checkboxes/options] :opt-un [::checkboxes/initial_options ::comp.spec/confirm])) 37 | 38 | (defn valid-initial-options? 39 | "Initial options MUST be a sample from options. Supports giving a key if 40 | comparing option groups instead of options" 41 | ([{:keys [initial_options initial_option] :as element} selector] 42 | (let [options (set (selector element))] 43 | (->> (into initial_options [initial_option]) 44 | (filterv some?) 45 | (every? options)))) 46 | ([element] 47 | (valid-initial-options? element :options))) 48 | 49 | (s/def ::checkboxes (s/with-gen 50 | (s/and ::checkboxes* valid-initial-options?) 51 | #(gen/fmap 52 | (fn [{:keys [options initial_options] :as checkboxes}] 53 | (if (some? initial_options) 54 | (assoc checkboxes :initial_options (random-sample 0.5 options)) 55 | checkboxes)) 56 | (s/gen ::checkboxes*)))) 57 | 58 | (s/def ::datepicker (s/keys :req-un [::datepicker/type ::strings.spec/action_id] :opt-un [::datepicker/placeholder ::datepicker/initial_date ::comp.spec/confirm])) 59 | 60 | (defn gen-select 61 | ([spec k] 62 | (gen/fmap 63 | (fn [{:keys [options option_groups] :as element}] 64 | (cond 65 | (:option_groups element) (assoc element k (random-sample 0.5 option_groups)) 66 | (:options element) (assoc element k (random-sample 0.5 options)))) 67 | (s/gen spec))) 68 | ([spec] 69 | (gen-select spec :initial_options))) 70 | 71 | (defn valid-select-options? 72 | [element] 73 | (if (some? (:option_groups element)) 74 | (valid-initial-options? element (fn [element] 75 | (->> (:option_groups element) 76 | (mapcat :options)))) 77 | (valid-initial-options? element :options))) 78 | 79 | (s/def ::multi-static-select* 80 | (s/keys :req-un [::multi-static-select/type ::select/placeholder ::strings.spec/action_id (or ::select/options ::select/option_groups)] 81 | :opt-un [::comp.spec/confirm ::multi-select/max_selected_items ::select/initial_options])) 82 | 83 | (s/def ::multi-static-select (s/with-gen 84 | (s/and ::multi-static-select* valid-select-options?) 85 | #(gen-select ::multi-static-select*))) 86 | 87 | (s/def ::multi-external-select (s/keys :req-un [::multi-external-select/type ::select/placeholder ::strings.spec/action_id] :opt-un [::external-select/min_query_length ::comp.spec/confirm ::multi-select/max_selected_items ::select/initial_options])) 88 | 89 | (s/def ::multi-users-select (s/keys :req-un [::multi-users-select/type ::select/placeholder ::strings.spec/action_id] :opt-un [::multi-users-select/initial_users ::comp.spec/confirm ::multi-select/max_selected_items])) 90 | 91 | (s/def ::multi-conversations-select (s/keys :req-un [::multi-conversations-select/type ::select/placeholder ::strings.spec/action_id] :opt-un [::multi-conversations-select/initial_conversations ::conversations-select/default_to_current_conversation ::comp.spec/confirm ::multi-select/max_selected_items ::conversation/filter])) 92 | 93 | (s/def ::multi-channels-select (s/keys :req-un [::multi-channels-select/type ::select/placeholder ::strings.spec/action_id] :opt-un [::multi-channels-select/initial_channels ::comp.spec/confirm ::multi-select/max_selected_items])) 94 | 95 | (s/def ::overflow (s/keys :req-un [::overflow/type ::strings.spec/action_id ::overflow/options] :opt-un [::comp.spec/confirm])) 96 | 97 | (s/def ::plain-text-input* (s/keys :req-un [::plain-text-input/type ::strings.spec/action_id] :opt-un [::plain-text-input/placeholder ::plain-text-input/initial_value ::plain-text-input/multiline ::plain-text-input/min_length ::plain-text-input/max_length ::comp.spec/dispatch_action_config])) 98 | 99 | (defn max-gte-min? 100 | [{:keys [min_length max_length] :or {min_length 0 max_length 1}}] 101 | (<= min_length max_length)) 102 | 103 | (s/def ::plain-text-input (s/with-gen 104 | (s/and ::plain-text-input* max-gte-min?) 105 | #(gen/fmap 106 | (fn [{:keys [max_length min_length] :as element}] 107 | (if (and max_length min_length (> min_length max_length)) 108 | (assoc element :min_length (- max_length 1)) 109 | element)) 110 | (s/gen ::plain-text-input*)))) 111 | 112 | (s/def ::radio-buttons* (s/keys :req-un [::radio-buttons/type ::strings.spec/action_id ::radio-buttons/options] :opt-un [::radio-buttons/initial_option ::comp.spec/confirm])) 113 | 114 | (s/def ::radio-buttons (s/with-gen 115 | (s/and ::radio-buttons* valid-initial-options?) 116 | #(gen/fmap 117 | (fn [element] 118 | (if (:initial_option element) 119 | (assoc element :initial_option (rand-nth (:options element))) 120 | element)) 121 | (s/gen ::radio-buttons*)))) 122 | 123 | (s/def ::static-select* 124 | (s/keys :req-un [::static-select/type ::select/placeholder ::strings.spec/action_id (or ::select/options ::select/option_groups)] 125 | :opt-un [::select/initial_option ::comp.spec/confirm])) 126 | 127 | (s/def ::static-select (s/with-gen 128 | (s/and ::static-select* valid-select-options?) 129 | #(gen-select ::static-select*))) 130 | 131 | (s/def ::external-select (s/keys :req-un [::external-select/type ::select/placeholder ::strings.spec/action_id] :opt-un [::select/initial_option ::external-select/min_query_length ::comp.spec/confirm])) 132 | 133 | (s/def ::users-select (s/keys :req-un [::users-select/type ::select/placeholder ::strings.spec/action_id] :opt-un [::users-select/initial_user ::comp.spec/confirm])) 134 | 135 | (s/def ::conversations-select (s/keys :req-un [::conversations-select/type ::select/placeholder ::strings.spec/action_id] :opt-un [::conversations-select/initial_conversation ::conversations-select/default_to_current_conversation ::comp.spec/confirm ::select/response_url_enabled ::conversation/filter])) 136 | 137 | (s/def ::channels-select (s/keys :req-un [::channels-select/type ::select/placeholder ::strings.spec/action_id] :opt-un [::channels-select/initial_channel ::comp.spec/confirm ::select/response_url_enabled])) 138 | 139 | (s/def ::timepicker (s/keys :req-un [::timepicker/type ::strings.spec/action_id] :opt-un [::timepicker/placeholder ::timepicker/initial_time ::comp.spec/confirm])) 140 | 141 | (s/def ::image (s/keys :req-un [::image/type ::image/image_url ::image/alt_text])) 142 | -------------------------------------------------------------------------------- /src/thlack/surfs/elements/spec/button.clj: -------------------------------------------------------------------------------- 1 | (ns ^:no-doc thlack.surfs.elements.spec.button 2 | (:require [clojure.spec.alpha :as s] 3 | [thlack.surfs.composition.spec :as comp.spec] 4 | [thlack.surfs.strings.spec :as strings.spec :refer [deftext]])) 5 | 6 | (s/def ::type #{:button}) 7 | 8 | (deftext ::text ::comp.spec/plain-text 75) 9 | 10 | (s/def ::action_id ::strings.spec/action_id) 11 | 12 | (deftext ::url ::strings.spec/url-string 3000) 13 | 14 | (deftext ::value ::strings.spec/string 2000) 15 | 16 | (s/def ::style #{:primary :danger}) 17 | -------------------------------------------------------------------------------- /src/thlack/surfs/elements/spec/channels_select.clj: -------------------------------------------------------------------------------- 1 | (ns ^:no-doc thlack.surfs.elements.spec.channels-select 2 | (:require [clojure.spec.alpha :as s] 3 | [thlack.surfs.strings.spec :as strings.spec])) 4 | 5 | (s/def ::type #{:channels_select}) 6 | 7 | (s/def ::initial_channel ::strings.spec/id) 8 | -------------------------------------------------------------------------------- /src/thlack/surfs/elements/spec/checkboxes.clj: -------------------------------------------------------------------------------- 1 | (ns ^:no-doc thlack.surfs.elements.spec.checkboxes 2 | (:require [clojure.spec.alpha :as s] 3 | [thlack.surfs.composition.spec :as comp.spec])) 4 | 5 | (s/def ::type #{:checkboxes}) 6 | 7 | (s/def ::options (s/coll-of ::comp.spec/option :into [] :min-count 1 :max-count 10)) 8 | 9 | (s/def ::initial_options ::options) 10 | -------------------------------------------------------------------------------- /src/thlack/surfs/elements/spec/conversations_select.clj: -------------------------------------------------------------------------------- 1 | (ns ^:no-doc thlack.surfs.elements.spec.conversations-select 2 | (:require [clojure.spec.alpha :as s] 3 | [thlack.surfs.strings.spec :as strings.spec])) 4 | 5 | (s/def ::type #{:conversations_select}) 6 | 7 | (s/def ::initial_conversation ::strings.spec/id) 8 | 9 | (s/def ::default_to_current_conversation boolean?) 10 | -------------------------------------------------------------------------------- /src/thlack/surfs/elements/spec/datepicker.clj: -------------------------------------------------------------------------------- 1 | (ns ^:no-doc thlack.surfs.elements.spec.datepicker 2 | (:require [clojure.spec.alpha :as s] 3 | [thlack.surfs.composition.spec :as comp.spec] 4 | [thlack.surfs.strings.spec :as strings.spec :refer [deftext]])) 5 | 6 | (s/def ::type #{:datepicker}) 7 | 8 | (deftext ::placeholder ::comp.spec/plain-text 150) 9 | 10 | (s/def ::initial_date ::strings.spec/date-string) 11 | -------------------------------------------------------------------------------- /src/thlack/surfs/elements/spec/external_select.clj: -------------------------------------------------------------------------------- 1 | (ns ^:no-doc thlack.surfs.elements.spec.external-select 2 | (:require [clojure.spec.alpha :as s])) 3 | 4 | (s/def ::type #{:external_select}) 5 | 6 | (s/def ::min_query_length pos-int?) 7 | -------------------------------------------------------------------------------- /src/thlack/surfs/elements/spec/image.clj: -------------------------------------------------------------------------------- 1 | (ns ^:no-doc thlack.surfs.elements.spec.image 2 | (:require [clojure.spec.alpha :as s] 3 | [thlack.surfs.strings.spec :as strings.spec])) 4 | 5 | (s/def ::type #{:image}) 6 | 7 | (s/def ::image_url ::strings.spec/url-string) 8 | 9 | (s/def ::alt_text ::strings.spec/string) 10 | -------------------------------------------------------------------------------- /src/thlack/surfs/elements/spec/multi_channels_select.clj: -------------------------------------------------------------------------------- 1 | (ns ^:no-doc thlack.surfs.elements.spec.multi-channels-select 2 | (:require [clojure.spec.alpha :as s] 3 | [thlack.surfs.strings.spec :as strings.spec])) 4 | 5 | (s/def ::type #{:multi_channels_select}) 6 | 7 | (s/def ::initial_channels (s/coll-of ::strings.spec/id :into [] :gen-max 10 :min-count 1)) 8 | -------------------------------------------------------------------------------- /src/thlack/surfs/elements/spec/multi_conversations_select.clj: -------------------------------------------------------------------------------- 1 | (ns ^:no-doc thlack.surfs.elements.spec.multi-conversations-select 2 | (:require [clojure.spec.alpha :as s] 3 | [thlack.surfs.strings.spec :as strings.spec])) 4 | 5 | (s/def ::type #{:multi_conversations_select}) 6 | 7 | (s/def ::initial_conversations (s/coll-of ::strings.spec/id :into [] :gen-max 5 :min-count 1)) 8 | -------------------------------------------------------------------------------- /src/thlack/surfs/elements/spec/multi_external_select.clj: -------------------------------------------------------------------------------- 1 | (ns ^:no-doc thlack.surfs.elements.spec.multi-external-select 2 | (:require [clojure.spec.alpha :as s])) 3 | 4 | (s/def ::type #{:multi_external_select}) 5 | -------------------------------------------------------------------------------- /src/thlack/surfs/elements/spec/multi_select.clj: -------------------------------------------------------------------------------- 1 | (ns ^:no-doc thlack.surfs.elements.spec.multi-select 2 | (:require [clojure.spec.alpha :as s])) 3 | 4 | (s/def ::max_selected_items pos-int?) 5 | -------------------------------------------------------------------------------- /src/thlack/surfs/elements/spec/multi_static_select.clj: -------------------------------------------------------------------------------- 1 | (ns ^:no-doc thlack.surfs.elements.spec.multi-static-select 2 | (:require [clojure.spec.alpha :as s])) 3 | 4 | (s/def ::type #{:multi_static_select}) 5 | -------------------------------------------------------------------------------- /src/thlack/surfs/elements/spec/multi_users_select.clj: -------------------------------------------------------------------------------- 1 | (ns ^:no-doc thlack.surfs.elements.spec.multi-users-select 2 | (:require [clojure.spec.alpha :as s] 3 | [thlack.surfs.strings.spec :as strings.spec])) 4 | 5 | (s/def ::type #{:multi_users_select}) 6 | 7 | (s/def ::initial_users (s/coll-of ::strings.spec/id :into [] :gen-max 10 :min-count 1)) 8 | -------------------------------------------------------------------------------- /src/thlack/surfs/elements/spec/overflow.clj: -------------------------------------------------------------------------------- 1 | (ns ^:no-doc thlack.surfs.elements.spec.overflow 2 | (:require [clojure.spec.alpha :as s] 3 | [thlack.surfs.composition.spec :as comp.spec] 4 | [thlack.surfs.strings.spec :as strings.spec])) 5 | 6 | (s/def ::type #{:overflow}) 7 | 8 | (s/def ::url (s/and ::strings.spec/url-string (strings.spec/max-len 3000))) 9 | 10 | (s/def ::option (s/merge ::comp.spec/option (s/keys :opt-un [::url]))) 11 | 12 | (s/def ::options (s/coll-of ::option :into [] :min-count 2 :max-count 5 :gen-max 3)) 13 | -------------------------------------------------------------------------------- /src/thlack/surfs/elements/spec/plain_text_input.clj: -------------------------------------------------------------------------------- 1 | (ns ^:no-doc thlack.surfs.elements.spec.plain-text-input 2 | (:require [clojure.spec.alpha :as s] 3 | [thlack.surfs.composition.spec :as comp.spec] 4 | [thlack.surfs.strings.spec :as strings.spec :refer [deftext]])) 5 | 6 | (s/def ::type #{:plain_text_input}) 7 | 8 | (deftext ::placeholder ::comp.spec/plain-text 150) 9 | 10 | (s/def ::initial_value ::strings.spec/string) 11 | 12 | (s/def ::multiline boolean?) 13 | 14 | (s/def ::min_length (s/int-in 1 3001)) 15 | 16 | (s/def ::max_length pos-int?) 17 | -------------------------------------------------------------------------------- /src/thlack/surfs/elements/spec/radio_buttons.clj: -------------------------------------------------------------------------------- 1 | (ns ^:no-doc thlack.surfs.elements.spec.radio-buttons 2 | (:require [clojure.spec.alpha :as s] 3 | [thlack.surfs.composition.spec :as comp.spec])) 4 | 5 | (s/def ::type #{:radio_buttons}) 6 | 7 | (s/def ::options (s/coll-of ::comp.spec/option :into [] :min-count 1 :max-count 10 :gen-max 5)) 8 | 9 | (s/def ::initial_option ::comp.spec/option) 10 | -------------------------------------------------------------------------------- /src/thlack/surfs/elements/spec/select.clj: -------------------------------------------------------------------------------- 1 | (ns ^:no-doc thlack.surfs.elements.spec.select 2 | (:require [clojure.spec.alpha :as s] 3 | [thlack.surfs.composition.spec :as comp.spec] 4 | [thlack.surfs.strings.spec :refer [deftext]])) 5 | 6 | (deftext ::placeholder ::comp.spec/plain-text 150) 7 | 8 | (s/def ::options (s/coll-of ::comp.spec/option :max-count 100 :min-count 1 :into [] :gen-max 5)) 9 | 10 | (s/def ::option_groups (s/coll-of ::comp.spec/option-group :max-count 100 :min-count 1 :into [] :gen-max 5)) 11 | 12 | (s/def ::initial_option ::comp.spec/option) 13 | 14 | (s/def ::initial_options ::options) 15 | 16 | (s/def ::response_url_enabled boolean?) 17 | -------------------------------------------------------------------------------- /src/thlack/surfs/elements/spec/static_select.clj: -------------------------------------------------------------------------------- 1 | (ns ^:no-doc thlack.surfs.elements.spec.static-select 2 | (:require [clojure.spec.alpha :as s])) 3 | 4 | (s/def ::type #{:static_select}) 5 | -------------------------------------------------------------------------------- /src/thlack/surfs/elements/spec/timepicker.clj: -------------------------------------------------------------------------------- 1 | (ns ^:no-doc thlack.surfs.elements.spec.timepicker 2 | (:require [clojure.spec.alpha :as s] 3 | [thlack.surfs.composition.spec :as comp.spec] 4 | [thlack.surfs.strings.spec :as strings.spec :refer [deftext]])) 5 | 6 | (s/def ::type #{:timepicker}) 7 | 8 | (s/def ::initial_time ::strings.spec/time-string) 9 | 10 | (deftext ::placeholder ::comp.spec/plain-text 150) 11 | -------------------------------------------------------------------------------- /src/thlack/surfs/elements/spec/users_select.clj: -------------------------------------------------------------------------------- 1 | (ns ^:no-doc thlack.surfs.elements.spec.users-select 2 | (:require [clojure.spec.alpha :as s] 3 | [thlack.surfs.strings.spec :as strings.spec])) 4 | 5 | (s/def ::type #{:users_select}) 6 | 7 | (s/def ::initial_user ::strings.spec/id) 8 | -------------------------------------------------------------------------------- /src/thlack/surfs/messages/components.clj: -------------------------------------------------------------------------------- 1 | (ns thlack.surfs.messages.components 2 | (:require [clojure.spec.alpha :as s] 3 | [thlack.surfs.props :as props] 4 | [thlack.surfs.validation :refer [validated]] 5 | [thlack.surfs.messages.spec :as message] 6 | [thlack.surfs.messages.components.spec :as mc.spec])) 7 | 8 | (defn message 9 | "Define a message. Supports the common message definition defined [here](https://api.slack.com/reference/messaging/payload). 10 | 11 | The spec does not require text AND blocks, but text is [HIGHLY recommended](https://api.slack.com/methods/chat.postMessage#text_usage) by 12 | Slack. 13 | 14 | Component usage: 15 | 16 | ```clojure 17 | [:message 18 | \"Fallback text for great good\" 19 | [:section {:block_id \"B123\"} 20 | [:text \"Text one\"]] 21 | [:section {:block_id \"B456\"} 22 | [:text \"Text two\"]]] 23 | ``` 24 | 25 | With props: 26 | 27 | ```clojure 28 | [:message {:thread_ts \"1049393493.23\"} 29 | \"Fallback text only\"] 30 | ``` 31 | 32 | With just text: 33 | 34 | ```clojure 35 | [:message \"This is a text only message\"] 36 | ``` 37 | 38 | With just blocks: 39 | 40 | ```clojure 41 | [:message 42 | [:section {:block_id \"B123\"} 43 | [:text \"Text one\"]] 44 | [:section {:block_id \"B456\"} 45 | [:text \"Text two\"]]] 46 | ```" 47 | [& args] 48 | (let [[props & children] (props/parse-args args mc.spec/message-props?) 49 | text (some #(if (string? %) % nil) children) 50 | blocks (filter (complement string?) children)] 51 | (-> props 52 | (cond-> 53 | (some? text) (assoc :text text) 54 | (seq blocks) (assoc :blocks (props/flatten-children blocks))) 55 | (validated ::message/message)))) 56 | 57 | (s/fdef message 58 | :args (s/alt :text-only (s/cat :text ::message/text) 59 | :props-text-only (s/cat :props ::mc.spec/message.props 60 | :text ::message/text) 61 | :blocks-only (s/cat :blocks ::mc.spec/message.children) 62 | :props-blocks-only (s/cat :props ::mc.spec/message.props 63 | :blocks ::mc.spec/message.children) 64 | :blocks-and-text (s/cat :text ::message/text 65 | :blocks ::mc.spec/message.children) 66 | :props-blocks-and-text (s/cat :props ::mc.spec/message.props 67 | :text ::message/text 68 | :blocks ::mc.spec/message.children)) 69 | :ret ::message/message) 70 | -------------------------------------------------------------------------------- /src/thlack/surfs/messages/components/spec.clj: -------------------------------------------------------------------------------- 1 | 2 | (ns ^:no-doc thlack.surfs.messages.components.spec 3 | (:require [clojure.spec.alpha :as s] 4 | [thlack.surfs.blocks.spec :as blocks.spec] 5 | [thlack.surfs.messages.spec :as message])) 6 | 7 | ;;; [:message] 8 | 9 | (s/def ::message.props 10 | (s/keys :opt-un [::message/thread_ts ::message/mrkdwn])) 11 | 12 | (defn- block? 13 | [props] 14 | (if (map? props) 15 | (-> props 16 | (select-keys [:type :block_id]) 17 | (seq) 18 | (some?)) 19 | false)) 20 | 21 | ;;; To account for all possible message properties - we will consider any non-block a valid map of props 22 | ;;; for the purpose of parsing message component arguments 23 | (defn message-props? 24 | [props] 25 | (if (block? props) 26 | false 27 | (map? props))) 28 | 29 | (s/def ::message.child (s/or :block ::blocks.spec/block :text ::message/text)) 30 | 31 | (s/def ::message.children 32 | (s/with-gen 33 | (s/* ::message.child) 34 | #(s/gen ::message/blocks))) 35 | -------------------------------------------------------------------------------- /src/thlack/surfs/messages/spec.clj: -------------------------------------------------------------------------------- 1 | (ns ^:no-doc thlack.surfs.messages.spec 2 | "Follows the common structure for ALL message payloads 3 | documented at https://api.slack.com/reference/messaging/payload" 4 | (:require [clojure.spec.alpha :as s] 5 | [thlack.surfs.strings.spec :as strings.spec :refer [deftext]] 6 | [thlack.surfs.blocks.spec :as blocks.spec])) 7 | 8 | (deftext ::text ::strings.spec/string 40000) 9 | 10 | (s/def ::blocks (s/coll-of ::blocks.spec/block :into [] :max-count 50 :min-count 1 :gen-max 3)) 11 | 12 | (s/def ::thread_ts ::strings.spec/string) 13 | 14 | (s/def ::mrkdwn boolean?) 15 | 16 | (s/def ::message-optional-blocks (s/keys :req-un [::text] :opt-un [::blocks ::thread_ts ::mrkdwn])) 17 | 18 | (s/def ::message-optional-text (s/keys :req-un [::blocks] :opt-un [::text ::thread_ts ::mrkdwn])) 19 | 20 | (s/def ::message 21 | (s/or :optional-blocks ::message-optional-blocks :optional-text ::message-optional-text)) 22 | -------------------------------------------------------------------------------- /src/thlack/surfs/props.clj: -------------------------------------------------------------------------------- 1 | (ns ^:no-doc thlack.surfs.props 2 | (:require [clojure.spec.alpha :as s] 3 | [thlack.surfs.blocks.spec] 4 | [thlack.surfs.blocks.spec.section :as section] 5 | [thlack.surfs.blocks.spec.input :as input])) 6 | 7 | ;;; Prop helpers 8 | 9 | (defn with-plain-text 10 | "Adds support for the disable_emoji_for prop. This is for props that represent 11 | simple plain text strings." 12 | [{:keys [disable_emoji_for] :as props :or {disable_emoji_for #{}}} text-keys] 13 | (reduce-kv 14 | (fn [m k v] 15 | (if (some? (text-keys k)) 16 | (assoc m k {:type :plain_text :text v :emoji (not (boolean (disable_emoji_for k)))}) 17 | (assoc m k v))) 18 | {} 19 | (dissoc props :disable_emoji_for))) 20 | 21 | (defn parse-args 22 | "Used for supporting components that may not require any props." 23 | ([args spec] 24 | (let [head (first args)] 25 | (cond 26 | (and (> (count args) 1) (s/valid? spec head)) (into [head] (rest args)) 27 | (not (seq head)) args 28 | :else (into [{}] args)))) 29 | ([args] 30 | (parse-args args map?))) 31 | 32 | ;;; Children 33 | 34 | (defmacro conformed 35 | "Similar to the validated macro, this conforms data to a spec or throws an informative 36 | exception." 37 | [x spec] 38 | `(let [conformed# (s/conform ~spec ~x)] 39 | (if (s/invalid? conformed#) 40 | (do 41 | (s/assert ~spec ~x) 42 | nil) 43 | conformed#))) 44 | 45 | (defn- detag 46 | "Detag data coming in via children. If a spec is provided, the data will be shaped by 47 | calling clojure.spec.alpha/unform - which may be trading ease for some performance" 48 | ([data spec] 49 | (s/unform spec data)) 50 | ([data] 51 | (if (map-entry? data) 52 | (val data) 53 | data))) 54 | 55 | (defn assoc-child 56 | "Associate a child element with a collection of children. This function will be called with 57 | a 'children' map and a tuple containing a tag and the conformed data." 58 | [children [tag data]] 59 | (condp some [tag] 60 | #{:plain-text} (assoc children :text (detag data)) 61 | #{:confirm} (assoc children tag (update data :text detag)) 62 | #{:option} (update children :options conj data) 63 | #{:option-group} (update children :option_groups conj data) 64 | #{:text} (assoc children :text (detag data)) 65 | #{:fields} (merge children (update data :fields #(map second %))) 66 | #{:accessory} (assoc children tag (detag data ::section/accessory)) 67 | #{:element} (assoc children tag (detag data ::input/element)) 68 | (assoc children tag data))) 69 | 70 | (defn flatten-children 71 | "Handles flattening children of an element or block. If a child is a sequence, 72 | it will be merged into the set of children. This is what makes it possible to include 73 | things like map expressions in children - i.e [:option-group (map make-options props)]" 74 | [coll] 75 | (->> coll 76 | (filter some?) 77 | (reduce 78 | (fn [children child] 79 | (into children (if (seq? child) child [child]))) 80 | []))) 81 | 82 | (defmacro with-children 83 | "Include children in a spec-safe way. 'children' here is meant to imply 84 | nested composition objects or elements, such as a section's accessory or a select element's 85 | options." 86 | [props children spec] 87 | `(if-let [filtered# (seq (flatten-children ~children))] 88 | (let [children# (conformed filtered# (s/coll-of ~spec :into []))] 89 | (merge ~props (reduce assoc-child {} (rseq children#)))) 90 | ~props)) 91 | -------------------------------------------------------------------------------- /src/thlack/surfs/props/spec.clj: -------------------------------------------------------------------------------- 1 | (ns ^:no-doc thlack.surfs.props.spec 2 | "Contains specs for convenience props included via thlack.surfs" 3 | (:require [clojure.spec.alpha :as s] 4 | [thlack.surfs.composition.spec :as comp.spec] 5 | [thlack.surfs.strings.spec :as strings.spec])) 6 | 7 | (s/def ::selected? boolean?) 8 | 9 | (s/def ::disable_emoji_for (s/coll-of keyword? :kind set?)) 10 | 11 | (s/def ::text (s/or :string ::strings.spec/string :map ::comp.spec/text)) 12 | 13 | (s/def ::plain-text (s/or :string ::strings.spec/string :map ::comp.spec/plain-text)) 14 | -------------------------------------------------------------------------------- /src/thlack/surfs/render.clj: -------------------------------------------------------------------------------- 1 | (ns ^:no-doc thlack.surfs.render 2 | "The surfs rendering implementation. Handles converting hiccup like tags to 3 | valid Slack blocks" 4 | (:require [clojure.walk :as walk] 5 | [thlack.surfs.blocks.components :as blocks] 6 | [thlack.surfs.composition.components :as comp] 7 | [thlack.surfs.elements.components :as elements] 8 | [thlack.surfs.messages.components :as messages] 9 | [thlack.surfs.views.components :as views])) 10 | 11 | ;;; The "tags" map defines the "out of the box" components provided by surfs. Maps 12 | ;;; a keyword tag to a render function. 13 | 14 | (def tags 15 | {:home #'views/home 16 | :modal #'views/modal 17 | :message #'messages/message 18 | :actions #'blocks/actions 19 | :context #'blocks/context 20 | :divider #'blocks/divider 21 | :header #'blocks/header 22 | :image #'blocks/image 23 | :section #'blocks/section 24 | :input #'blocks/input 25 | :fields #'blocks/fields 26 | :button #'elements/button 27 | :checkboxes #'elements/checkboxes 28 | :datepicker #'elements/datepicker 29 | :timepicker #'elements/timepicker 30 | :img #'elements/img 31 | :multi-external-select #'elements/multi-external-select 32 | :multi-users-select #'elements/multi-users-select 33 | :multi-conversations-select #'elements/multi-conversations-select 34 | :multi-channels-select #'elements/multi-channels-select 35 | :multi-static-select #'elements/multi-static-select 36 | :static-select #'elements/static-select 37 | :external-select #'elements/external-select 38 | :users-select #'elements/users-select 39 | :conversations-select #'elements/conversations-select 40 | :channels-select #'elements/channels-select 41 | :overflow #'elements/overflow 42 | :plain-text-input #'elements/plain-text-input 43 | :radio-buttons #'elements/radio-buttons 44 | :plain-text #'comp/plain-text 45 | :label #'comp/plain-text 46 | :placeholder #'comp/plain-text 47 | :hint #'comp/plain-text 48 | :title #'comp/plain-text 49 | :confirm #'comp/confirm 50 | :option #'comp/option 51 | :option-group #'comp/option-group 52 | :markdown #'comp/markdown 53 | :text #'comp/text}) 54 | 55 | (defn- render-tag 56 | [[head & args]] 57 | (if-let [render-fn (tags head)] 58 | (apply (var-get render-fn) args) 59 | (apply vector head args))) 60 | 61 | (defn- expand 62 | "Expand a custom component. A custom component is a hiccup tag with a function 63 | in the head position." 64 | [element] 65 | (if (and (vector? element) (fn? (first element))) 66 | (apply (first element) (next element)) 67 | element)) 68 | 69 | (defn- render' 70 | [element] 71 | (cond 72 | (map-entry? element) element 73 | (vector? element) (render-tag element) 74 | :else element)) 75 | 76 | (defn render 77 | [component] 78 | (->> component 79 | (walk/prewalk expand) 80 | (walk/postwalk render'))) 81 | -------------------------------------------------------------------------------- /src/thlack/surfs/repl.clj: -------------------------------------------------------------------------------- 1 | (ns thlack.surfs.repl 2 | "Repl helpers for surfs. Useful for exploring component specs and 3 | getting documentation." 4 | (:require [thlack.surfs.repl.impl :as impl])) 5 | 6 | (defn describe 7 | "Returns raw metadata about a component. Includes function metadata as well 8 | as the fspec of the component's render function. describe might be considered 9 | them most \"low level\" repl utility. 10 | 11 | Usage: 12 | 13 | ```clojure 14 | (describe :static-select) 15 | ```" 16 | [tag] 17 | (impl/describe tag)) 18 | 19 | (defn doc 20 | "Prints the full documentation of a component. This documentation includes component signatures 21 | as well as usage examples. 22 | 23 | Usage: 24 | 25 | ```clojure 26 | (doc :static-select) 27 | ```" 28 | [tag] 29 | (impl/doc tag)) 30 | 31 | (defn props 32 | "Returns the spec for a component's props if it has them. Specs leveraging merge 33 | will have their keywords fully expanded into a valid `keys` spec. 34 | 35 | ```clojure 36 | (props :multi-external-select) 37 | ```" 38 | [tag] 39 | (impl/props tag)) 40 | -------------------------------------------------------------------------------- /src/thlack/surfs/repl/impl.clj: -------------------------------------------------------------------------------- 1 | (ns ^:no-doc thlack.surfs.repl.impl 2 | (:require [clojure.spec.alpha :as s] 3 | [clojure.string :as string] 4 | [thlack.surfs.render :as render])) 5 | 6 | (defn- get-var 7 | [tag] 8 | (render/tags tag)) 9 | 10 | (defn- get-meta 11 | [tag] 12 | (some-> tag 13 | (get-var) 14 | (meta) 15 | (select-keys [:doc :name]))) 16 | 17 | (defn- get-spec 18 | [tag] 19 | (some->> tag 20 | (get-var) 21 | (s/get-spec) 22 | (s/describe) 23 | (next) 24 | (apply hash-map))) 25 | 26 | (defn- with-spec 27 | [tag description] 28 | (assoc description :spec (get-spec tag))) 29 | 30 | (defn describe 31 | [tag] 32 | (->> tag 33 | (get-meta) 34 | (with-spec tag) 35 | (merge {:tag tag}))) 36 | 37 | (defn- get-args 38 | [{{:keys [args]} :spec}] 39 | args) 40 | 41 | (defn- get-cats' 42 | [args] 43 | (let [head (first args)] 44 | (if (= 'alt head) 45 | (vals (apply hash-map (rest args))) 46 | (list args)))) 47 | 48 | (defn- get-cats 49 | [args] 50 | (->> args 51 | (get-cats') 52 | (map (fn [c] 53 | (map (fn [x] 54 | (if (seq? x) 55 | (second x) 56 | x)) c))))) 57 | 58 | (defn- signature-string 59 | "Get an arglist based on a function spec." 60 | [{:keys [tag] :as description}] 61 | (->> (get-args description) 62 | (get-cats) 63 | (map rest) 64 | (map #(map symbol %)) 65 | (map #(take-nth 2 %)) 66 | (map vec) 67 | (map #(into [tag] %)) 68 | (pr-str))) 69 | 70 | (defn- doc-string 71 | "Get the doc string for a component description" 72 | [description] 73 | (some-> description 74 | (:doc) 75 | (string/replace #"^" " ") 76 | (string/split-lines) 77 | (#(string/join (System/lineSeparator) %)))) 78 | 79 | (defn doc 80 | [tag] 81 | (let [description (describe tag)] 82 | (->> description 83 | (doc-string) 84 | (str (signature-string description) (System/lineSeparator)) 85 | (println)))) 86 | 87 | (defn- expand-prop 88 | [prop] 89 | (if (qualified-keyword? prop) 90 | (s/describe prop) 91 | prop)) 92 | 93 | (defn props 94 | [tag] 95 | (some->> tag 96 | (describe) 97 | (get-args) 98 | (get-cats) 99 | (map #(apply hash-map (rest %))) 100 | (filter #(contains? % :props)) 101 | (first) 102 | :props 103 | (s/describe) 104 | (map expand-prop))) 105 | -------------------------------------------------------------------------------- /src/thlack/surfs/strings/spec.clj: -------------------------------------------------------------------------------- 1 | (ns ^:no-doc thlack.surfs.strings.spec 2 | "Contains specs for the various string types leveraged in the Slack block kit" 3 | (:require [clojure.spec.alpha :as s] 4 | [clojure.spec.gen.alpha :as gen] 5 | [clojure.string :as string]) 6 | (:import [java.net URL] 7 | [java.time LocalDate] 8 | [java.time.format DateTimeFormatter])) 9 | 10 | (s/def ::string (s/and string? (complement string/blank?))) 11 | 12 | (defn len-lte? 13 | [s len] 14 | (<= (count s) len)) 15 | 16 | (defn max-len 17 | "Returns a predicate that checks if string literals, maps, and map entries 18 | containing text do not exceed a given length" 19 | [len] 20 | (fn [s] 21 | (cond 22 | (string? s) (len-lte? s len) 23 | (map? s) (len-lte? (:text s) len) 24 | (map-entry? s) (len-lte? (:text (val s)) len)))) 25 | 26 | (defn with-max-gen 27 | "Create a spec with a generator that ensures text does not exceed a given 28 | length" 29 | [spec len] 30 | (letfn [(truncate [s] (subs s 0 (min (count s) len)))] 31 | (s/with-gen 32 | spec 33 | #(gen/fmap 34 | (fn [s] 35 | (cond 36 | (string? s) (truncate s) 37 | (map? s) (update s :text truncate) 38 | (map-entry? s) (update-in s [1 :text] truncate))) 39 | (s/gen spec))))) 40 | 41 | (defmacro deftext 42 | "Define a text spec that adhers to a maximum length" 43 | [k spec len] 44 | `(s/def ~k 45 | (with-max-gen 46 | (s/and ~spec (max-len ~len)) 47 | ~len))) 48 | 49 | (s/def ::date-string* (s/and ::string (fn [s] 50 | (try 51 | (LocalDate/parse s) 52 | true 53 | (catch Exception _ 54 | false))))) 55 | 56 | (s/def ::date-string (s/with-gen 57 | ::date-string* 58 | #(gen/return (-> (LocalDate/now) 59 | (.format (DateTimeFormatter/ofPattern "yyyy-MM-dd")))))) 60 | 61 | (s/def ::time-string* #(re-find #"^(?:[0-1][0-9]|2[0-3]):[0-5][0-9]$" %)) 62 | 63 | (s/def ::time-string (s/with-gen 64 | ::time-string* 65 | (fn [] 66 | (let [left (rand-int 24) 67 | right (rand-int 60)] 68 | (gen/return (format "%02d:%02d" left right)))))) 69 | 70 | (s/def ::url-string* (s/and ::string (fn [s] 71 | (try 72 | (URL. s) 73 | true 74 | (catch Exception _ 75 | false))))) 76 | 77 | (s/def ::url-string (s/with-gen 78 | ::url-string* 79 | #(gen/fmap 80 | (fn [uri] 81 | (str uri)) 82 | (s/gen uri?)))) 83 | 84 | (s/def ::id (s/and ::string (max-len 255))) 85 | 86 | (s/def ::action_id ::id) 87 | 88 | (s/def ::block_id ::id) 89 | -------------------------------------------------------------------------------- /src/thlack/surfs/validation.clj: -------------------------------------------------------------------------------- 1 | (ns ^:no-doc thlack.surfs.validation 2 | "Handles validation for components" 3 | (:require [clojure.spec.alpha :as s])) 4 | 5 | (defmacro validated 6 | "Returns a data structure that is validated against a spec or throws an informative 7 | exception." 8 | [x spec] 9 | `(s/assert ~spec ~x)) 10 | -------------------------------------------------------------------------------- /src/thlack/surfs/views/components.clj: -------------------------------------------------------------------------------- 1 | (ns thlack.surfs.views.components 2 | (:require [clojure.spec.alpha :as s] 3 | [thlack.surfs.props :as props] 4 | [thlack.surfs.validation :refer [validated]] 5 | [thlack.surfs.views.spec :as views.spec] 6 | [thlack.surfs.views.components.spec :as vc.spec])) 7 | 8 | (defn- with-private-metadata 9 | "Supports private_metadata as Clojure data structures. If a private_metadata 10 | prop is given, it will have (pr-str) applied to it." 11 | [{:keys [private_metadata] :as props}] 12 | (if (some? private_metadata) 13 | (assoc props :private_metadata (pr-str private_metadata)) 14 | props)) 15 | 16 | (defn home 17 | "Define a home tab. 18 | 19 | Component usage: 20 | 21 | ```clojure 22 | [:home {:private_metadata {:cool? true}} 23 | [:section {:block_id \"B123\"} 24 | [:text \"Some text\"]]] 25 | ``` 26 | 27 | Without props: 28 | 29 | ```clojure 30 | [:home 31 | [:section {:block_id \"B123\"} 32 | [:text \"Some text\"]] 33 | ```" 34 | [& args] 35 | (let [[props & blocks] (props/parse-args args)] 36 | (-> (assoc props :type :home) 37 | (with-private-metadata) 38 | (assoc :blocks (props/flatten-children blocks)) 39 | (validated ::views.spec/home)))) 40 | 41 | (s/fdef home 42 | :args (s/alt :props-and-blocks (s/cat :props ::vc.spec/view.props :children ::vc.spec/view.children) 43 | :blocks (s/cat :children ::vc.spec/view.children)) 44 | :ret ::views.spec/home) 45 | 46 | (defn modal 47 | "Define a modal. 48 | 49 | Component usage: 50 | 51 | ```clojure 52 | [:modal {:private_metadata {:cool? true} 53 | :title \"Cool Modal!\" 54 | :close \"Nah!\" 55 | :submit \"Yah!\"} 56 | [:section {:block_id \"B123\"} 57 | [:text \"Some text\"]]] 58 | ```" 59 | [props & blocks] 60 | (-> (assoc props :type :modal) 61 | (with-private-metadata) 62 | (props/with-plain-text #{:title :close :submit}) 63 | (assoc :blocks (props/flatten-children blocks)) 64 | (validated ::views.spec/modal))) 65 | 66 | (s/fdef modal 67 | :args (s/cat :props ::vc.spec/modal.props :children ::vc.spec/view.children) 68 | :ret ::views.spec/modal) 69 | -------------------------------------------------------------------------------- /src/thlack/surfs/views/components/spec.clj: -------------------------------------------------------------------------------- 1 | (ns ^:no-doc thlack.surfs.views.components.spec 2 | (:require [clojure.spec.alpha :as s] 3 | [clojure.spec.gen.alpha :as gen] 4 | [thlack.surfs.blocks.spec :as blocks.spec] 5 | [thlack.surfs.props.spec :as props.spec] 6 | [thlack.surfs.strings.spec :as strings.spec :refer [deftext]] 7 | [thlack.surfs.views.spec :as view] 8 | [thlack.surfs.views.spec.modal :as modal])) 9 | 10 | ;;; Override private_metadata spec so it accomodates any value. All 11 | ;;; private_metadata will be passed through (pr-str) so Clojure data structures 12 | ;;; can be used easily 13 | 14 | (s/def ::private_metadata 15 | (s/with-gen 16 | any? 17 | #(gen/return {:meta? true :data? true}))) 18 | 19 | (s/def ::view.props (s/keys :opt-un [::private_metadata ::view/callback_id ::view/external_id])) 20 | 21 | (s/def ::view.child (s/or :block ::blocks.spec/block)) 22 | 23 | (s/def ::view.children 24 | (s/with-gen 25 | (s/+ ::view.child) 26 | #(s/gen ::view/blocks))) 27 | 28 | ;;; [:modal] 29 | 30 | (deftext ::modal-props.string ::strings.spec/string 24) 31 | 32 | (s/def :thlack.surfs.views.components.spec.modal-props/title ::modal-props.string) 33 | 34 | (s/def :thlack.surfs.views.components.spec.modal-props/close ::modal-props.string) 35 | 36 | (s/def :thlack.surfs.views.components.spec.modal-props/submit ::modal-props.string) 37 | 38 | (s/def ::modal.props (s/merge ::view.props (s/keys :req-un [:thlack.surfs.views.components.spec.modal-props/title] 39 | :opt-un [:thlack.surfs.views.components.spec.modal-props/close 40 | :thlack.surfs.views.components.spec.modal-props/submit 41 | ::modal/clear_on_close 42 | ::modal/notify_on_close 43 | ::props.spec/disable_emoji_for]))) 44 | -------------------------------------------------------------------------------- /src/thlack/surfs/views/spec.clj: -------------------------------------------------------------------------------- 1 | (ns ^:no-doc thlack.surfs.views.spec 2 | (:require [clojure.spec.alpha :as s] 3 | [thlack.surfs.strings.spec :as strings.spec] 4 | [thlack.surfs.blocks.spec :as blocks.spec] 5 | [thlack.surfs.views.spec.modal :as modal] 6 | [thlack.surfs.views.spec.home :as home])) 7 | 8 | (s/def ::blocks (s/coll-of ::blocks.spec/block :into [] :max-count 100 :min-count 1 :gen-max 3)) 9 | 10 | (s/def ::private_metadata (strings.spec/with-max-gen 11 | ::strings.spec/string 12 | 3000)) 13 | 14 | (s/def ::callback_id (strings.spec/with-max-gen 15 | ::strings.spec/string 16 | 255)) 17 | 18 | (s/def ::external_id ::strings.spec/string) 19 | 20 | (s/def ::home (s/keys :req-un [::home/type ::blocks] :opt-un [::private_metadata ::callback_id ::external_id])) 21 | 22 | (s/def ::modal (s/keys :req-un [::modal/type ::blocks ::modal/title] :opt-un [::modal/close ::modal/submit ::private_metadata ::callback_id ::modal/clear_on_close ::modal/notify_on_close ::external_id])) 23 | -------------------------------------------------------------------------------- /src/thlack/surfs/views/spec/home.clj: -------------------------------------------------------------------------------- 1 | (ns ^:no-doc thlack.surfs.views.spec.home 2 | (:require [clojure.spec.alpha :as s])) 3 | 4 | (s/def ::type #{:home}) 5 | -------------------------------------------------------------------------------- /src/thlack/surfs/views/spec/modal.clj: -------------------------------------------------------------------------------- 1 | (ns ^:no-doc thlack.surfs.views.spec.modal 2 | (:require [clojure.spec.alpha :as s] 3 | [thlack.surfs.composition.spec :as comp.spec] 4 | [thlack.surfs.strings.spec :refer [deftext]])) 5 | 6 | (s/def ::type #{:modal}) 7 | 8 | (deftext ::title ::comp.spec/plain-text 24) 9 | 10 | (deftext ::close ::comp.spec/plain-text 24) 11 | 12 | (deftext ::submit ::comp.spec/plain-text 24) 13 | 14 | (s/def ::clear_on_close boolean?) 15 | 16 | (s/def ::notify_on_close boolean?) 17 | -------------------------------------------------------------------------------- /surfs.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thlack/surfs/e03d137d6d43c4b73a45a71984cf084d2904c4b0/surfs.gif -------------------------------------------------------------------------------- /test/thlack/surfs/blocks/components_test.clj: -------------------------------------------------------------------------------- 1 | (ns thlack.surfs.blocks.components-test 2 | (:require [thlack.surfs.blocks.components :as co] 3 | [thlack.surfs.test-utils :refer [defcheck]])) 4 | 5 | (def num-tests 10) 6 | 7 | (defcheck actions `co/actions num-tests) 8 | 9 | (defcheck fields `co/fields num-tests) 10 | 11 | (defcheck section `co/section num-tests) 12 | 13 | (defcheck context `co/context num-tests) 14 | 15 | (defcheck divider `co/divider num-tests) 16 | 17 | (defcheck header `co/header num-tests) 18 | 19 | (defcheck image `co/image num-tests) 20 | 21 | (defcheck input `co/input num-tests) 22 | -------------------------------------------------------------------------------- /test/thlack/surfs/composition/components_test.clj: -------------------------------------------------------------------------------- 1 | (ns thlack.surfs.composition.components-test 2 | (:require [thlack.surfs.composition.components :as co] 3 | [thlack.surfs.test-utils :refer [defcheck]])) 4 | 5 | (def num-tests 100) 6 | 7 | (defcheck text `co/text num-tests) 8 | 9 | (defcheck plain-text `co/plain-text num-tests) 10 | 11 | (defcheck markdown `co/markdown num-tests) 12 | 13 | (defcheck confirm `co/confirm num-tests) 14 | 15 | (defcheck option `co/option num-tests) 16 | 17 | (defcheck option-group `co/option-group num-tests) 18 | -------------------------------------------------------------------------------- /test/thlack/surfs/elements/components_test.clj: -------------------------------------------------------------------------------- 1 | (ns thlack.surfs.elements.components-test 2 | (:require [thlack.surfs.elements.components :as co] 3 | [thlack.surfs.test-utils :refer [defcheck]])) 4 | 5 | (def num-tests 50) 6 | 7 | (defcheck button `co/button num-tests) 8 | 9 | (defcheck checkboxes `co/checkboxes num-tests) 10 | 11 | (defcheck datepicker `co/datepicker num-tests) 12 | 13 | (defcheck timepicker `co/timepicker num-tests) 14 | 15 | (defcheck img `co/img num-tests) 16 | 17 | (defcheck multi-static-select `co/multi-static-select 10) 18 | 19 | (defcheck multi-external-select `co/multi-external-select 10) 20 | 21 | (defcheck multi-users-select `co/multi-users-select num-tests) 22 | 23 | (defcheck multi-conversations-select `co/multi-conversations-select num-tests) 24 | 25 | (defcheck multi-channels-select `co/multi-channels-select num-tests) 26 | 27 | (defcheck static-select `co/static-select 10) 28 | 29 | (defcheck external-select `co/external-select 10) 30 | 31 | (defcheck users-select `co/users-select num-tests) 32 | 33 | (defcheck conversations-select `co/conversations-select num-tests) 34 | 35 | (defcheck channels-select `co/channels-select num-tests) 36 | 37 | (defcheck overflow `co/overflow num-tests) 38 | 39 | (defcheck plain-text-input `co/plain-text-input num-tests) 40 | 41 | (defcheck radio-buttons `co/radio-buttons num-tests) 42 | -------------------------------------------------------------------------------- /test/thlack/surfs/examples_test.clj: -------------------------------------------------------------------------------- 1 | (ns thlack.surfs.examples-test 2 | "Way less cool unit test suite, but it's useful for seeing surfs 3 | in action :)" 4 | (:require [clojure.test :refer [is]] 5 | [thlack.surfs :refer [defc]] 6 | [thlack.surfs.test-utils :refer [defrendertest]])) 7 | 8 | ;;; Composition 9 | 10 | (defrendertest text 11 | [:text "Hello"] 12 | 13 | [:text {:type :mrkdwn :verbatim false :text "# Hello"}] 14 | 15 | [:text {:type :plain_text :emoji true :text "Hello"}] 16 | 17 | (fn [[literal markdown plain]] 18 | (is (= literal {:type :plain_text :text "Hello"})) 19 | (is (= markdown {:type :mrkdwn :text "# Hello" :verbatim false})) 20 | (is (= plain {:type :plain_text :text "Hello" :emoji true})))) 21 | 22 | (defrendertest plain-text 23 | [:plain-text "Hello"] 24 | 25 | [:plain-text "Goodbye" false] 26 | 27 | [:plain-text {:text "Greetings" :emoji false}] 28 | 29 | (fn [[text-only with-emoji props]] 30 | (is (= {:type :plain_text :text "Hello" :emoji true} text-only)) 31 | (is (= {:type :plain_text :text "Goodbye" :emoji false} with-emoji)) 32 | (is (= {:type :plain_text :text "Greetings" :emoji false} props)))) 33 | 34 | (defrendertest markdown 35 | [:markdown "# Hello"] 36 | 37 | [:markdown "# Goodbye" true] 38 | 39 | [:markdown {:text "# Greetings" :verbatim true}] 40 | 41 | (fn [[text-only with-verbatim props]] 42 | (is (= {:type :mrkdwn :text "# Hello" :verbatim false} text-only)) 43 | (is (= {:type :mrkdwn :text "# Goodbye" :verbatim true} with-verbatim)) 44 | (is (= {:type :mrkdwn :text "# Greetings" :verbatim true} props)))) 45 | 46 | (defrendertest confirm 47 | [:confirm {:confirm "Ok!" :deny "Nah!" :title "This is a title!"} 48 | [:text "Are you sure?"]] 49 | 50 | [:confirm {:confirm "Ok!" :deny "Nah!" :title "This is a title!"} "Are you sure?"] 51 | 52 | (fn [[confirm literal-text]] 53 | (let [expected {:confirm {:type :plain_text :text "Ok!" :emoji true} 54 | :deny {:type :plain_text :text "Nah!" :emoji true} 55 | :title {:type :plain_text :text "This is a title!" :emoji true} 56 | :text {:type :plain_text :text "Are you sure?"} 57 | :style :primary}] 58 | (is (= expected confirm literal-text))))) 59 | 60 | (defrendertest option 61 | [:option {:value "1"} "Label"] 62 | 63 | [:option {:value "1"} {:type :plain_text :text "Label"}] 64 | 65 | [:option {:value "1" :description "Oh hello"} "Label"] 66 | 67 | (fn [[first second third]] 68 | (let [expected {:value "1" :text {:type :plain_text :text "Label"}}] 69 | (is (= expected first second)) 70 | (is (= (merge expected {:description {:type :plain_text :text "Oh hello" :emoji true}}) third))))) 71 | 72 | (defrendertest option-group 73 | [:option-group 74 | [:label "Pizza Toppings"] 75 | [:option {:value "1"} "Mushrooms"] 76 | [:option {:value "2"} "Pepperoni"]] 77 | 78 | (fn [[group]] 79 | (is (= {:label {:type :plain_text :text "Pizza Toppings" :emoji true} 80 | :options [{:value "1" :text {:type :plain_text :text "Mushrooms"}} 81 | {:value "2" :text {:type :plain_text :text "Pepperoni"}}]} group)))) 82 | 83 | ;;; Elements 84 | 85 | (defn assert-confirm 86 | "Helper to make asserting on confirms easier" 87 | [base actual & {:keys [confirm deny title text]}] 88 | (is (= (merge base {:confirm {:confirm {:type :plain_text 89 | :text confirm 90 | :emoji true} 91 | :deny {:type :plain_text 92 | :text deny 93 | :emoji true} 94 | :title {:type :plain_text 95 | :text title 96 | :emoji true} 97 | :text {:type :plain_text 98 | :text text} 99 | :style :primary}}) actual))) 100 | 101 | (defrendertest button 102 | [:button {:action_id "A123" :value "1"} 103 | "Click Me!"] 104 | 105 | [:button {:action_id "A123" :value "1"} 106 | [:text "Click Me!"]] 107 | 108 | [:button {:action_id "A123" :value "1"} 109 | [:confirm {:confirm "Ok!" :deny "Nah!" :title "You sure?!?!?"} 110 | [:text "This is irreversible!"]] 111 | "Click Me!"] 112 | 113 | [:button "Click Me!"] 114 | 115 | (fn [[literal with-text with-confirm no-props]] 116 | (let [expected {:action_id "A123" 117 | :value "1" 118 | :type :button 119 | :text {:type :plain_text 120 | :text "Click Me!"}}] 121 | (is (= expected literal with-text)) 122 | (is (= (dissoc expected :value :action_id) (dissoc no-props :action_id))) 123 | (is (string? (:action_id no-props))) 124 | (assert-confirm expected with-confirm :confirm "Ok!" :deny "Nah!" :title "You sure?!?!?" :text "This is irreversible!")))) 125 | 126 | (defrendertest checkboxes 127 | [:checkboxes {:action_id "A123"} 128 | [:option {:value "1"} "Mushrooms"] 129 | [:option {:value "2" :selected? true} "Pepperoni"]] 130 | 131 | [:checkboxes {:action_id "A123"} 132 | [:option {:value "1"} "Mushrooms"] 133 | [:option {:value "2" :selected? true} "Pepperoni"] 134 | [:confirm {:confirm "Ok!" :deny "Nah!" :title "You sure?!?!?"} 135 | [:text "This is irreversible!"]]] 136 | 137 | (fn [[first with-confirm]] 138 | (let [expected {:action_id "A123" 139 | :type :checkboxes 140 | :options [{:value "1" :text {:type :plain_text :text "Mushrooms"}} 141 | {:value "2" :text {:type :plain_text :text "Pepperoni"}}] 142 | :initial_options [{:value "2" :text {:type :plain_text :text "Pepperoni"}}]}] 143 | (is (= expected first)) 144 | (assert-confirm expected with-confirm :confirm "Ok!" :deny "Nah!" :title "You sure?!?!?" :text "This is irreversible!")))) 145 | 146 | (defrendertest datepicker 147 | [:datepicker {:action_id "A123" :initial_date "2020-11-30"} 148 | [:placeholder "The date"]] 149 | 150 | [:datepicker {:action_id "A123" :initial_date "2020-11-30"} 151 | [:placeholder "The date"] 152 | [:confirm {:confirm "Ok!" :deny "Nah!" :title "You sure?!?!?"} 153 | [:text "This is irreversible!"]]] 154 | 155 | (fn [[first with-confirm]] 156 | (let [expected {:action_id "A123" 157 | :type :datepicker 158 | :initial_date "2020-11-30" 159 | :placeholder {:type :plain_text :text "The date" :emoji true}}] 160 | (is (= expected first)) 161 | (assert-confirm expected with-confirm :confirm "Ok!" :deny "Nah!" :title "You sure?!?!?" :text "This is irreversible!")))) 162 | 163 | (defrendertest timepicker 164 | [:timepicker {:action_id "A123" :initial_time "12:30"} 165 | [:placeholder "The time"]] 166 | 167 | [:timepicker {:action_id "A123" :initial_time "12:30"} 168 | [:placeholder "The time"] 169 | [:confirm {:confirm "Ok!" :deny "Nah!" :title "You sure?!?!?"} 170 | [:text "This is irreversible!"]]] 171 | 172 | (fn [[first with-confirm]] 173 | (let [expected {:action_id "A123" 174 | :type :timepicker 175 | :initial_time "12:30" 176 | :placeholder {:type :plain_text :text "The time" :emoji true}}] 177 | (is (= expected first)) 178 | (assert-confirm expected with-confirm :confirm "Ok!" :deny "Nah!" :title "You sure?!?!?" :text "This is irreversible!")))) 179 | 180 | (defrendertest img 181 | [:img {:image_url "http://www.fillmurray.com/200/300" :alt_text "It's Bill Murray"}] 182 | 183 | (fn [[element]] 184 | (is (= {:type :image :image_url "http://www.fillmurray.com/200/300" :alt_text "It's Bill Murray"} element)))) 185 | 186 | (defrendertest multi-static-select-options 187 | [:multi-static-select {:action_id "A123" :max_selected_items 5} 188 | [:placeholder "Pizza Toppings"] 189 | [:option {:value "1"} "Mushrooms"] 190 | [:option {:value "2" :selected? true} "Pepperoni"] 191 | [:option {:value "3" :selected? true} "Cheese"]] 192 | 193 | [:multi-static-select {:action_id "A123" :max_selected_items 5} 194 | [:placeholder "Pizza Toppings"] 195 | [:option {:value "1"} "Mushrooms"] 196 | [:option {:value "2" :selected? true} "Pepperoni"] 197 | [:option {:value "3" :selected? true} "Cheese"] 198 | [:confirm {:confirm "Ok!" :deny "Nah!" :title "You sure?!?!?"} 199 | [:text "This is irreversible!"]]] 200 | 201 | (fn [[first with-confirm]] 202 | (let [expected {:action_id "A123" 203 | :max_selected_items 5 204 | :type :multi_static_select 205 | :placeholder {:type :plain_text :text "Pizza Toppings" :emoji true} 206 | :options [{:value "1" :text {:type :plain_text :text "Mushrooms"}} 207 | {:value "2" :text {:type :plain_text :text "Pepperoni"}} 208 | {:value "3" :text {:type :plain_text :text "Cheese"}}] 209 | :initial_options [{:value "2" :text {:type :plain_text :text "Pepperoni"}} 210 | {:value "3" :text {:type :plain_text :text "Cheese"}}]}] 211 | (is (= expected first)) 212 | (assert-confirm expected with-confirm :confirm "Ok!" :deny "Nah!" :title "You sure?!?!?" :text "This is irreversible!")))) 213 | 214 | (defrendertest multi-static-select-option-groups 215 | [:multi-static-select {:action_id "A123" :max_selected_items 5} 216 | [:placeholder "Pizza Toppings"] 217 | [:option-group 218 | [:label "Veggies"] 219 | [:option {:value "1"} "Mushrooms"] 220 | [:option {:value "2" :selected? true} "Peppers"]] 221 | [:option-group 222 | [:label "Meats"] 223 | [:option {:value "3"} "Pepperoni"] 224 | [:option {:value "4" :selected? true} "Ham"]]] 225 | 226 | [:multi-static-select {:action_id "A123" :max_selected_items 5} 227 | [:placeholder "Pizza Toppings"] 228 | [:option-group 229 | [:label "Veggies"] 230 | [:option {:value "1"} "Mushrooms"] 231 | [:option {:value "2" :selected? true} "Peppers"]] 232 | [:option-group 233 | [:label "Meats"] 234 | [:option {:value "3"} "Pepperoni"] 235 | [:option {:value "4" :selected? true} "Ham"]] 236 | [:confirm {:confirm "Ok!" :deny "Nah!" :title "You sure?!?!?"} 237 | [:text "This is irreversible!"]]] 238 | 239 | (fn [[first with-confirm]] 240 | (let [expected {:action_id "A123" 241 | :max_selected_items 5 242 | :type :multi_static_select 243 | :placeholder {:type :plain_text :text "Pizza Toppings" :emoji true} 244 | :option_groups [{:label {:type :plain_text :text "Veggies" :emoji true} 245 | :options [{:value "1" :text {:type :plain_text :text "Mushrooms"}} 246 | {:value "2" :text {:type :plain_text :text "Peppers"}}]} 247 | {:label {:type :plain_text :text "Meats" :emoji true} 248 | :options [{:value "3" :text {:type :plain_text :text "Pepperoni"}} 249 | {:value "4" :text {:type :plain_text :text "Ham"}}]}] 250 | :initial_options [{:value "2" :text {:type :plain_text :text "Peppers"}} 251 | {:value "4" :text {:type :plain_text :text "Ham"}}]}] 252 | (is (= expected first)) 253 | (assert-confirm expected with-confirm :confirm "Ok!" :deny "Nah!" :title "You sure?!?!?" :text "This is irreversible!")))) 254 | 255 | (defrendertest multi-external-select 256 | [:multi-external-select {:action_id "A123" :max_selected_items 5 :min_query_length 3} 257 | [:placeholder "Pizza Toppings"] 258 | [:option {:value "1"} "Pepperoni"] 259 | [:option {:value "2"} "Mushrooms"]] 260 | 261 | [:multi-external-select {:action_id "A123" :max_selected_items 5 :min_query_length 3} 262 | [:placeholder "Pizza Toppings"] 263 | [:option {:value "1"} "Pepperoni"] 264 | [:option {:value "2"} "Mushrooms"] 265 | [:confirm {:confirm "Ok!" :deny "Nah!" :title "You sure?!?!?"} 266 | [:text "This is irreversible!"]]] 267 | 268 | (fn [[first with-confirm]] 269 | (let [expected {:action_id "A123" 270 | :max_selected_items 5 271 | :min_query_length 3 272 | :type :multi_external_select 273 | :placeholder {:type :plain_text :text "Pizza Toppings" :emoji true} 274 | :initial_options [{:value "1" :text {:type :plain_text :text "Pepperoni"}} 275 | {:value "2" :text {:type :plain_text :text "Mushrooms"}}]}] 276 | (is (= expected first)) 277 | (assert-confirm expected with-confirm :confirm "Ok!" :deny "Nah!" :title "You sure?!?!?" :text "This is irreversible!")))) 278 | 279 | (defrendertest multi-users-select 280 | [:multi-users-select {:action_id "A123" :max_selected_items 3 :initial_users ["U123" "U456"]} 281 | [:placeholder "Team captains"]] 282 | 283 | [:multi-users-select {:action_id "A123" :max_selected_items 3 :initial_users ["U123" "U456"]} 284 | [:placeholder "Team captains"] 285 | [:confirm {:confirm "Ok!" :deny "Nah!" :title "You sure?!?!?"} 286 | [:text "This is irreversible!"]]] 287 | 288 | (fn [[first with-confirm]] 289 | (let [expected {:action_id "A123" 290 | :max_selected_items 3 291 | :initial_users ["U123" "U456"] 292 | :type :multi_users_select 293 | :placeholder {:type :plain_text :text "Team captains" :emoji true}}] 294 | (is (= expected first)) 295 | (assert-confirm expected with-confirm :confirm "Ok!" :deny "Nah!" :title "You sure?!?!?" :text "This is irreversible!")))) 296 | 297 | (defrendertest multi-conversations-select 298 | [:multi-conversations-select {:action_id "A123" 299 | :max_selected_items 3 300 | :default_to_current_conversation true 301 | :initial_conversations ["C123" "C456"] 302 | :filter {:include #{:private} 303 | :exclude_bot_users true 304 | :exclude_external_shared_channels true}} 305 | [:placeholder "Select conversation"]] 306 | 307 | [:multi-conversations-select {:action_id "A123" 308 | :max_selected_items 3 309 | :default_to_current_conversation true 310 | :initial_conversations ["C123" "C456"] 311 | :filter {:include #{:private} 312 | :exclude_bot_users true 313 | :exclude_external_shared_channels true}} 314 | [:placeholder "Select conversation"] 315 | [:confirm {:confirm "Ok!" :deny "Nah!" :title "You sure?!?!?"} 316 | [:text "This is irreversible!"]]] 317 | 318 | (fn [[first with-confirm]] 319 | (let [expected {:action_id "A123" 320 | :max_selected_items 3 321 | :default_to_current_conversation true 322 | :initial_conversations ["C123" "C456"] 323 | :filter {:include #{:private} 324 | :exclude_bot_users true 325 | :exclude_external_shared_channels true} 326 | :type :multi_conversations_select 327 | :placeholder {:type :plain_text :text "Select conversation" :emoji true}}] 328 | (is (= expected first)) 329 | (assert-confirm expected with-confirm :confirm "Ok!" :deny "Nah!" :title "You sure?!?!?" :text "This is irreversible!")))) 330 | 331 | (defrendertest multi-channels-select 332 | [:multi-channels-select {:action_id "A123" :max_selected_items 3 :initial_channels ["C123" "C456"]} 333 | [:placeholder "Select channel"]] 334 | 335 | [:multi-channels-select {:action_id "A123" :max_selected_items 3 :initial_channels ["C123" "C456"]} 336 | [:placeholder "Select channel"] 337 | [:confirm {:confirm "Ok!" :deny "Nah!" :title "You sure?!?!?"} 338 | [:text "This is irreversible!"]]] 339 | 340 | (fn [[first with-confirm]] 341 | (let [expected {:action_id "A123" 342 | :max_selected_items 3 343 | :initial_channels ["C123" "C456"] 344 | :type :multi_channels_select 345 | :placeholder {:type :plain_text :text "Select channel" :emoji true}}] 346 | (is (= expected first)) 347 | (assert-confirm expected with-confirm :confirm "Ok!" :deny "Nah!" :title "You sure?!?!?" :text "This is irreversible!")))) 348 | 349 | (defrendertest static-select-options 350 | [:static-select {:action_id "A123"} 351 | [:placeholder "Pizza Toppings"] 352 | [:option {:value "1"} "Mushrooms"] 353 | [:option {:value "2" :selected? true} "Pepperoni"] 354 | [:option {:value "3"} "Cheese"]] 355 | 356 | [:static-select {:action_id "A123"} 357 | [:placeholder "Pizza Toppings"] 358 | [:option {:value "1"} "Mushrooms"] 359 | [:option {:value "2" :selected? true} "Pepperoni"] 360 | [:option {:value "3"} "Cheese"] 361 | [:confirm {:confirm "Ok!" :deny "Nah!" :title "You sure?!?!?"} 362 | [:text "This is irreversible!"]]] 363 | 364 | (fn [[first with-confirm]] 365 | (let [expected {:action_id "A123" 366 | :type :static_select 367 | :placeholder {:type :plain_text :text "Pizza Toppings" :emoji true} 368 | :options [{:value "1" :text {:type :plain_text :text "Mushrooms"}} 369 | {:value "2" :text {:type :plain_text :text "Pepperoni"}} 370 | {:value "3" :text {:type :plain_text :text "Cheese"}}] 371 | :initial_option {:value "2" :text {:type :plain_text :text "Pepperoni"}}}] 372 | (is (= expected first)) 373 | (assert-confirm expected with-confirm :confirm "Ok!" :deny "Nah!" :title "You sure?!?!?" :text "This is irreversible!")))) 374 | 375 | (defrendertest static-select-option-groups 376 | [:static-select {:action_id "A123"} 377 | [:placeholder "Pizza Toppings"] 378 | [:option-group 379 | [:label "Veggies"] 380 | [:option {:value "1"} "Mushrooms"] 381 | [:option {:value "2" :selected? true} "Peppers"]] 382 | [:option-group 383 | [:label "Meats"] 384 | [:option {:value "3"} "Pepperoni"] 385 | [:option {:value "4"} "Ham"]]] 386 | 387 | [:static-select {:action_id "A123"} 388 | [:placeholder "Pizza Toppings"] 389 | [:option-group 390 | [:label "Veggies"] 391 | [:option {:value "1"} "Mushrooms"] 392 | [:option {:value "2" :selected? true} "Peppers"]] 393 | [:option-group 394 | [:label "Meats"] 395 | [:option {:value "3"} "Pepperoni"] 396 | [:option {:value "4"} "Ham"]] 397 | [:confirm {:confirm "Ok!" :deny "Nah!" :title "You sure?!?!?"} 398 | [:text "This is irreversible!"]]] 399 | 400 | (fn [[first with-confirm]] 401 | (let [expected {:action_id "A123" 402 | :type :static_select 403 | :placeholder {:type :plain_text :text "Pizza Toppings" :emoji true} 404 | :option_groups [{:label {:type :plain_text :text "Veggies" :emoji true} 405 | :options [{:value "1" :text {:type :plain_text :text "Mushrooms"}} 406 | {:value "2" :text {:type :plain_text :text "Peppers"}}]} 407 | {:label {:type :plain_text :text "Meats" :emoji true} 408 | :options [{:value "3" :text {:type :plain_text :text "Pepperoni"}} 409 | {:value "4" :text {:type :plain_text :text "Ham"}}]}] 410 | :initial_option {:value "2" :text {:type :plain_text :text "Peppers"}}}] 411 | (is (= expected first)) 412 | (assert-confirm expected with-confirm :confirm "Ok!" :deny "Nah!" :title "You sure?!?!?" :text "This is irreversible!")))) 413 | 414 | (defrendertest external-select 415 | [:external-select {:action_id "A123" :min_query_length 3} 416 | [:placeholder "Pizza Toppings"] 417 | [:option {:value "1"} "Pepperoni"]] 418 | 419 | [:external-select {:action_id "A123" :min_query_length 3} 420 | [:placeholder "Pizza Toppings"] 421 | [:option {:value "1"} "Pepperoni"] 422 | [:confirm {:confirm "Ok!" :deny "Nah!" :title "You sure?!?!?"} 423 | [:text "This is irreversible!"]]] 424 | 425 | (fn [[first with-confirm]] 426 | (let [expected {:action_id "A123" 427 | :min_query_length 3 428 | :type :external_select 429 | :placeholder {:type :plain_text :text "Pizza Toppings" :emoji true} 430 | :initial_option {:value "1" :text {:type :plain_text :text "Pepperoni"}}}] 431 | (is (= expected first)) 432 | (assert-confirm expected with-confirm :confirm "Ok!" :deny "Nah!" :title "You sure?!?!?" :text "This is irreversible!")))) 433 | 434 | (defrendertest users-select 435 | [:users-select {:action_id "A123" :initial_user "U123"} 436 | [:placeholder "Team captain"]] 437 | 438 | [:users-select {:action_id "A123" :initial_user "U123"} 439 | [:placeholder "Team captain"] 440 | [:confirm {:confirm "Ok!" :deny "Nah!" :title "You sure?!?!?"} 441 | [:text "This is irreversible!"]]] 442 | 443 | (fn [[first with-confirm]] 444 | (let [expected {:action_id "A123" 445 | :initial_user "U123" 446 | :type :users_select 447 | :placeholder {:type :plain_text :text "Team captain" :emoji true}}] 448 | (is (= expected first)) 449 | (assert-confirm expected with-confirm :confirm "Ok!" :deny "Nah!" :title "You sure?!?!?" :text "This is irreversible!")))) 450 | 451 | (defrendertest conversations-select 452 | [:conversations-select {:action_id "A123" 453 | :default_to_current_conversation true 454 | :initial_conversation "C123" 455 | :filter {:include #{:private} 456 | :exclude_bot_users true 457 | :exclude_external_shared_channels true}} 458 | [:placeholder "Select conversation"]] 459 | 460 | [:conversations-select {:action_id "A123" 461 | :default_to_current_conversation true 462 | :initial_conversation "C123" 463 | :filter {:include #{:private} 464 | :exclude_bot_users true 465 | :exclude_external_shared_channels true}} 466 | [:placeholder "Select conversation"] 467 | [:confirm {:confirm "Ok!" :deny "Nah!" :title "You sure?!?!?"} 468 | [:text "This is irreversible!"]]] 469 | 470 | (fn [[first with-confirm]] 471 | (let [expected {:action_id "A123" 472 | :default_to_current_conversation true 473 | :initial_conversation "C123" 474 | :filter {:include #{:private} 475 | :exclude_bot_users true 476 | :exclude_external_shared_channels true} 477 | :type :conversations_select 478 | :placeholder {:type :plain_text :text "Select conversation" :emoji true}}] 479 | (is (= expected first)) 480 | (assert-confirm expected with-confirm :confirm "Ok!" :deny "Nah!" :title "You sure?!?!?" :text "This is irreversible!")))) 481 | 482 | (defrendertest channels-select 483 | [:channels-select {:action_id "A123" :initial_channel "C123"} 484 | [:placeholder "Select channel"]] 485 | 486 | [:channels-select {:action_id "A123" :initial_channel "C123"} 487 | [:placeholder "Select channel"] 488 | [:confirm {:confirm "Ok!" :deny "Nah!" :title "You sure?!?!?"} 489 | [:text "This is irreversible!"]]] 490 | 491 | (fn [[first with-confirm]] 492 | (let [expected {:action_id "A123" 493 | :initial_channel "C123" 494 | :type :channels_select 495 | :placeholder {:type :plain_text :text "Select channel" :emoji true}}] 496 | (is (= expected first)) 497 | (assert-confirm expected with-confirm :confirm "Ok!" :deny "Nah!" :title "You sure?!?!?" :text "This is irreversible!")))) 498 | 499 | (defrendertest overflow 500 | [:overflow {:action_id "A123"} 501 | [:option {:value "1" :url "https://google.com"} "Google"] 502 | [:option {:value "2" :url "https://bing.com"} "Bing"] 503 | [:option {:value "3" :url "https://duckduckgo.com"} "DuckDuckGo"]] 504 | 505 | [:overflow 506 | [:option {:value "1" :url "https://google.com"} "Google"] 507 | [:option {:value "2" :url "https://bing.com"} "Bing"] 508 | [:option {:value "3" :url "https://duckduckgo.com"} "DuckDuckGo"]] 509 | 510 | [:overflow {:action_id "A123"} 511 | [:option {:value "1" :url "https://google.com"} "Google"] 512 | [:option {:value "2" :url "https://bing.com"} "Bing"] 513 | [:option {:value "3" :url "https://duckduckgo.com"} "DuckDuckGo"] 514 | [:confirm {:confirm "Ok!" :deny "Nah!" :title "You sure?!?!?"} 515 | [:text "This is irreversible!"]]] 516 | 517 | (fn [[first no-props with-confirm]] 518 | (let [expected {:action_id "A123" 519 | :type :overflow 520 | :options [{:value "1" :url "https://google.com" :text {:type :plain_text :text "Google"}} 521 | {:value "2" :url "https://bing.com" :text {:type :plain_text :text "Bing"}} 522 | {:value "3" :url "https://duckduckgo.com" :text {:type :plain_text :text "DuckDuckGo"}}]}] 523 | (is (= expected first)) 524 | (is (= (dissoc expected :action_id) (dissoc no-props :action_id))) 525 | (is (string? (:action_id no-props))) 526 | (assert-confirm expected with-confirm :confirm "Ok!" :deny "Nah!" :title "You sure?!?!?" :text "This is irreversible!")))) 527 | 528 | (defrendertest plain-text-input 529 | [:plain-text-input {:action_id "A123" 530 | :initial_value "hello" 531 | :multiline true 532 | :min_length 1 533 | :max_length 100 534 | :dispatch_action_config {:trigger_actions_on [:on_enter_pressed]}} 535 | [:placeholder "Greeting"]] 536 | 537 | (fn [[element]] 538 | (let [expected {:action_id "A123" 539 | :initial_value "hello" 540 | :multiline true 541 | :min_length 1 542 | :max_length 100 543 | :dispatch_action_config {:trigger_actions_on [:on_enter_pressed]} 544 | :type :plain_text_input 545 | :placeholder {:type :plain_text :text "Greeting" :emoji true}}] 546 | (is (= expected element))))) 547 | 548 | (defrendertest radio-buttons 549 | [:radio-buttons {:action_id "A123"} 550 | [:option {:value "1"} "Pepperoni"] 551 | [:option {:value "2" :selected? true} "Pineapple"] 552 | [:option {:value "3"} "Mushrooms"]] 553 | 554 | [:radio-buttons {:action_id "A123"} 555 | [:option {:value "1"} "Pepperoni"] 556 | [:option {:value "2" :selected? true} "Pineapple"] 557 | [:option {:value "3"} "Mushrooms"] 558 | [:confirm {:confirm "Ok!" :deny "Nah!" :title "You sure?!?!?"} 559 | [:text "This is irreversible!"]]] 560 | 561 | (fn [[first with-confirm]] 562 | (let [expected {:action_id "A123" 563 | :type :radio_buttons 564 | :options [{:value "1" :text {:type :plain_text :text "Pepperoni"}} 565 | {:value "2" :text {:type :plain_text :text "Pineapple"}} 566 | {:value "3" :text {:type :plain_text :text "Mushrooms"}}] 567 | :initial_option 568 | {:value "2" :text {:type :plain_text :text "Pineapple"}}}] 569 | (is (= expected first)) 570 | (assert-confirm expected with-confirm :confirm "Ok!" :deny "Nah!" :title "You sure?!?!?" :text "This is irreversible!")))) 571 | 572 | ;;; Blocks 573 | 574 | (defrendertest actions 575 | [:actions {:block_id "B123"} 576 | [:radio-buttons {:action_id "A123"} 577 | [:option {:value "1"} "Pepperoni"] 578 | [:option {:value "2" :selected? true} "Pineapple"] 579 | [:option {:value "3"} "Mushrooms"]] 580 | [:channels-select {:action_id "A456" :initial_channel "C123"} 581 | [:placeholder "Select channel"]]] 582 | 583 | [:actions 584 | [:radio-buttons {:action_id "A123"} 585 | [:option {:value "1"} "Pepperoni"] 586 | [:option {:value "2" :selected? true} "Pineapple"] 587 | [:option {:value "3"} "Mushrooms"]] 588 | [:channels-select {:action_id "A456" :initial_channel "C123"} 589 | [:placeholder "Select channel"]]] 590 | 591 | (fn [[block no-props]] 592 | (let [expected {:block_id "B123" 593 | :type :actions 594 | :elements 595 | [{:action_id "A123" 596 | :type :radio_buttons 597 | :options [{:value "1" :text {:type :plain_text :text "Pepperoni"}} 598 | {:value "2" :text {:type :plain_text :text "Pineapple"}} 599 | {:value "3" :text {:type :plain_text :text "Mushrooms"}}] 600 | :initial_option 601 | {:value "2" :text {:type :plain_text :text "Pineapple"}}} 602 | {:action_id "A456" 603 | :initial_channel "C123" 604 | :type :channels_select 605 | :placeholder 606 | {:type :plain_text :text "Select channel" :emoji true}}]}] 607 | (is (= expected block)) 608 | (is (= (dissoc expected :block_id) no-props))))) 609 | 610 | (defrendertest fields 611 | [:fields 612 | [:plain-text "Hello"] 613 | [:markdown "# There"] 614 | [:text {:type :plain_text :text "My friend" :emoji false}] 615 | [:text {:type :mrkdwn :text "## Pleased to meet you" :verbatim false}]] 616 | 617 | (fn [[fields]] 618 | (let [expected {:fields 619 | [{:type :plain_text, :text "Hello", :emoji true} 620 | {:type :mrkdwn, :text "# There", :verbatim false} 621 | {:type :plain_text, :text "My friend", :emoji false} 622 | {:type :mrkdwn, :text "## Pleased to meet you", :verbatim false}]}] 623 | (is (= expected fields))))) 624 | 625 | (defrendertest section 626 | [:section {:block_id "B123"} 627 | [:text "This is an important action"] 628 | [:datepicker {:action_id "A123" :initial_date "2020-11-30"} 629 | [:placeholder "The date"] 630 | [:confirm {:confirm "Ok!" :deny "Nah!" :title "You sure?!?!?"} 631 | [:text "This is irreversible!"]]]] 632 | 633 | [:section 634 | [:text "This is an important action"] 635 | [:datepicker {:action_id "A123" :initial_date "2020-11-30"} 636 | [:placeholder "The date"] 637 | [:confirm {:confirm "Ok!" :deny "Nah!" :title "You sure?!?!?"} 638 | [:text "This is irreversible!"]]]] 639 | 640 | [:section {:block_id "B123"} 641 | [:text "This is an important action"] 642 | [:fields 643 | [:markdown "# Field 1"] 644 | [:plain-text "Field 2"]] 645 | [:datepicker {:action_id "A123" :initial_date "2020-11-30"} 646 | [:placeholder "The date"] 647 | [:confirm {:confirm "Ok!" :deny "Nah!" :title "You sure?!?!?"} 648 | [:text "This is irreversible!"]]]] 649 | 650 | [:section {:block_id "B123"} 651 | [:fields 652 | [:markdown "# Field 1"] 653 | [:plain-text "Field 2"]] 654 | [:datepicker {:action_id "A123" :initial_date "2020-11-30"} 655 | [:placeholder "The date"] 656 | [:confirm {:confirm "Ok!" :deny "Nah!" :title "You sure?!?!?"} 657 | [:text "This is irreversible!"]]]] 658 | 659 | [:section 660 | [:text "Just text"]] 661 | 662 | (fn [[block no-props with-fields only-fields text-only]] 663 | (let [expected {:block_id "B123" 664 | :type :section 665 | :accessory 666 | {:action_id "A123" 667 | :initial_date "2020-11-30" 668 | :type :datepicker 669 | :confirm 670 | {:confirm {:type :plain_text, :text "Ok!", :emoji true} 671 | :deny {:type :plain_text, :text "Nah!", :emoji true} 672 | :title {:type :plain_text, :text "You sure?!?!?", :emoji true} 673 | :text {:type :plain_text, :text "This is irreversible!"} 674 | :style :primary} 675 | :placeholder {:type :plain_text, :text "The date", :emoji true}} 676 | :text {:type :plain_text, :text "This is an important action"}} 677 | expected-with-fields (assoc expected :fields 678 | [{:type :mrkdwn, :text "# Field 1", :verbatim false} 679 | {:type :plain_text, :text "Field 2", :emoji true}])] 680 | (is (= expected block)) 681 | (is (= (dissoc expected :block_id) no-props)) 682 | (is (= expected-with-fields with-fields)) 683 | (is (= (dissoc expected-with-fields :text) only-fields)) 684 | (is (= {:type :section :text {:type :plain_text :text "Just text"}} text-only))))) 685 | 686 | (defrendertest context 687 | [:context {:block_id "B123"} 688 | [:image {:alt_text "It's Bill" :image_url "http://www.fillmurray.com/200/300"}] 689 | [:text "This is some text"]] 690 | 691 | [:context 692 | [:image {:alt_text "It's Bill" :image_url "http://www.fillmurray.com/200/300"}] 693 | [:text "This is some text"]] 694 | 695 | (fn [[context no-props]] 696 | (let [expected {:block_id "B123" 697 | :type :context 698 | :elements [{:alt_text "It's Bill" 699 | :image_url "http://www.fillmurray.com/200/300" 700 | :type :image} 701 | {:type :plain_text 702 | :text "This is some text"}]}] 703 | (is (= expected context)) 704 | (is (= (dissoc expected :block_id) no-props))))) 705 | 706 | (defrendertest divider 707 | [:divider] 708 | [:divider {:block_id "B123"}] 709 | 710 | (fn [[first second]] 711 | (is (= {:type :divider} first)) 712 | (is (= {:type :divider :block_id "B123"} second)))) 713 | 714 | (defrendertest header 715 | [:header {:block_id "B123"} 716 | [:text "Hello"]] 717 | 718 | [:header {:block_id "B123"} "Hello"] 719 | 720 | [:header "Hello"] 721 | 722 | (fn [[first second no-props]] 723 | (let [expected {:block_id "B123" 724 | :type :header 725 | :text {:type :plain_text :text "Hello"}}] 726 | (is (= expected first)) 727 | (is (= expected second)) 728 | (is (= (dissoc expected :block_id) no-props))))) 729 | 730 | (defrendertest image 731 | [:image {:image_url "http://www.fillmurray.com/200/300" 732 | :alt_text "It's Bill" 733 | :block_id "B123"} 734 | [:title "Wowzers!"]] 735 | 736 | [:image {:image_url "http://www.fillmurray.com/200/300" 737 | :alt_text "It's Bill" 738 | :block_id "B123"} 739 | "Wowzers!"] 740 | 741 | [:image {:image_url "http://www.fillmurray.com/200/300" 742 | :alt_text "It's Bill" 743 | :block_id "B123"}] 744 | 745 | (fn [[first second third]] 746 | (let [expected {:block_id "B123" 747 | :alt_text "It's Bill" 748 | :type :image 749 | :image_url "http://www.fillmurray.com/200/300" 750 | :title {:type :plain_text :text "Wowzers!" :emoji true}}] 751 | (is (= expected first)) 752 | (is (= (update expected :title dissoc :emoji) second)) 753 | (is (= (dissoc expected :title) third))))) 754 | 755 | (defrendertest input 756 | [:input {:block_id "B123" :dispatch_action false :optional false} 757 | [:label "Some input"] 758 | [:hint "Do something radical"] 759 | [:plain-text-input {:action_id "A123" 760 | :initial_value "hello"} 761 | [:placeholder "Greeting"]]] 762 | 763 | [:input 764 | [:label "Some input"] 765 | [:hint "Do something radical"] 766 | [:plain-text-input {:action_id "A123" 767 | :initial_value "hello"} 768 | [:placeholder "Greeting"]]] 769 | 770 | (fn [[block no-props]] 771 | (let [expected {:block_id "B123" 772 | :dispatch_action false 773 | :optional false 774 | :type :input 775 | :element {:action_id "A123" 776 | :initial_value "hello" 777 | :type :plain_text_input 778 | :placeholder {:type :plain_text :text "Greeting" :emoji true}} 779 | :label {:type :plain_text :text "Some input" :emoji true} 780 | :hint {:type :plain_text :text "Do something radical" :emoji true}}] 781 | (is (= expected block)) 782 | (is (= (dissoc expected :block_id :dispatch_action :optional) no-props))))) 783 | 784 | 785 | ;;; Views 786 | 787 | 788 | (defrendertest home 789 | [:home {:private_metadata {:cool? true}} 790 | [:section {:block_id "B123"} 791 | [:text "Some text"]]] 792 | 793 | [:home 794 | [:section {:block_id "B123"} 795 | [:text "Some text"]]] 796 | 797 | (fn [[view no-props]] 798 | (let [expected {:type :home 799 | :private_metadata "{:cool? true}" 800 | :blocks [{:block_id "B123" 801 | :type :section 802 | :text {:type :plain_text :text "Some text"}}]}] 803 | (is (= expected view)) 804 | (is (= (dissoc expected :private_metadata) no-props))))) 805 | 806 | (defrendertest modal 807 | [:modal {:private_metadata {:cool? true} 808 | :title "Cool Modal!" 809 | :close "Nah!" 810 | :submit "Yah!"} 811 | [:section {:block_id "B123"} 812 | [:text "Some text"]]] 813 | 814 | [:modal {:private_metadata {:cool? true} 815 | :title "Cool Modal!" 816 | :close "Nah!" 817 | :submit "Yah!" 818 | :disable_emoji_for #{:title :close :submit}} 819 | [:section {:block_id "B123"} 820 | [:text "Some text"]]] 821 | (fn [[view no-emoji]] 822 | (let [expected {:type :modal 823 | :private_metadata "{:cool? true}" 824 | :title {:type :plain_text :text "Cool Modal!" :emoji true} 825 | :close {:type :plain_text :text "Nah!" :emoji true} 826 | :submit {:type :plain_text :text "Yah!" :emoji true} 827 | :blocks [{:block_id "B123" 828 | :type :section 829 | :text {:type :plain_text :text "Some text"}}]}] 830 | (is (= expected view)) 831 | (is (= (-> expected 832 | (assoc-in [:title :emoji] false) 833 | (assoc-in [:close :emoji] false) 834 | (assoc-in [:submit :emoji] false)) no-emoji))))) 835 | 836 | ;;; Messages 837 | 838 | (defrendertest message 839 | [:message {:thread_ts "107"} 840 | "Text"] 841 | 842 | [:message "Just text"] 843 | 844 | [:message "Fallback" [:divider]] 845 | 846 | [:message {:thread_ts "107"} [:divider]] 847 | 848 | [:message [:header "text"] [:header "text"]] 849 | 850 | [:message {:thread_ts "107"} "Fallback" [:divider]] 851 | 852 | (fn [[props-and-text just-text no-props-text-and-blocks props-and-blocks multiple-blocks-no-props all]] 853 | (is (= {:thread_ts "107" :text "Text"} props-and-text)) 854 | (is (= {:text "Just text"} just-text)) 855 | (is (= {:text "Fallback" :blocks [{:type :divider}]} no-props-text-and-blocks)) 856 | (is (= {:thread_ts "107" :blocks [{:type :divider}]} props-and-blocks)) 857 | (is (= {:blocks [{:type :header :text {:type :plain_text :text "text"}} {:type :header :text {:type :plain_text :text "text"}}]} multiple-blocks-no-props)) 858 | (is (= {:thread_ts "107" :text "Fallback" :blocks [{:type :divider}]} all)))) 859 | 860 | ;;; Custom components 861 | 862 | (defn fun-text 863 | [str] 864 | [:text str]) 865 | 866 | (defn custom 867 | [props & children] 868 | [:section {:block_id "B123"} 869 | [fun-text (:text props)] 870 | children]) 871 | 872 | (defrendertest custom-component 873 | [custom {:text "Hello"} 874 | [:button {:action_id "A123"} 875 | [:text "Click Me!"]]] 876 | (fn [[element]] 877 | (is (= {:block_id "B123" 878 | :type :section 879 | :accessory {:action_id "A123" 880 | :type :button 881 | :text {:type :plain_text 882 | :text "Click Me!"}} 883 | :text {:type :plain_text 884 | :text "Hello"}} element)))) 885 | 886 | ;;; Misc 887 | 888 | (defn render-sibling 889 | [n] 890 | [:divider {:block_id (str "sibling_" n)}]) 891 | 892 | (defc super-interesting-component 893 | [siblings] 894 | [:header "Header"] 895 | (map render-sibling siblings)) 896 | 897 | (defrendertest block-with-sequence-of-siblings 898 | [super-interesting-component [1 2]] 899 | (fn [[blocks]] 900 | (let [expected [{:type :header 901 | :text {:type :plain_text 902 | :text "Header"}} 903 | {:type :divider :block_id "sibling_1"} 904 | {:type :divider :block_id "sibling_2"}]] 905 | (is (= blocks expected))))) 906 | -------------------------------------------------------------------------------- /test/thlack/surfs/messages/components_test.clj: -------------------------------------------------------------------------------- 1 | (ns thlack.surfs.messages.components-test 2 | (:require [thlack.surfs.messages.components :as co] 3 | [thlack.surfs.test-utils :refer [defcheck]])) 4 | 5 | (defcheck message `co/message 18) 6 | -------------------------------------------------------------------------------- /test/thlack/surfs/render_test.clj: -------------------------------------------------------------------------------- 1 | (ns thlack.surfs.render-test 2 | "Mostly property based tests for rendering surfs" 3 | (:require [clojure.spec.alpha :as s] 4 | [clojure.spec.gen.alpha :as gen] 5 | [clojure.test :refer [deftest is]] 6 | [clojure.test.check.clojure-test :refer [defspec]] 7 | [clojure.test.check.properties :as prop] 8 | [thlack.surfs.render :as surfs.render] 9 | [thlack.surfs.blocks.components.spec :as bc.spec] 10 | [thlack.surfs.blocks.spec :as blocks.spec] 11 | [thlack.surfs.elements.components.spec :as ec.spec] 12 | [thlack.surfs.elements.spec :as elements.spec] 13 | [thlack.surfs.composition.spec :as comp.spec] 14 | [thlack.surfs.composition.spec.confirm :as confirm] 15 | [thlack.surfs.composition.spec.option :as option] 16 | [thlack.surfs.composition.components.spec :as cc.spec] 17 | [thlack.surfs.messages.components.spec :as mc.spec] 18 | [thlack.surfs.messages.spec :as messages.spec] 19 | [thlack.surfs.test-utils :refer [render]] 20 | [thlack.surfs.views.components.spec :as vc.spec] 21 | [thlack.surfs.views.spec :as views.spec])) 22 | 23 | (def iterations 10) 24 | 25 | ;;; Composition elements 26 | 27 | (defspec text 28 | iterations 29 | (prop/for-all [props (s/gen ::comp.spec/text)] 30 | (render ::comp.spec/text [:text props]))) 31 | 32 | (defspec confirm 33 | iterations 34 | (prop/for-all [props (s/gen ::cc.spec/confirm.props)] 35 | (let [text (gen/generate (s/gen ::confirm/text))] 36 | (render ::comp.spec/confirm 37 | [:confirm props 38 | [:text text]])))) 39 | 40 | (defspec option 41 | iterations 42 | (prop/for-all [props (s/gen ::cc.spec/option.props)] 43 | (let [text (gen/generate (s/gen ::option/text))] 44 | (render ::comp.spec/option [:option props text])))) 45 | 46 | (defn with-description 47 | [option] 48 | (if (some? (:description option)) 49 | (update option :description :text) 50 | option)) 51 | 52 | (defn make-options 53 | "Converts generated options into a map of option tags" 54 | [options] 55 | (when options 56 | (map #(vector :option (with-description %) (:text %)) options))) 57 | 58 | (defn make-option-groups 59 | [option-groups] 60 | (when option-groups 61 | (map (fn [{:keys [label options]}] 62 | [:option-group 63 | [:label label] 64 | (make-options options)]) option-groups))) 65 | 66 | (defn make-confirm 67 | "Converts an optional confirm generated from specs into a 68 | confirm tag" 69 | [confirm] 70 | (when confirm 71 | [:confirm 72 | (-> (select-keys confirm [:confirm :deny :title]) 73 | (update :confirm :text) 74 | (update :deny :text) 75 | (update :title :text)) 76 | [:text (:text confirm)]])) 77 | 78 | (defspec option-group 79 | iterations 80 | (prop/for-all [props (s/gen ::comp.spec/option-group)] 81 | (let [{label :label 82 | options :options} props] 83 | (render ::comp.spec/option-group 84 | [:option-group 85 | [:label label] 86 | (make-options options)])))) 87 | 88 | ;;; Elements 89 | 90 | (defspec button 91 | iterations 92 | (prop/for-all [props (s/gen ::elements.spec/button)] 93 | (let [{text :text 94 | confirm :confirm} props] 95 | (render ::elements.spec/button 96 | [:button props 97 | [:text text] 98 | (make-confirm confirm)])))) 99 | 100 | (defspec checkboxes 101 | iterations 102 | (prop/for-all [props (s/gen ::ec.spec/checkboxes.props) 103 | element (s/gen ::elements.spec/checkboxes)] 104 | (let [{options :options 105 | confirm :confirm} element] 106 | (render ::elements.spec/checkboxes 107 | [:checkboxes props 108 | (make-options options) 109 | (make-confirm confirm)])))) 110 | 111 | (defspec datepicker 112 | iterations 113 | (prop/for-all [props (s/gen ::ec.spec/datepicker.props) 114 | element (s/gen ::elements.spec/datepicker)] 115 | (let [{placeholder :placeholder 116 | confirm :confirm} element] 117 | (render ::elements.spec/datepicker 118 | [:datepicker props 119 | (when placeholder 120 | [:placeholder placeholder]) 121 | (make-confirm confirm)])))) 122 | 123 | (defspec timepicker 124 | iterations 125 | (prop/for-all [props (s/gen ::ec.spec/timepicker.props) 126 | element (s/gen ::elements.spec/timepicker)] 127 | (let [{placeholder :placeholder 128 | confirm :confirm} element] 129 | (render ::elements.spec/timepicker 130 | [:timepicker props 131 | (when placeholder 132 | [:placeholder placeholder]) 133 | (make-confirm confirm)])))) 134 | 135 | (defspec image-element 136 | iterations 137 | (prop/for-all [props (s/gen ::elements.spec/image)] 138 | (render ::elements.spec/image [:img props]))) 139 | 140 | (defspec multi-static-select 141 | iterations 142 | (prop/for-all [props (s/gen ::ec.spec/multi-select.props) 143 | element (s/gen ::elements.spec/multi-static-select)] 144 | (let [{placeholder :placeholder 145 | options :options 146 | option-groups :option-groups 147 | confirm :confirm} element] 148 | (render ::elements.spec/multi-static-select 149 | [:multi-static-select props 150 | [:placeholder placeholder] 151 | (make-options options) 152 | (make-option-groups option-groups) 153 | (make-confirm confirm)])))) 154 | 155 | (defspec multi-external-select 156 | iterations 157 | (prop/for-all [props (s/gen ::ec.spec/multi-external-select.props) 158 | element (s/gen ::elements.spec/multi-external-select)] 159 | (let [{placeholder :placeholder 160 | confirm :confirm} element] 161 | (render ::elements.spec/multi-external-select 162 | [:multi-external-select props 163 | [:placeholder placeholder] 164 | (make-confirm confirm)])))) 165 | 166 | (defspec multi-users-select 167 | iterations 168 | (prop/for-all [props (s/gen ::ec.spec/multi-users-select.props) 169 | element (s/gen ::elements.spec/multi-users-select)] 170 | (let [{placeholder :placeholder 171 | confirm :confirm} element] 172 | (render ::elements.spec/multi-users-select 173 | [:multi-users-select props 174 | [:placeholder placeholder] 175 | (make-confirm confirm)])))) 176 | 177 | (defspec multi-conversations-select 178 | iterations 179 | (prop/for-all [props (s/gen ::ec.spec/multi-conversations-select.props) 180 | element (s/gen ::elements.spec/multi-conversations-select)] 181 | (let [{placeholder :placeholder 182 | confirm :confirm} element] 183 | (render ::elements.spec/multi-conversations-select 184 | [:multi-conversations-select props 185 | [:placeholder placeholder] 186 | (make-confirm confirm)])))) 187 | 188 | (defspec multi-channels-select 189 | iterations 190 | (prop/for-all [props (s/gen ::ec.spec/multi-channels-select.props) 191 | element (s/gen ::elements.spec/multi-channels-select)] 192 | (let [{placeholder :placeholder 193 | confirm :confirm} element] 194 | (render ::elements.spec/multi-channels-select 195 | [:multi-channels-select props 196 | [:placeholder placeholder] 197 | (make-confirm confirm)])))) 198 | 199 | (defspec static-select 200 | iterations 201 | (prop/for-all [props (s/gen ::ec.spec/static-select.props) 202 | element (s/gen ::elements.spec/static-select)] 203 | (let [{placeholder :placeholder 204 | options :options 205 | option-groups :option-groups 206 | confirm :confirm} element] 207 | (render ::elements.spec/static-select 208 | [:static-select props 209 | [:placeholder placeholder] 210 | (make-options options) 211 | (make-option-groups option-groups) 212 | (make-confirm confirm)])))) 213 | 214 | (defspec external-select 215 | iterations 216 | (prop/for-all [props (s/gen ::ec.spec/external-select.props) 217 | element (s/gen ::elements.spec/external-select)] 218 | (let [{placeholder :placeholder 219 | confirm :confirm} element] 220 | (render ::elements.spec/external-select 221 | [:external-select props 222 | [:placeholder placeholder] 223 | (make-confirm confirm)])))) 224 | 225 | (defspec users-select 226 | iterations 227 | (prop/for-all [props (s/gen ::ec.spec/users-select.props) 228 | element (s/gen ::elements.spec/users-select)] 229 | (let [{placeholder :placeholder 230 | confirm :confirm} element] 231 | (render ::elements.spec/users-select 232 | [:users-select props 233 | [:placeholder placeholder] 234 | (make-confirm confirm)])))) 235 | 236 | (defspec conversations-select 237 | iterations 238 | (prop/for-all [props (s/gen ::ec.spec/conversations-select.props) 239 | element (s/gen ::elements.spec/conversations-select)] 240 | (let [{placeholder :placeholder 241 | confirm :confirm} element] 242 | (render ::elements.spec/conversations-select 243 | [:conversations-select props 244 | [:placeholder placeholder] 245 | (make-confirm confirm)])))) 246 | 247 | (defspec channels-select 248 | iterations 249 | (prop/for-all [props (s/gen ::ec.spec/channels-select.props) 250 | element (s/gen ::elements.spec/channels-select)] 251 | (let [{placeholder :placeholder 252 | confirm :confirm} element] 253 | (render ::elements.spec/channels-select 254 | [:channels-select props 255 | [:placeholder placeholder] 256 | (make-confirm confirm)])))) 257 | 258 | (defspec overflow 259 | iterations 260 | (prop/for-all [element (s/gen ::elements.spec/overflow)] 261 | (render ::elements.spec/overflow 262 | [:overflow {:action_id (:action_id element)} 263 | (make-options (:options element)) 264 | (make-confirm (:confirm element))]))) 265 | 266 | (defspec plain-text-input 267 | iterations 268 | (prop/for-all [props (s/gen ::ec.spec/plain-text-input.props) 269 | element (s/gen ::elements.spec/plain-text-input)] 270 | (render ::elements.spec/plain-text-input 271 | [:plain-text-input props 272 | (when (:placeholder element) 273 | [:placeholder (:placeholder element)])]))) 274 | 275 | (defspec radio-buttons 276 | iterations 277 | (prop/for-all [props (s/gen ::ec.spec/radio-buttons.props) 278 | element (s/gen ::elements.spec/radio-buttons)] 279 | (let [{options :options 280 | confirm :confirm} element] 281 | (render ::elements.spec/radio-buttons 282 | [:radio-buttons props 283 | (make-options options) 284 | (make-confirm confirm)])))) 285 | 286 | (defspec actions 287 | iterations 288 | (prop/for-all [props (s/gen ::bc.spec/block.props) 289 | block (s/gen ::blocks.spec/actions)] 290 | (render ::blocks.spec/actions 291 | (apply vector :actions 292 | props 293 | (:elements block))))) 294 | 295 | (defn make-fields 296 | [fields] 297 | [:fields 298 | (map #(vector :text %) fields)]) 299 | 300 | (defspec section 301 | iterations 302 | (prop/for-all [props (s/gen ::bc.spec/block.props) 303 | element (s/gen ::blocks.spec/section)] 304 | (let [{text :text 305 | fields :fields 306 | accessory :accessory} element] 307 | (render ::blocks.spec/section 308 | (cond 309 | (and text fields accessory) [:section props [:text text] (make-fields fields) accessory] 310 | (and text fields) [:section props [:text text] (make-fields fields)] 311 | (and text accessory) [:section props [:text text] accessory] 312 | (and fields accessory) [:section props (make-fields fields) accessory] 313 | (and text) [:section props [:text text]] 314 | (and fields) [:section props (make-fields fields)]))))) 315 | 316 | (defspec context 317 | iterations 318 | (prop/for-all [block (s/gen ::blocks.spec/context) 319 | props (s/gen ::bc.spec/block.props)] 320 | (render ::blocks.spec/context 321 | [:context props 322 | (map 323 | (fn [{:keys [type] 324 | :as elem-props}] 325 | (if (= :image type) 326 | [type elem-props] 327 | [:text elem-props])) 328 | (:elements block))]))) 329 | 330 | (deftest divider 331 | (is (= {:type :divider 332 | :block_id "B123"} 333 | (surfs.render/render [:divider {:block_id "B123"}]))) 334 | (is (= {:type :divider} 335 | (surfs.render/render [:divider])))) 336 | 337 | (defspec header 338 | iterations 339 | (prop/for-all [block (s/gen ::blocks.spec/header) 340 | props (s/gen ::bc.spec/block.props)] 341 | (render ::blocks.spec/header 342 | [:header props 343 | [:text (:text block)]]))) 344 | 345 | (defspec image 346 | iterations 347 | (prop/for-all [element (s/gen ::blocks.spec/image) 348 | props (s/gen ::bc.spec/image.props)] 349 | (render ::blocks.spec/image 350 | [:image props 351 | (when (:title element) 352 | [:title (:title element)])]))) 353 | 354 | (defspec input 355 | iterations 356 | (prop/for-all [elem (s/gen ::blocks.spec/input) 357 | props (s/gen ::bc.spec/input.props)] 358 | (let [{hint :hint 359 | label :label 360 | element :element} elem] 361 | ;;; To avoid generating arbitrary hiccup, any generated 362 | ;;; element will be appended as-is via props 363 | (render ::blocks.spec/input 364 | [:input props 365 | [:label label] 366 | element 367 | (when hint 368 | [:hint hint])])))) 369 | 370 | ;;; Messages 371 | 372 | (defspec message 373 | 5 374 | (prop/for-all [props (s/gen ::mc.spec/message.props) 375 | text (s/gen ::messages.spec/text) 376 | blocks (s/gen ::mc.spec/message.children)] 377 | (render ::messages.spec/message 378 | (cond 379 | (and text blocks) (apply vector :message props text blocks) 380 | (some? text) [:message props text] 381 | (seq blocks) (apply vector :message props blocks))))) 382 | 383 | ;;; Views 384 | 385 | (defspec home 386 | 1 387 | (prop/for-all [props (s/gen ::vc.spec/view.props) 388 | blocks (s/gen ::vc.spec/view.children)] 389 | (render ::views.spec/home 390 | (apply vector :home props blocks)))) 391 | 392 | (defspec modal 393 | 1 394 | (prop/for-all [props (s/gen ::vc.spec/modal.props) 395 | blocks (s/gen ::vc.spec/view.children)] 396 | (render ::views.spec/modal 397 | (apply vector :modal props blocks)))) 398 | -------------------------------------------------------------------------------- /test/thlack/surfs/test_utils.clj: -------------------------------------------------------------------------------- 1 | (ns thlack.surfs.test-utils 2 | (:require [clojure.spec.alpha :as s] 3 | [clojure.test :refer [deftest is]] 4 | [clojure.spec.test.alpha :as st] 5 | [expound.alpha :as expound] 6 | [thlack.surfs :as surfs])) 7 | 8 | (set! s/*explain-out* expound/printer) 9 | 10 | (s/check-asserts true) 11 | 12 | (defn check 13 | [sym num-tests] 14 | (let [check-result (st/check sym {:clojure.spec.test.check/opts {:num-tests num-tests}}) 15 | result (-> check-result 16 | first 17 | :clojure.spec.test.check/ret 18 | :result)] 19 | (when-not (true? result) 20 | (expound/explain-result check-result)) 21 | result)) 22 | 23 | (defmacro defcheck 24 | [name sym num-tests] 25 | `(deftest ~name 26 | (is (true? (check ~sym ~num-tests))))) 27 | 28 | (defmacro defrendertest 29 | [name & children] 30 | (let [components (vec (butlast children)) 31 | assert-fn (last children)] 32 | `(deftest ~name 33 | (~assert-fn (apply surfs/render ~components))))) 34 | 35 | (defmacro render 36 | [spec component] 37 | `(let [result# (surfs.render/render ~component)] 38 | (is (true? (s/valid? ~spec result#))))) 39 | -------------------------------------------------------------------------------- /test/thlack/surfs/views/components_test.clj: -------------------------------------------------------------------------------- 1 | (ns thlack.surfs.views.components-test 2 | (:require [thlack.surfs.views.components :as co] 3 | [thlack.surfs.test-utils :refer [defcheck]])) 4 | 5 | (defcheck home `co/home 5) 6 | 7 | (defcheck modal `co/modal 5) 8 | --------------------------------------------------------------------------------