├── .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 | [](https://cljdoc.org/d/thlack/surfs/CURRENT) [](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 |
--------------------------------------------------------------------------------