diff --git a/.env.example b/.env.example index 96b11095d..bc610b509 100644 --- a/.env.example +++ b/.env.example @@ -48,7 +48,8 @@ EMAIL_HOST_USER= EMAIL_HOST_PORT= EMAIL_FROM= TWILIO_MESSAGE_VALIDITY_PERIOD= -DST_REFERENCE_TIMEZONE='America/New_York' +DST_REFERENCE_TIMEZONE='US/Eastern' +DEFAULT_TZ='US/Eastern' PASSPORT_STRATEGY=local -TEXTER_SIDEBOXES=celebration-gif,default-dynamicassignment,default-releasecontacts,contact-reference,tag-contact,freshworks-widget,default-editinitial,take-conversations +TEXTER_SIDEBOXES=celebration-gif,default-dynamicassignment,default-releasecontacts,contact-reference,tag-contact,freshworks-widget,default-editinitial,take-conversations,hide-media,texter-feedback OWNER_CONFIGURABLE=ALL diff --git a/.github/workflows/cypress-tests.yaml b/.github/workflows/cypress-tests.yaml new file mode 100644 index 000000000..be92296a7 --- /dev/null +++ b/.github/workflows/cypress-tests.yaml @@ -0,0 +1,59 @@ +name: Integration Tests + +on: [push] + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + matrix: + node-version: [12.x] + services: + redis: + image: redis + ports: + - 6379:6379 + postgres: + image: postgres:10 + env: + POSTGRES_USER: spoke_test + POSTGRES_PASSWORD: spoke_test + POSTGRES_DB: spoke_test + ports: + - 5432:5432 + options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 + steps: + - uses: actions/checkout@v2 + - name: Cypress run + uses: cypress-io/github-action@v2 + env: + NODE_ENV: test + PORT: 3001 + OUTPUT_DIR: ./build + ASSETS_DIR: ./build/client/assets + ASSETS_MAP_FILE: assets.json + DB_TYPE: pg + DB_NAME: spoke_test + DB_USER: spoke_test + DB_PASSWORD: spoke_test + SESSION_SECRET: secret + DEFAULT_SERVICE: fakeservice + JOBS_SAME_PROCESS: 1 + PASSPORT_STRATEGY: local + PHONE_INVENTORY: 1 + with: + browser: chrome + build: npm run prod-build + start: npm start + wait-on: 'http://localhost:3001' + - uses: actions/upload-artifact@v1 + if: failure() + with: + name: cypress-screenshots + path: cypress/screenshots + - uses: actions/upload-artifact@v1 + if: failure() + with: + name: cypress-videos + path: cypress/videos \ No newline at end of file diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 7a56593ce..000000000 --- a/.travis.yml +++ /dev/null @@ -1,24 +0,0 @@ -language: node_js -node_js: -- "10.19" -cache: - yarn: true -services: -- postgresql -- redis-server -env: - global: - - secure: TODO -before_install: -- curl -o- -L https://yarnpkg.com/install.sh | bash -s -- --version 1.13.0 -- export PATH="$HOME/.yarn/bin:$PATH" -install: -- yarn install --frozen-lockfile -before_script: -- psql -c 'CREATE DATABASE spoke_test;' -U postgres -- psql -c "CREATE USER spoke_test WITH PASSWORD 'spoke_test';" -U postgres -- psql -c 'GRANT ALL PRIVILEGES ON DATABASE spoke_test TO spoke_test;' -U postgres -script: -- yarn test -- yarn test-rediscache -- yarn test-sqlite diff --git a/LICENSE b/LICENSE index 378deef93..36b5961ad 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,634 @@ -The MIT License (MIT) - -Copyright (c) 2016-2020 MoveOn Civic Action - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +Spoke: A mass-contact text/SMS peer-to-peer messaging tool + +Copyright (c) 2016-2021 MoveOn Civic Action + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License version 3 as +published by the Free Software Foundation, +with the Additional Term under Section 7(b) to include preserving +the following author attribution statement in the Spoke application: + + Spoke is developed and maintained by people committed to fighting + oppressive systems and structures, including economic injustice, + racism, patriarchy, and militarism + + +### GNU GENERAL PUBLIC LICENSE + +Version 3, 29 June 2007 + +Copyright (C) 2007 Free Software Foundation, Inc. + + +Everyone is permitted to copy and distribute verbatim copies of this +license document, but changing it is not allowed. + +### Preamble + +The GNU General Public License is a free, copyleft license for +software and other kinds of works. + +The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom +to share and change all versions of a program--to make sure it remains +free software for all its users. We, the Free Software Foundation, use +the GNU General Public License for most of our software; it applies +also to any other work released this way by its authors. You can apply +it to your programs, too. + +When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + +To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you +have certain responsibilities if you distribute copies of the +software, or if you modify it: responsibilities to respect the freedom +of others. + +For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + +Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + +For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + +Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the +manufacturer can do so. This is fundamentally incompatible with the +aim of protecting users' freedom to change the software. The +systematic pattern of such abuse occurs in the area of products for +individuals to use, which is precisely where it is most unacceptable. +Therefore, we have designed this version of the GPL to prohibit the +practice for those products. If such problems arise substantially in +other domains, we stand ready to extend this provision to those +domains in future versions of the GPL, as needed to protect the +freedom of users. + +Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish +to avoid the special danger that patents applied to a free program +could make it effectively proprietary. To prevent this, the GPL +assures that patents cannot be used to render the program non-free. + +The precise terms and conditions for copying, distribution and +modification follow. + +### TERMS AND CONDITIONS + +#### 0. Definitions. + +"This License" refers to version 3 of the GNU General Public License. + +"Copyright" also means copyright-like laws that apply to other kinds +of works, such as semiconductor masks. + +"The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + +To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of +an exact copy. The resulting work is called a "modified version" of +the earlier work or a work "based on" the earlier work. + +A "covered work" means either the unmodified Program or a work based +on the Program. + +To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + +To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user +through a computer network, with no transfer of a copy, is not +conveying. + +An interactive user interface displays "Appropriate Legal Notices" to +the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + +#### 1. Source Code. + +The "source code" for a work means the preferred form of the work for +making modifications to it. "Object code" means any non-source form of +a work. + +A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + +The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + +The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + +The Corresponding Source need not include anything that users can +regenerate automatically from other parts of the Corresponding Source. + +The Corresponding Source for a work in source code form is that same +work. + +#### 2. Basic Permissions. + +All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + +You may make, run and propagate covered works that you do not convey, +without conditions so long as your license otherwise remains in force. +You may convey covered works to others for the sole purpose of having +them make modifications exclusively for you, or provide you with +facilities for running those works, provided that you comply with the +terms of this License in conveying all material for which you do not +control copyright. Those thus making or running the covered works for +you must do so exclusively on your behalf, under your direction and +control, on terms that prohibit them from making any copies of your +copyrighted material outside their relationship with you. + +Conveying under any other circumstances is permitted solely under the +conditions stated below. Sublicensing is not allowed; section 10 makes +it unnecessary. + +#### 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + +No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + +When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such +circumvention is effected by exercising rights under this License with +respect to the covered work, and you disclaim any intention to limit +operation or modification of the work as a means of enforcing, against +the work's users, your or third parties' legal rights to forbid +circumvention of technological measures. + +#### 4. Conveying Verbatim Copies. + +You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + +#### 5. Conveying Modified Source Versions. + +You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these +conditions: + +- a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. +- b) The work must carry prominent notices stating that it is + released under this License and any conditions added under + section 7. This requirement modifies the requirement in section 4 + to "keep intact all notices". +- c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. +- d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + +A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + +#### 6. Conveying Non-Source Forms. + +You may convey a covered work in object code form under the terms of +sections 4 and 5, provided that you also convey the machine-readable +Corresponding Source under the terms of this License, in one of these +ways: + +- a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. +- b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the Corresponding + Source from a network server at no charge. +- c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. +- d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. +- e) Convey the object code using peer-to-peer transmission, + provided you inform other peers where the object code and + Corresponding Source of the work are being offered to the general + public at no charge under subsection 6d. + +A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + +A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, +family, or household purposes, or (2) anything designed or sold for +incorporation into a dwelling. In determining whether a product is a +consumer product, doubtful cases shall be resolved in favor of +coverage. For a particular product received by a particular user, +"normally used" refers to a typical or common use of that class of +product, regardless of the status of the particular user or of the way +in which the particular user actually uses, or expects or is expected +to use, the product. A product is a consumer product regardless of +whether the product has substantial commercial, industrial or +non-consumer uses, unless such uses represent the only significant +mode of use of the product. + +"Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to +install and execute modified versions of a covered work in that User +Product from a modified version of its Corresponding Source. The +information must suffice to ensure that the continued functioning of +the modified object code is in no case prevented or interfered with +solely because modification has been made. + +If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + +The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or +updates for a work that has been modified or installed by the +recipient, or for the User Product in which it has been modified or +installed. Access to a network may be denied when the modification +itself materially and adversely affects the operation of the network +or violates the rules and protocols for communication across the +network. + +Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + +#### 7. Additional Terms. + +"Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + +When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + +Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders +of that material) supplement the terms of this License with terms: + +- a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or +- b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or +- c) Prohibiting misrepresentation of the origin of that material, + or requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or +- d) Limiting the use for publicity purposes of names of licensors + or authors of the material; or +- e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or +- f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions + of it) with contractual assumptions of liability to the recipient, + for any liability that these contractual assumptions directly + impose on those licensors and authors. + +All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + +Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; the +above requirements apply either way. + +#### 8. Termination. + +You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + +However, if you cease all violation of this License, then your license +from a particular copyright holder is reinstated (a) provisionally, +unless and until the copyright holder explicitly and finally +terminates your license, and (b) permanently, if the copyright holder +fails to notify you of the violation by some reasonable means prior to +60 days after the cessation. + +Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + +Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + +#### 9. Acceptance Not Required for Having Copies. + +You are not required to accept this License in order to receive or run +a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + +#### 10. Automatic Licensing of Downstream Recipients. + +Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + +An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + +You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + +#### 11. Patents. + +A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + +A contributor's "essential patent claims" are all patent claims owned +or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + +In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + +If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + +A patent license is "discriminatory" if it does not include within the +scope of its coverage, prohibits the exercise of, or is conditioned on +the non-exercise of one or more of the rights that are specifically +granted under this License. You may not convey a covered work if you +are a party to an arrangement with a third party that is in the +business of distributing software, under which you make payment to the +third party based on the extent of your activity of conveying the +work, and under which the third party grants, to any of the parties +who would receive the covered work from you, a discriminatory patent +license (a) in connection with copies of the covered work conveyed by +you (or copies made from those copies), or (b) primarily for and in +connection with specific products or compilations that contain the +covered work, unless you entered into that arrangement, or that patent +license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + +#### 12. No Surrender of Others' Freedom. + +If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under +this License and any other pertinent obligations, then as a +consequence you may not convey it at all. For example, if you agree to +terms that obligate you to collect a royalty for further conveying +from those to whom you convey the Program, the only way you could +satisfy both those terms and this License would be to refrain entirely +from conveying the Program. + +#### 13. Use with the GNU Affero General Public License. + +Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + +#### 14. Revised Versions of this License. + +The Free Software Foundation may publish revised and/or new versions +of the GNU General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in +detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies that a certain numbered version of the GNU General Public +License "or any later version" applies to it, you have the option of +following the terms and conditions either of that numbered version or +of any later version published by the Free Software Foundation. If the +Program does not specify a version number of the GNU General Public +License, you may choose any version ever published by the Free +Software Foundation. + +If the Program specifies that a proxy can decide which future versions +of the GNU General Public License can be used, that proxy's public +statement of acceptance of a version permanently authorizes you to +choose that version for the Program. + +Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + +#### 15. Disclaimer of Warranty. + +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT +WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND +PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE +DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR +CORRECTION. + +#### 16. Limitation of Liability. + +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR +CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT +NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR +LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM +TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER +PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +#### 17. Interpretation of Sections 15 and 16. + +If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + +END OF TERMS AND CONDITIONS diff --git a/README.md b/README.md index 731b4af54..381f93411 100644 --- a/README.md +++ b/README.md @@ -46,12 +46,6 @@ Additional guidance: - [How to hire someone to install Spoke](/docs/HOWTO_HIRE_SOMEONE_TO_INSTALL_SPOKE.md) - [Option for minimalist deployment](docs/HOWTO_MINIMALIST_DEPLOY.md) - - -## Big Thanks - -Cross-browser Testing Platform and Open Source <3 Provided by [Sauce Labs](https://saucelabs.com). - # License Spoke is licensed under the MIT license. diff --git a/__test__/components/AssignmentTexter/Survey.test.js b/__test__/components/AssignmentTexter/Survey.test.js index ca432c143..70ae927c1 100644 --- a/__test__/components/AssignmentTexter/Survey.test.js +++ b/__test__/components/AssignmentTexter/Survey.test.js @@ -14,6 +14,23 @@ describe("Survey component", () => { value: "Foo is an animal", interactionStepId: 3, nextInteractionStep: { script: "foo" } + }, + { + value: "Foo is a mineral", + interactionStepId: 3, + nextInteractionStep: { script: "bar" } + }, + { + value: "Foo is a vegetable", + interactionStepId: 3, + nextInteractionStep: { script: "fizz" } + } + ], + filteredAnswerOptions: [ + { + value: "Foo is a mineral", + interactionStepId: 3, + nextInteractionStep: { script: "bar" } } ] } @@ -48,6 +65,6 @@ describe("Survey component", () => { interactionStep: currentInteractionStep, answerIndex: 0 }) - ).toBe("foo"); + ).toBe("bar"); }); }); diff --git a/__test__/components/TexterFrequentlyAskedQuestions.test.js b/__test__/components/TexterFrequentlyAskedQuestions.test.js index 2ed111268..503b0c87d 100644 --- a/__test__/components/TexterFrequentlyAskedQuestions.test.js +++ b/__test__/components/TexterFrequentlyAskedQuestions.test.js @@ -19,7 +19,7 @@ describe("FAQs component", () => { const answer = wrapper.find("CardText p"); // then - expect(question.prop("title")).toBe("1. q1"); - expect(answer.text()).toBe("a2"); + expect(question.at(0).prop("title")).toBe("1. q1"); + expect(answer.at(0).text()).toBe("a2"); }); }); diff --git a/__test__/containers/CampaignList.test.js b/__test__/containers/CampaignList.test.js index abcb147f4..025bbdea0 100644 --- a/__test__/containers/CampaignList.test.js +++ b/__test__/containers/CampaignList.test.js @@ -31,6 +31,9 @@ describe("CampaignList", () => { contactsCount: 1300, messagedCount: 98, assignedCount: 199 + }, + organization: { + id: 77 } }; @@ -77,7 +80,10 @@ describe("CampaignList", () => { creator: { displayName: "Lorem Ipsum" }, - completionStats: {} + completionStats: {}, + organization: { + id: 1 + } }; const data = { @@ -121,7 +127,10 @@ describe("CampaignList", () => { creator: { displayName: "Lorem Ipsum" }, - completionStats: {} + completionStats: {}, + organization: { + id: 1 + } }; const data = { diff --git a/__test__/cypress/fixtures/test-data.js b/__test__/cypress/fixtures/test-data.js deleted file mode 100644 index a0e31e1f7..000000000 --- a/__test__/cypress/fixtures/test-data.js +++ /dev/null @@ -1,22 +0,0 @@ -export default { - users: { - admin1: { - name: "admin1", - email: "e2e.spokeadmin10@example.com", - password: "SpokeAdmin1!", - first_name: "Admin1First", - last_name: "Admin1Last", - cell: "5555550000", - role: "OWNER" - }, - texter1: { - name: "texter1", - email: "e2e.spoketexter10@example.com", - password: "SpokeTexter1!", - first_name: "Texter1First", - last_name: "Texter1Last", - cell: "5555550001", - role: "TEXTER" - } - } -}; diff --git a/__test__/cypress/integration/basic-campaign-e2e.test.js b/__test__/cypress/integration/basic-campaign-e2e.test.js index c8be46b7d..76a76b52d 100644 --- a/__test__/cypress/integration/basic-campaign-e2e.test.js +++ b/__test__/cypress/integration/basic-campaign-e2e.test.js @@ -1,50 +1,73 @@ -import TestData from "../fixtures/test-data"; - describe("End-to-end campaign flow", () => { - before(() => { - // ensure texter one exists so they can be assigned - cy.task("createOrUpdateUser", TestData.users.texter1); + const adminInfo = { email: "admin@example.com", password: "Admin1!" }; + const texterInfo = { + email: "texter@example.com", + password: "Texter1!", + first_name: "TexterFirst", + last_name: "TexterLast" + }; + let admin = null; + let texter = null; + + beforeEach(() => { + cy.task("createOrganization").then(org => { + // Admin creates a campaign and assigns contacts to the texter + cy.task("createUser", { + userInfo: adminInfo, + org, + role: "OWNER" + }).then(user => (admin = user)); + cy.task("createUser", { + userInfo: texterInfo, + org, + role: "TEXTER" + }).then(user => (texter = user)); + }); }); it("with an assigned texter", () => { // ADMIN - const campaignTitle = `E2E basic flow ${new Date().getTime()}`; + const campaignTitle = "Integration Test Campaign"; const campaignDescription = "Basic campaign with assignments"; - cy.login("admin1"); + cy.login(admin); cy.visit("/"); cy.get("button[data-test=addCampaign]").click(); // Fill out basics cy.get("input[data-test=title]").type(campaignTitle); cy.get("input[data-test=description]").type(campaignDescription); - cy.get("input[data-test=dueBy]").click(); - // Very brittle DatePicker interaction to pick the first day of the next month - // Note: newer versions of Material UI appear to have better hooks for integration - // testing. - cy.get( - "body > div:nth-child(5) > div > div:nth-child(1) > div > div > div > div > div:nth-child(2) > div:nth-child(1) > div:nth-child(1) > button:nth-child(3)" - ).click(); - cy.get("button") - .contains("1") + // DatePicker is difficult to interact with as its components have no ids or classes + // Selectors are fairly brittle, consider upgrading material-ui for easier test interaction + + // Open picker by focusing input + cy.get("input[data-test=dueBy]").click(); + // Click next month (>) + cy.get("body > div:nth-of-type(2) button:nth-of-type(2)") + .first() + .click(); + // Click first of the month + cy.get("body > div:nth-of-type(2) button:contains(1)") + .first() .click(); - // wait for modal to get dismissed, maybe use https://www.npmjs.com/package/cypress-wait-until - cy.wait(500); + // Wait for modal to close then submit + // TODO: use cy.waitUntil() instead of wait() + cy.wait(400); cy.get("[data-test=campaignBasicsForm]").submit(); // Upload Contacts - cy.get("#contact-upload").attachFile("two-contacts.csv"); + cy.get("#contact-upload").attachFile("two-contacts.csv"), { force: true }; cy.get("button[data-test=submitContactsCsvUpload]").click(); // Assignments // Note: Material UI v0 AutoComplete component appears to require a click on the element // later versions should just allow you to hit enter - cy.get("input[data-test=texterSearch]").type("Texter1First"); + cy.get("input[data-test=texterSearch]").type("Texter"); // see if there is a better way to select the search result cy.get("body") - .contains("Texter1First Texter1Last") + .contains(`${texter.first_name} ${texter.last_name}`) .click(); cy.get("input[data-test=autoSplit]").click(); cy.get("button[data-test=submitCampaignTextersForm]").click(); @@ -69,10 +92,10 @@ describe("End-to-end campaign flow", () => { .contains("This campaign is running") .should("exist"); + // Login as TEXTER and send messages to contacts cy.url().then(url => { const campaignId = url.match(/campaigns\/(\d+)/)[1]; - // TEXTER - cy.login("texter1"); + cy.login(texter); cy.visit("/app"); const cardSelector = `div[data-test=assignmentSummary-${campaignId}]`; cy.get(cardSelector) @@ -84,27 +107,26 @@ describe("End-to-end campaign flow", () => { cy.get(cardSelector) .find("button[data-test=sendFirstTexts]") .click(); - // TODO: handle when order of contacts is reversed cy.get("textArea[name=messageText]").then(el => { - expect(el).to.have.text( - "Hi Contactfirst1 this is Texter1first, how are you?" + expect(el.text()).to.match( + /Hi ContactFirst(\d) this is TexterFirst, how are you\?/ + ); + }); + + cy.get("button[data-test=send]").click(); + // Message next contact + cy.wait(200); + cy.get("textArea[name=messageText]").then(el => { + expect(el.text()).to.match( + /Hi ContactFirst(\d) this is TexterFirst, how are you\?/ ); }); + cy.get("button[data-test=send]").click(); - if (Cypress.env("DEFAULT_SERVICE") === "fakeservice") { - cy.get("button[data-test=send]").click(); - // wait advance to next contact - cy.wait(200); - cy.get("textArea[name=messageText]").then(el => { - expect(el).to.have.text( - "Hi Contactfirst2 this is Texter1first, how are you?" - ); - }); - cy.get("button[data-test=send]").click(); - // Go back to TODOS - cy.wait(200); - cy.url().should("include", "/todos"); - } + // Shows we're done and click back to /todos + cy.get("body").contains("You've messaged all your assigned contacts."); + cy.get("button:contains(Back To Todos)").click(); + cy.waitUntil(() => cy.url().then(url => url.match(/\/todos$/))); }); }); }); diff --git a/__test__/cypress/integration/local-auth.test.js b/__test__/cypress/integration/local-auth.test.js index 5d04e19e4..60a0aeefd 100644 --- a/__test__/cypress/integration/local-auth.test.js +++ b/__test__/cypress/integration/local-auth.test.js @@ -1,39 +1,39 @@ -// Disable this test if running against auth0 -describe("Login with the local passport strategy", () => { - const ts = new Date().getTime(); +describe("Authentication with the local passport strategy", () => { + // TODO: test with SUPPRESS_SELF_INVITE=true by creating an invite + describe("With SUPPRESS_SELF_INVITE=false", () => { + it("Signs a user up", () => { + cy.visit("/"); - beforeEach(() => { - // TODO: create an invite in the test organization so that this works - // even when SUPPRESS_SELF_INVITE is turned off - cy.visit("/"); - }); - - it("Sign up", () => { - cy.get("#login").click(); - cy.get("button[name='signup']").click(); - cy.get("input[name='email']").type(`spoke.itest.${ts}@example.com`); - cy.get("input[name='firstName']").type("SignupTestUserFirst"); - cy.get("input[name='lastName']").type("SignupTestUserLast"); - cy.get("input[name='cell']").type("5555551234"); - cy.get("input[name='password']").type("SignupTestUser1!", { delay: 10 }); - cy.get("input[name='passwordConfirm']").type("SignupTestUser1!", { - delay: 10 + cy.get("#login").click(); + cy.get("button[name='signup']").click(); + cy.get("input[name='email']").type(`SignupTestUser@example.com`); + cy.get("input[name='firstName']").type("SignupTestUserFirst"); + cy.get("input[name='lastName']").type("SignupTestUserLast"); + cy.get("input[name='cell']").type("5555551234"); + cy.get("input[name='password']").type("SignupTestUser1!", { delay: 10 }); + cy.get("input[name='passwordConfirm']").type("SignupTestUser1!", { + delay: 10 + }); + cy.get("[data-test=userEditForm]").submit(); + // The next page is different depending on whether SUPPRESS_SELF_INVITE is + // set, so we just assert that we are not still on the login page + cy.waitUntil(() => cy.url().then(url => !url.match(/login/))); }); - cy.get("[data-test=userEditForm]").submit(); - // The next page is different depending on whether SUPPRESS_SELF_INVITE is - // set, so we just assert that we are not still on the login page - // the wait is required because cypress doesn't know how long to wait for the url to change - cy.wait(500); - cy.url().then(url => expect(url).not.to.match(/.*login.*/)); - }); - // sign in as the user - it("Sign in", () => { - cy.get("#login").click(); - cy.get("input[name='email']").type(`spoke.itest.${ts}@example.com`); - cy.get("input[name='password']").type("SignupTestUser1!", { delay: 10 }); - cy.get("[data-test=userEditForm]").submit(); - cy.wait(500); - cy.url().then(url => expect(url).not.to.match(/.*login.*/)); + it("Logs a user in", () => { + let email = "user@example.com"; + let password = "User1!"; + cy.task("createUser", { + userInfo: { email, password } + }); + + cy.visit("/"); + cy.get("#login").click(); + + cy.get("input[name='email']").type(email); + cy.get("input[name='password']").type(password, { delay: 10 }); + cy.get("[data-test=userEditForm]").submit(); + cy.waitUntil(() => cy.url().then(url => !url.match(/login/))); + }); }); }); diff --git a/__test__/cypress/integration/phone-inventory.test.js b/__test__/cypress/integration/phone-inventory.test.js index 6b9b23426..14c307cd3 100644 --- a/__test__/cypress/integration/phone-inventory.test.js +++ b/__test__/cypress/integration/phone-inventory.test.js @@ -1,41 +1,41 @@ -if (Cypress.env("DEFAULT_SERVICE") === "fakeservice") { - describe("Phone number management screen in the Admin interface", () => { - const testAreaCode = "212"; +describe("Phone number management screen in the Admin interface", () => { + const testAreaCode = "212"; + const adminInfo = { email: "admin@example.com", password: "Admin1!" }; + let admin = null; - beforeEach(() => { - cy.login("admin1"); - cy.visit("/"); - cy.task("clearTestOrgPhoneNumbers", testAreaCode); + beforeEach(() => { + cy.task("createOrganization").then(org => { + cy.task("createUser", { + userInfo: adminInfo, + org, + role: "OWNER" + }).then(user => (admin = user)); }); + }); - it("shows numbers by area code and allows OWNERs to buy more", () => { - cy.get("[data-test=navPhoneNumbers]").click(); - cy.get("th").contains("Area Code"); - cy.get("th").contains("Allocated"); - cy.get("th").contains("Available"); - cy.get("tr") - .contains(testAreaCode) - .should("not.exist"); + it("shows numbers by area code and allows OWNERs to buy more", () => { + cy.login(admin); + cy.visit("/admin/1/phone-numbers"); - // Fill out the form to buy a new number - cy.get("button[data-test=buyPhoneNumbers]").click(); - cy.get("input[data-test=areaCode]").type(testAreaCode); - cy.get("input[data-test=limit]").type("1"); - cy.get("[data-test=buyNumbersForm]").submit(); + cy.get("th").contains("Area Code"); + cy.get("th").contains("Allocated"); + cy.get("th").contains("Available"); + cy.get("tr") + .contains(testAreaCode) + .should("not.exist"); - // Skip testing pending jobs logic now. Just refresh the page - // and check that we have one number available - cy.wait(200); - cy.reload(); + // Fill out the form to buy a new number + cy.get("button[data-test=buyPhoneNumbers]").click(); + cy.get("input[data-test=areaCode]").type(testAreaCode); + cy.get("input[data-test=limit]").type("1"); + cy.get("[data-test=buyNumbersForm]").submit(); - // "Available" column of the row containing the test areaCode - cy.get(`tr:contains(${testAreaCode}) td:nth-child(3)`).then(td => { - expect(td).to.have.text("1"); - }); - }); - }); -} else { - describe(`When running against ${Cypress.env("DEFAULT_SERVICE")}`, () => { - it.skip("tests skipped"); + // "Available" column of the row containing the test areaCode + // Waits until job run completes + cy.waitUntil( + () => + cy.get(`tr:contains(${testAreaCode}) td:nth-child(4)`).contains("1"), + { timeout: 1000 } + ); }); -} +}); diff --git a/__test__/cypress/integration/user-edit.test.js b/__test__/cypress/integration/user-edit.test.js index 080166dec..7d55f3aaf 100644 --- a/__test__/cypress/integration/user-edit.test.js +++ b/__test__/cypress/integration/user-edit.test.js @@ -1,26 +1,28 @@ -import testData from "../fixtures/test-data"; - describe("The user edit screen", () => { + const adminInfo = { email: "admin@example.com", password: "Admin1!" }; + let admin = null; + beforeEach(() => { - cy.login("admin1"); - cy.visit("/"); + cy.task("createOrganization").then(org => { + cy.task("createUser", { + userInfo: adminInfo, + org, + role: "OWNER" + }).then(user => (admin = user)); + }); }); it("displays the current user's and allows them to edit it", () => { - const userDetails = testData.users.admin1; + cy.login(admin); + cy.visit("/"); + cy.get("[data-test=userMenuButton]").click(); cy.get("[data-test=userMenuDisplayName]").click(); - cy.get("input[data-test=email]").should("have.value", userDetails.email); - cy.get("input[data-test=firstName]").should( - "have.value", - userDetails.first_name - ); - cy.get("input[data-test=lastName]").should( - "have.value", - userDetails.last_name - ); + cy.get("input[data-test=email]").should("have.value", admin.email); + cy.get("input[data-test=firstName]").should("have.value", admin.first_name); + cy.get("input[data-test=lastName]").should("have.value", admin.last_name); cy.get("input[data-test=alias]").should("have.value", ""); - cy.get("input[data-test=cell]").should("have.value", userDetails.cell); + cy.get("input[data-test=cell]").should("have.value", admin.cell); cy.get("input[data-test=firstName]").type("NewAdminFirstName"); cy.get("input[data-test=lastName]").type("NewAdminLastName"); @@ -37,12 +39,5 @@ describe("The user edit screen", () => { "NewAdminLastName" ); cy.get("input[data-test=alias]").should("have.value", "NewAlias"); - - // revert to previous values so this doesn't interfere with other tests - // TODO: it would be better to create a new user for this - cy.get("input[data-test=firstName]").type(userDetails.first_name); - cy.get("input[data-test=lastName]").type(userDetails.last_name); - cy.get("input[data-test=alias]").type("{backspace}"); - cy.get("[data-test=userEditForm]").submit(); }); }); diff --git a/__test__/cypress/plugins/index.js b/__test__/cypress/plugins/index.js index 3b4d9e09b..9941bdbef 100644 --- a/__test__/cypress/plugins/index.js +++ b/__test__/cypress/plugins/index.js @@ -1,39 +1,17 @@ // plugins run in the cypress Node process, not the browser. Define tasks // operations like seeding the database that can't be run from the browser. // See: https://on.cypress.io/plugins-guide -require("dotenv").load(); require("babel-register"); require("babel-polyfill"); if (process.env.DEFAULT_SERVICE !== "fakeservice") { - console.log("Not using fakeservice, some tests will be disabled"); + throw "Integration tests require DEFAULT_SERVICE=fakesevice"; } +// PostgreSQL required because of a conflict between the sqlite and electron binaries +// See: https://github.com/MoveOnOrg/Spoke/issues/1529#issuecomment-623680962 if (process.env.DB_TYPE !== "pg") { - // Not supported because of a conflict between the sqlite and electron binaries - // See: https://github.com/MoveOnOrg/Spoke/issues/1529#issuecomment-623680962 - throw Error( - "Running Cypress tests against Sqlite is not currently supported" - ); + throw "Running Cypress tests against Sqlite is not currently supported"; } -const makeTasks = require("./tasks").makeTasks; -const utils = require("./utils"); - -module.exports = async (on, config) => { - if (config.env.SUPPRESS_ORG_CREATION && !config.env.TEST_ORGANIZATION_ID) { - throw new Error( - "Missing TEST_ORGANIZATION_ID and org creation is disabled" - ); - } - if (!config.env.TEST_ORGANIZATION_ID) { - config.env.TEST_ORGANIZATION_ID = await utils.getOrCreateTestOrganization(); - } - - // TODO: use the API to determine what service is being used rather - // than relying on .env. - config.env.DEFAULT_SERVICE = process.env.DEFAULT_SERVICE; - on("task", makeTasks(config)); - - return config; -}; +module.exports = require("./tasks").defineTasks; diff --git a/__test__/cypress/plugins/tasks.js b/__test__/cypress/plugins/tasks.js index 58869ecb7..dc9485962 100644 --- a/__test__/cypress/plugins/tasks.js +++ b/__test__/cypress/plugins/tasks.js @@ -1,82 +1,59 @@ -import { r, User } from "../../../src/server/models"; import AuthHasher from "passport-local-authenticate"; +import { r, createTables, truncateTables } from "../../../src/server/models/"; +import { + createUser, + createInvite, + createOrganization +} from "../../test_helpers"; /** - * Make Cypress tasks with access to the config. + * Cypress tasks run in the node process started by Cypress + * and are invoked by tests running in the browser. * * https://docs.cypress.io/api/commands/task.html#Syntax */ -export function makeTasks(config) { - return { - /** - * Create a user and add it to the test organization with the specified role. - */ - createOrUpdateUser: async userData => { - let user = await r - .knex("user") - .where("email", userData.email) - .first(); - - if (!user) { - // TODO[matteosb]: support Auth0 and consider creating users through - // the API rather than with direct database access, which would be - // better when running against remote envs. Alternatively, we could - // simply not support user creation when running against a remove - // env, similar to SUPPRESS_ORG_CREATION. - user = await new Promise((resolve, reject) => { - AuthHasher.hash(userData.password, async (err, hashed) => { - if (err) reject(err); - const passwordToSave = `localauth|${hashed.salt}|${hashed.hash}`; - const { email, first_name, last_name, cell } = userData; - const u = await User.save({ - email, - first_name, - last_name, - cell, - auth0_id: passwordToSave, - is_superadmin: false - }); - resolve(u); - }); - }); - } - - const role = await r - .knex("user_organization") - .where({ - organization_id: config.env.TEST_ORGANIZATION_ID, - user_id: user.id - }) - .first(); +export function defineTasks(on, config) { + on("task", { + async resetDB() { + await createTables(); + await truncateTables(); + return null; + }, - if (!role) { - await r.knex("user_organization").insert({ - user_id: user.id, - organization_id: config.env.TEST_ORGANIZATION_ID, - role: userData.role + async createOrganization() { + const admin = await createUser(); + const invite = await createInvite(); + const organizationResult = await createOrganization(admin, invite); + const org = organizationResult.data.createOrganization; + await r + .knex("organization") + .where({ id: org.id }) + .update({ + features: JSON.stringify({ EXPERIMENTAL_PHONE_INVENTORY: true }) }); - } - - if (role !== userData.role) { - await r - .knex("user_organization") - .where({ organization_id: config.env.TEST_ORGANIZATION_ID }) - .update({ role: userData.role }); - } - - return user.id; + return org; }, - clearTestOrgPhoneNumbers: async areaCode => { - await r - .knex("owned_phone_number") - .where({ - organization_id: config.env.TEST_ORGANIZATION_ID, - service: "fakeservice", - area_code: areaCode - }) - .delete(); - return null; + async createUser({ userInfo, org, role }) { + const user = await new Promise((resolve, reject) => { + AuthHasher.hash(userInfo.password, async (err, hashed) => { + if (err) reject(err); + const hashedPassword = `localauth|${hashed.salt}|${hashed.hash}`; + const u = await createUser( + { + ...userInfo, + auth0_id: hashedPassword + }, + org ? org.id : null, + role + ); + resolve(u); + }); + }); + user.password = userInfo.password; + return user; } - }; + }); + + return config; } diff --git a/__test__/cypress/plugins/utils.js b/__test__/cypress/plugins/utils.js deleted file mode 100644 index 5a2f6fd43..000000000 --- a/__test__/cypress/plugins/utils.js +++ /dev/null @@ -1,22 +0,0 @@ -import { r } from "../../../src/server/models"; -import uuid from "uuid"; - -const DEFAULT_ORGANIZATION_NAME = "E2E Test Organization"; - -export async function getOrCreateTestOrganization() { - let org = await r - .knex("organization") - .where("name", DEFAULT_ORGANIZATION_NAME) - .first(); - if (!org) { - org = await r - .knex("organization") - .insert({ - name: DEFAULT_ORGANIZATION_NAME, - uuid: uuid.v4(), - features: JSON.stringify({ EXPERIMENTAL_PHONE_INVENTORY: true }) - }) - .returning("*"); - } - return org.id; -} diff --git a/__test__/cypress/support/commands.js b/__test__/cypress/support/commands.js new file mode 100644 index 000000000..1d9cc2b9f --- /dev/null +++ b/__test__/cypress/support/commands.js @@ -0,0 +1,12 @@ +// See https://docs.cypress.io/api/cypress-api/custom-commands.html#Syntax +import "cypress-file-upload"; +import "cypress-wait-until"; + +Cypress.Commands.add("login", userData => { + cy.request("POST", "/login-callback", { + nextUrl: "/", + authType: "login", + password: userData.password, + email: userData.email + }); +}); diff --git a/__test__/cypress/support/index.js b/__test__/cypress/support/index.js index a9583e1ca..3fe2555dc 100644 --- a/__test__/cypress/support/index.js +++ b/__test__/cypress/support/index.js @@ -1,21 +1,7 @@ // support/index.js is processed and loaded automatically before your test files. // this runs in the browser is a good place to define common cypress operations -import "cypress-file-upload"; +import "./commands"; -import TestData from "../fixtures/test-data"; - -// TODO: support Auth0 -Cypress.Commands.add("login", testDataId => { - const userData = TestData.users[testDataId]; - if (!userData) { - throw Error(`Unknown test user ${testDataId}`); - } - - cy.task("createOrUpdateUser", userData); - cy.request("POST", "/login-callback", { - nextUrl: "/", - authType: "login", - password: userData.password, - email: userData.email - }); +beforeEach(() => { + cy.task("resetDB"); }); diff --git a/__test__/e2e/.env.e2e b/__test__/e2e/.env.e2e deleted file mode 100644 index d379ce5d5..000000000 --- a/__test__/e2e/.env.e2e +++ /dev/null @@ -1,43 +0,0 @@ -NODE_ENV=development -SUPPRESS_SELF_INVITE= -JOBS_SAME_PROCESS=1 -AWS_ACCESS_KEY_ID= -AWS_SECRET_ACCESS_KEY= -AWS_S3_BUCKET_NAME= -APOLLO_OPTICS_KEY= -DEV_APP_PORT=8090 -OUTPUT_DIR=./build -ASSETS_DIR=./build/client/assets -ASSETS_MAP_FILE=assets.json -CAMPAIGN_ID='campaign-id-hash' -DB_HOST=127.0.0.1 -DB_PORT=5432 -DB_NAME=spoke_test -DB_TYPE=pg -DB_MIN_POOL=2 -DB_MAX_POOL=10 -DB_USE_SSL=false -WEBPACK_HOST=localhost -WEBPACK_PORT=3000 -BASE_URL=http://localhost:3000 -SESSION_SECRET=set_this_in_production -DEFAULT_SERVICE=twilio -NEXMO_API_KEY= -NEXMO_API_SECRET= -TWILIO_ACCOUNT_SID= -TWILIO_AUTH_TOKEN= -TWILIO_MESSAGE_SERVICE_SID= -TWILIO_STATUS_CALLBACK_URL= -TWILIO_SQS_QUEUE_URL= -PHONE_NUMBER_COUNTRY=US -ROLLBAR_CLIENT_TOKEN= -ROLLBAR_ACCESS_TOKEN= -ROLLBAR_ENDPOINT=https://api.rollbar.com/api/1/item/ -ALLOW_SEND_ALL=false -EMAIL_HOST= -EMAIL_HOST_PASSWORD= -EMAIL_HOST_USER= -EMAIL_HOST_PORT= -EMAIL_FROM= -TWILIO_MESSAGE_VALIDITY_PERIOD= -DST_REFERENCE_TIMEZONE='America/New_York' diff --git a/__test__/e2e/basic_text_manager.test.js b/__test__/e2e/basic_text_manager.test.js deleted file mode 100644 index 625903d13..000000000 --- a/__test__/e2e/basic_text_manager.test.js +++ /dev/null @@ -1,147 +0,0 @@ -import { selenium } from "./util/helpers"; -import STRINGS from "./data/strings"; -import { campaigns, login, main, people, texter } from "./page-functions/index"; - -jasmine.getEnv().addReporter(selenium.reporter); - -describe("Basic Text Manager Workflow", () => { - // Instantiate browser(s) - const driverAdmin = selenium.buildDriver({ - name: "Spoke E2E Tests - Chrome - Basic Text Manager Workflow - Admin" - }); - const driverTexter = selenium.buildDriver({ - name: "Spoke E2E Tests - Chrome - Basic Text Manager Workflow - Texter" - }); - - beforeAll(() => { - global.e2e = {}; - }); - - /** - * Test Suite Sequence: - * Setup Admin and Texter Users - * Create Campaign (No Existing Texter) - * Create Campaign (Existing Texter) - * Create Campaign (No Existing Texter with Opt-Out) - * Create Campaign (Existing Texter with Opt-Out) - */ - - afterAll(async () => { - await selenium.quitDriver(driverAdmin); - await selenium.quitDriver(driverTexter); - }); - - describe("Setup Admin User", () => { - describe("(As Admin) Open Landing Page", () => { - login.landing(driverAdmin); - }); - - describe("(As Admin) Log In an admin to Spoke", () => { - login.tryLoginThenSignUp(driverAdmin, STRINGS.users.admin0); - }); - - describe("(As Admin) Create a New Organization / Team", () => { - main.createOrg(driverAdmin, STRINGS.org); - }); - }); - - describe("Create Campaign (No Existing Texter)", () => { - const CAMPAIGN = STRINGS.campaigns.noExistingTexter; - - describe("(As Admin) Create a New Campaign", () => { - campaigns.startCampaign(driverAdmin, CAMPAIGN); - }); - - describe("(As Texter) Follow the Invite URL", () => { - texter.viewInvite(driverTexter); - login.tryLoginThenSignUp(driverTexter, CAMPAIGN.texter); - }); - - describe("(As Texter) Verify Todos", () => { - texter.viewSendFirstTexts(driverTexter); - }); - - describe("(As Texter) Log Out", () => { - main.logOutUser(driverTexter); - }); - }); - - describe("Create Campaign (Existing Texter)", () => { - const CAMPAIGN = STRINGS.campaigns.existingTexter; - - describe("(As Admin) Invite a new Texter", () => { - people.invite(driverAdmin); - }); - - describe("(As Texter) Follow the Invite URL", () => { - texter.viewInvite(driverTexter); - login.tryLoginThenSignUp(driverTexter, CAMPAIGN.texter); - }); - - describe("(As Admin) Create a New Campaign", () => { - campaigns.startCampaign(driverAdmin, CAMPAIGN); - }); - - describe("(As Texter) Send Texts", () => { - texter.sendTexts(driverTexter, CAMPAIGN); - }); - - describe("(As Admin) Send Replies", () => { - campaigns.sendReplies(driverAdmin, CAMPAIGN); - }); - - describe("(As Texter) View Replies", () => { - texter.viewReplies(driverTexter, CAMPAIGN); - }); - - describe("(As Texter) Opt Out Contact", () => { - texter.optOutContact(driverTexter); - }); - - describe("(As Texter) Log Out", () => { - main.logOutUser(driverTexter); - }); - }); - - describe("Create Campaign (No Existing Texter with Opt-Out)", () => { - const CAMPAIGN = STRINGS.campaigns.noExistingTexterOptOut; - - describe("(As Admin) Create a New Campaign", () => { - campaigns.startCampaign(driverAdmin, CAMPAIGN); - }); - - describe("(As Texter) Follow the Invite URL", () => { - texter.viewInvite(driverTexter); - login.tryLoginThenSignUp(driverTexter, CAMPAIGN.texter); - }); - - describe("(As Texter) Verify Todos", () => { - texter.viewSendFirstTexts(driverTexter); - }); - - describe("(As Texter) Log Out", () => { - main.logOutUser(driverTexter); - }); - }); - - describe("Create Campaign (Existing Texters with Opt-Out)", () => { - const CAMPAIGN = STRINGS.campaigns.existingTexterOptOut; - - describe("(As Admin) Invite a new Texter", () => { - people.invite(driverAdmin); - }); - - describe("(As Texter) Follow the Invite URL", () => { - texter.viewInvite(driverTexter); - login.tryLoginThenSignUp(driverTexter, CAMPAIGN.texter); - }); - - describe("(As Admin) Create a New Campaign", () => { - campaigns.startCampaign(driverAdmin, CAMPAIGN); - }); - - describe("(As Texter) Verify Todos", () => { - texter.viewSendFirstTexts(driverTexter); - }); - }); -}); diff --git a/__test__/e2e/create_copy_campaign.test.js b/__test__/e2e/create_copy_campaign.test.js deleted file mode 100644 index d2ae33ad3..000000000 --- a/__test__/e2e/create_copy_campaign.test.js +++ /dev/null @@ -1,54 +0,0 @@ -import { selenium } from "./util/helpers"; -import STRINGS from "./data/strings"; -import { campaigns, login, main, people, texter } from "./page-functions/index"; - -jasmine.getEnv().addReporter(selenium.reporter); - -describe("Create and Copy Campaign", () => { - // Instantiate browser(s) - const driverAdmin = selenium.buildDriver({ - name: "Spoke E2E Tests - Chrome - Create and Copy Campaign - Admin" - }); - const driverTexter = selenium.buildDriver({ - name: "Spoke E2E Tests - Chrome - Create and Copy Campaign - Texter" - }); - const CAMPAIGN = STRINGS.campaigns.copyCampaign; - - beforeAll(() => { - global.e2e = {}; - }); - - afterAll(async () => { - await selenium.quitDriver(driverAdmin); - await selenium.quitDriver(driverTexter); - }); - - describe("(As Admin) Open Landing Page", () => { - login.landing(driverAdmin); - }); - - describe("(As Admin) Log In an admin to Spoke", () => { - login.tryLoginThenSignUp(driverAdmin, CAMPAIGN.admin); - }); - - describe("(As Admin) Create a New Organization / Team", () => { - main.createOrg(driverAdmin, STRINGS.org); - }); - - describe("(As Admin) Invite a new User", () => { - people.invite(driverAdmin); - }); - - describe("(As Texter) Follow the Invite URL", () => { - texter.viewInvite(driverTexter); - login.tryLoginThenSignUp(driverTexter, CAMPAIGN.texter); - }); - - describe("(As Admin) Create a New Campaign", () => { - campaigns.startCampaign(driverAdmin, CAMPAIGN); - }); - - describe("(As Admin) Copy Campaign", () => { - campaigns.copyCampaign(driverAdmin, CAMPAIGN); - }); -}); diff --git a/__test__/e2e/create_edit_campaign.test.js b/__test__/e2e/create_edit_campaign.test.js deleted file mode 100644 index 24a712c34..000000000 --- a/__test__/e2e/create_edit_campaign.test.js +++ /dev/null @@ -1,41 +0,0 @@ -import { selenium } from "./util/helpers"; -import STRINGS from "./data/strings"; -import { campaigns, login, main } from "./page-functions/index"; - -jasmine.getEnv().addReporter(selenium.reporter); - -describe("Create and Edit Campaign", () => { - // Instantiate browser(s) - const driver = selenium.buildDriver({ - name: "Spoke E2E Tests - Chrome - Create and Edit Campaign - Admin" - }); - const CAMPAIGN = STRINGS.campaigns.editCampaign; - - beforeAll(() => { - global.e2e = {}; - }); - - afterAll(async () => { - await selenium.quitDriver(driver); - }); - - describe("(As Admin) Open Landing Page", () => { - login.landing(driver); - }); - - describe("(As Admin) Log In an admin to Spoke", () => { - login.tryLoginThenSignUp(driver, CAMPAIGN.admin); - }); - - describe("(As Admin) Create a New Organization / Team", () => { - main.createOrg(driver, STRINGS.org); - }); - - describe("(As Admin) Create a New Campaign", () => { - campaigns.startCampaign(driver, CAMPAIGN); - }); - - describe("(As Admin) Edit Campaign", () => { - campaigns.editCampaign(driver, CAMPAIGN); - }); -}); diff --git a/__test__/e2e/data/people.csv b/__test__/e2e/data/people.csv deleted file mode 100644 index 8f8512dab..000000000 --- a/__test__/e2e/data/people.csv +++ /dev/null @@ -1,3 +0,0 @@ -firstName,lastName,cell,zip -Firstone,Lastone,5675550001,43001 -Firsttwo,Lasttwo,5675550002,43002 \ No newline at end of file diff --git a/__test__/e2e/data/strings.js b/__test__/e2e/data/strings.js deleted file mode 100644 index 2452e66d3..000000000 --- a/__test__/e2e/data/strings.js +++ /dev/null @@ -1,215 +0,0 @@ -import path from "path"; -import _ from "lodash"; - -// Common to all campaigns -const contacts = { - csv: path.resolve(__dirname, "./people.csv") -}; - -const texters = { - contactLength: 2, - contactLengthAfterOptOut: 1 -}; - -const interaction = { - script: "Test First {firstName} Last {lastName}!", - question: "Test Question?", - answers: [ - { - answerOption: "Test Answer 0", - script: "Test Answer 0 {firstName}.", - questionText: "Test Child Question 0?" - }, - { - answerOption: "Test Answer 1", - script: "Test Answer 1 {lastName}.", - questionText: "Test Child Question 1?" - } - ] -}; - -const cannedResponses = [ - { - title: "Test CR0", - script: "Test CR First {firstName} Last {lastName}." - } -]; - -const standardReply = "Test Reply"; - -const org = "SpokeTestOrg"; - -const users = { - /** - * Note: Changing passwords for existing Auth0 users requires the user be removed from Auth0 - */ - admin0: { - name: "admin0", - email: "spokeadmin0@moveon.org", - password: "SpokeAdmin0!", - given_name: "Adminzerofirst", - family_name: "Adminzerolast", - cell: "4145550000" - }, - admin1: { - name: "admin1", - email: "spokeadmin1@moveon.org", - email_changed: "spokeadmin1b@moveon.org", - password: "SpokeAdmin1!", - given_name: "Adminonefirst", - given_name_changed: "Adminonefirstb", - family_name: "Adminonelast", - family_name_changed: "Adminonelastb", - cell: "4145550001", - cell_changed: "6085550001" - }, - texter0: { - name: "texter0", - email: "spoketexter0@moveon.org", - password: "SpokeTexter0!", - given_name: "Texterzerofirst", - family_name: "Texterzerolast", - cell: "4146660000" - }, - texter1: { - name: "texter1", - email: "spoketexter1@moveon.org", - email_changed: "spoketexter1b@moveon.org", - password: "SpokeTexter1!", - given_name: "Texteronefirst", - given_name_changed: "Texteronefirstb", - family_name: "Texteronelast", - family_name_changed: "Texteronelastb", - cell: "4146660001", - cell_changed: "6086660001" - }, - texter2: { - name: "texter2", - email: "spoketexter2@moveon.org", - password: "SpokeTexter2!", - given_name: "Textertwofirst", - family_name: "Textertwolast", - cell: "4146660002" - }, - texter3: { - name: "texter3", - email: "spoketexter3@moveon.org", - password: "SpokeTexter3!", - given_name: "Texterthreefirst", - family_name: "Texterthreelast", - cell: "4146660003" - } -}; - -const campaigns = { - noExistingTexter: { - name: "noExistingTexter", - optOut: false, - admin: users.admin0, - texter: users.texter0, - existingTexter: false, - basics: { - title: "Test NET Campaign Title", - description: "Test NET Campaign Description" - }, - contacts, - texters, - interaction, - cannedResponses, - standardReply - }, - existingTexter: { - name: "existingTexter", - optOut: false, - admin: users.admin0, - texter: users.texter1, - existingTexter: true, - basics: { - title: "Test ET Campaign Title", - description: "Test ET Campaign Description" - }, - contacts, - texters, - interaction, - cannedResponses, - standardReply - }, - noExistingTexterOptOut: { - name: "noExistingTexterOptOut", - optOut: true, - admin: users.admin0, - texter: users.texter2, - existingTexter: false, - basics: { - title: "Test NETOO Campaign Title", - description: "Test NETOO Campaign Description" - }, - contacts, - texters: _.assign({}, texters, { - contactLength: texters.contactLengthAfterOptOut - }), - interaction, - cannedResponses, - standardReply - }, - existingTexterOptOut: { - name: "existingTexterOptOut", - optOut: true, - admin: users.admin0, - texter: users.texter3, - existingTexter: true, - basics: { - title: "Test ETOO Campaign Title", - description: "Test ETOO Campaign Description" - }, - contacts, - texters: _.assign({}, texters, { - contactLength: texters.contactLengthAfterOptOut - }), - interaction, - cannedResponses, - standardReply - }, - copyCampaign: { - name: "copyCampaign", - admin: users.admin0, - texter: users.texter0, - existingTexter: true, - dynamicAssignment: false, - basics: { - title: "Test C Campaign Title", - title_copied: "COPY - Test C Campaign Title", - description: "Test C Campaign Description" - }, - contacts, - texters, - interaction, - cannedResponses - }, - editCampaign: { - name: "editCampaign", - admin: users.admin1, - existingTexter: false, - dynamicAssignment: true, - basics: { - title: "Test E Campaign Title", - title_changed: "Test E Campaign Title Changed", - description: "Test E Campaign Description" - }, - contacts, - texters, - interaction, - cannedResponses - }, - userManagement: { - name: "userManagement", - admin: users.admin1, - texter: users.texter1 - } -}; - -export default { - campaigns, - org, - users -}; diff --git a/__test__/e2e/invite_texter.test.js b/__test__/e2e/invite_texter.test.js deleted file mode 100644 index 9504503bd..000000000 --- a/__test__/e2e/invite_texter.test.js +++ /dev/null @@ -1,54 +0,0 @@ -import { selenium } from "./util/helpers"; -import STRINGS from "./data/strings"; -import { login, main, people, texter } from "./page-functions/index"; - -jasmine.getEnv().addReporter(selenium.reporter); - -describe("Invite Texter workflow", () => { - // Instantiate browser(s) - const driverAdmin = selenium.buildDriver({ - name: "Spoke E2E Tests - Chrome - Invite Texter workflow - Admin" - }); - const driverTexter = selenium.buildDriver({ - name: "Spoke E2E Tests - Chrome - Invite Texter workflow - Texter" - }); - const CAMPAIGN = STRINGS.campaigns.userManagement; - - beforeAll(() => { - global.e2e = {}; - }); - - afterAll(async () => { - await selenium.quitDriver(driverAdmin); - await selenium.quitDriver(driverTexter); - }); - - describe("(As Admin) Open Landing Page", () => { - login.landing(driverAdmin); - }); - - describe("(As Admin) Log In an admin to Spoke", () => { - login.tryLoginThenSignUp(driverAdmin, CAMPAIGN.admin); - }); - - describe("(As Admin) Create a New Organization / Team", () => { - main.createOrg(driverAdmin, STRINGS.org); - }); - - describe("(As Admin) Invite a new User", () => { - people.invite(driverAdmin); - }); - - describe("(As Texter) Follow the Invite URL", () => { - texter.viewInvite(driverTexter); - login.tryLoginThenSignUp(driverTexter, CAMPAIGN.texter); - }); - - describe("(As Admin) Edit User", () => { - people.editUser(driverAdmin, CAMPAIGN.admin); - }); - - describe("(As Texter) Edit User", () => { - main.editUser(driverTexter, CAMPAIGN.texter); - }); -}); diff --git a/__test__/e2e/page-functions/campaigns.js b/__test__/e2e/page-functions/campaigns.js deleted file mode 100644 index 15700841e..000000000 --- a/__test__/e2e/page-functions/campaigns.js +++ /dev/null @@ -1,447 +0,0 @@ -/** - * Date Picker Notes: - * The selector for the date is fragile. It may be better to programatically set it. - * await driver.executeScript('document.getElementsByName("dueBy")[0].setAttribute("value","10 Jan 2019")') - * Similarly, a sleep is added because it's difficult to know when the picker dialog is gone. - */ - -import _ from "lodash"; -import { wait, urlBuilder } from "../util/helpers"; -import pom from "../page-objects/index"; - -// For legibility -const form = pom.campaigns.form; - -export const campaigns = { - startCampaign(driver, campaign) { - it("opens the Campaigns tab", async () => { - await driver.get(urlBuilder.admin.root()); - await wait.andClick(driver, pom.navigation.sections.campaigns); - }); - - it("clicks the + button to add a new campaign", async () => { - await wait.andClick(driver, pom.campaigns.add, { goesStale: true }); - }); - - it("completes the Basics section", async () => { - // Title - await wait.andType(driver, form.basics.title, campaign.basics.title); - // Description - await wait.andType( - driver, - form.basics.description, - campaign.basics.description - ); - // Select a Due Date using the Date Picker - await wait.andClick(driver, form.basics.dueBy); - await wait.andClick(driver, form.datePickerDialog.nextMonth, { - waitAfterVisible: 2000 - }); - await wait.andClick(driver, form.datePickerDialog.enabledDate, { - waitAfterVisible: 2000, - goesStale: true - }); - // Save - await wait.andClick(driver, form.save, { waitAfterVisible: 2000 }); - // This should switch to the Contacts section - expect( - await wait.andGetEl(driver, form.contacts.uploadButton) - ).toBeDefined(); - expect( - await wait.andGetEl(driver, form.contacts.input, { - elementIsVisible: false - }) - ).toBeDefined(); - }); - - it("completes the Contacts section", async () => { - await wait.andType(driver, form.contacts.input, campaign.contacts.csv, { - clear: false, - click: false, - elementIsVisible: false - }); - expect( - await wait.andGetEl(driver, form.contacts.uploadedContacts) - ).toBeDefined(); - // Save - await wait.andClick(driver, form.save, { waitAfterVisible: 2000 }); - // Reload the Contacts section to validate Contacts - await wait.andClick(driver, form.contacts.section, { - waitAfterVisible: 2000 - }); - expect( - await wait.andGetEl(driver, form.contacts.uploadedContacts) - ).toBeDefined(); - expect( - await wait.andGetEl( - driver, - form.contacts.uploadedContactsByQty(campaign.texters.contactLength) - ) - ).toBeDefined(); - await wait.andClick(driver, form.texters.section, { - waitAfterVisible: 2000 - }); - // This should switch to the Texters section - expect(await wait.andGetEl(driver, form.texters.addAll)).toBeDefined(); - }); - - it("completes the Texters section", async () => { - if (campaign.existingTexter) { - // Add All - await wait.andClick(driver, form.texters.addAll); - // Assign (Split) - await wait.andClick(driver, form.texters.autoSplit, { - elementIsVisible: false - }); - // Validate Assignment - const assignedToFirstTexter = await wait.andGetValue( - driver, - form.texters.texterAssignmentByIndex(0) - ); - expect(Number(assignedToFirstTexter)).toBeGreaterThan(0); - // Assign (All to Texter) - await wait.andClick(driver, form.texters.autoSplit, { - elementIsVisible: false - }); - await wait.andType( - driver, - form.texters.texterAssignmentByText(campaign.admin.given_name), - "0" - ); - await driver.sleep(1000); - await wait.andType( - driver, - form.texters.texterAssignmentByText(campaign.texter.given_name), - campaign.texters.contactLength - ); - // Validate Assignment - expect( - await wait.andGetValue( - driver, - form.texters.texterAssignmentByText(campaign.admin.given_name) - ) - ).toBe("0"); - } else { - // Dynamically Assign - await wait.andClick(driver, form.texters.useDynamicAssignment, { - elementIsVisible: false, - waitAfterVisible: 2000 - }); - // Store the invite (join) URL into a global for future use. - global.e2e.joinUrl = await wait.andGetValue( - driver, - form.texters.joinUrl - ); - } - // Save - await wait.andClick(driver, form.save); - // This should switch to the Interactions section - expect( - await wait.andGetEl(driver, form.interactions.editorLaunch) - ).toBeDefined(); - }); - - describe("completes the Interactions section", () => { - it("adds an initial question", async () => { - // Script - await wait.andClick(driver, form.interactions.editorLaunch); - await wait.andType( - driver, - pom.scriptEditor.editor, - campaign.interaction.script, - { clear: false, click: false, waitAfterVisible: 2000 } - ); - await wait.andClick(driver, pom.scriptEditor.done, { goesStale: true }); - // Question - await wait.andType( - driver, - form.interactions.questionText, - campaign.interaction.question, - { waitAfterVisible: 2000 } - ); - // Save with No Answers Defined - await wait.andClick(driver, form.interactions.submit); - await wait.andClick(driver, form.interactions.section, { - waitAfterVisible: 2000 - }); - let allChildInteractions = await driver.findElements( - form.interactions.childInteraction - ); - expect(allChildInteractions.length).toBe(0); - // Save with Empty Answer - await wait.andClick(driver, form.interactions.addResponse); - await wait.andClick(driver, form.interactions.submit); - await wait.andClick(driver, form.interactions.section, { - waitAfterVisible: 2000 - }); - allChildInteractions = await driver.findElements( - form.interactions.childInteraction - ); - expect(allChildInteractions.length).toBe(1); - }); - - describe("Add all Responses", () => { - _.each(campaign.interaction.answers, (answer, index) => { - it(`Adds Answer ${index}`, async () => { - if (index > 0) - await wait.andClick(driver, form.interactions.addResponse); // The first (0th) response reuses the empty Answer created above - // Answer - await wait.andType( - driver, - form.interactions.answerOptionChildByIndex(index), - answer.answerOption, - { clear: false, waitAfterVisible: 2000 } - ); - // Answer Script - await wait.andClick( - driver, - form.interactions.editorLaunchChildByIndex(index) - ); - await wait.andType(driver, pom.scriptEditor.editor, answer.script, { - clear: false, - click: false, - waitAfterVisible: 2000 - }); - await wait.andClick(driver, pom.scriptEditor.done, { - goesStale: true - }); - // Answer - Next Question - await wait.andType( - driver, - form.interactions.questionTextChildByIndex(index), - answer.questionText, - { clear: false, waitAfterVisible: 2000 } - ); - }); - }); - it("validates that all responses were added", async () => { - const allChildInteractions = await driver.findElements( - form.interactions.childInteraction - ); - expect(allChildInteractions.length).toBe( - campaign.interaction.answers.length - ); - }); - }); - - it("saves for the last time", async () => { - // Save - await wait.andClick(driver, form.interactions.submit); - // This should switch to the Canned Responses section - expect( - await wait.andGetEl(driver, form.cannedResponse.addNew) - ).toBeDefined(); - }); - }); - - it("completes the Canned Responses section", async () => { - // Add New - await wait.andClick(driver, form.cannedResponse.addNew); - // Title - await wait.andType( - driver, - form.cannedResponse.title, - campaign.cannedResponses[0].title - ); - // Script - await wait.andClick(driver, form.cannedResponse.editorLaunch); - await wait.andType( - driver, - pom.scriptEditor.editor, - campaign.cannedResponses[0].script, - { clear: false, click: false, waitAfterVisible: 2000 } - ); - await wait.andClick(driver, pom.scriptEditor.done, { goesStale: true }); - // Script - Relaunch and cancel (bug?) - await wait.andClick(driver, form.cannedResponse.editorLaunch, { - waitAfterVisible: 2000 - }); - await wait.andClick(driver, pom.scriptEditor.cancel, { - waitAfterVisible: 2000, - goesStale: true - }); - // Submit Response - await wait.andClick(driver, form.cannedResponse.submit, { - waitAfterVisible: 2000, - goesStale: true - }); - // Save - await wait.andClick(driver, form.save, { - waitAfterVisible: 2000, - goesStale: true - }); - // Should be able to start campaign - expect(await wait.andIsEnabled(driver, pom.campaigns.start)).toBeTruthy(); - }); - - it("clicks Start Campaign", async () => { - // Store the new campaign URL into a global for future use. - global.e2e.newCampaignUrl = await driver.getCurrentUrl(); - await wait.andClick(driver, pom.campaigns.start, { - waitAfterVisible: 2000, - goesStale: true - }); - // Validate Started - expect(await wait.andGetEl(driver, pom.campaigns.isStarted)).toBeTruthy(); - }); - }, - copyCampaign(driver, campaign) { - it("opens the Campaigns tab", async () => { - await driver.get(urlBuilder.admin.root()); - await wait.andClick(driver, pom.navigation.sections.campaigns); - }); - - it("clicks on an existing campaign", async () => { - await wait.andClick( - driver, - pom.campaigns.campaignRowByText(campaign.basics.title), - { goesStale: true } - ); - }); - - it("clicks Copy in Stats", async () => { - await wait.andClick(driver, pom.campaigns.stats.copy, { - waitAfterVisible: 2000 - }); - }); - - it("verifies copy in Campaigns list", async () => { - await wait.andClick(driver, pom.navigation.sections.campaigns); - expect( - await wait.andGetEl(driver, pom.campaigns.campaignRowByText("COPY")) - ).toBeDefined(); - // expect(await wait.andGetEl(driver, pom.campaigns.warningIcon)).toBeDefined() - await wait.andClick(driver, pom.campaigns.campaignRowByText("COPY")); - }); - - describe("verifies Campaign sections", () => { - it("verifies Basics section", async () => { - await wait.andClick(driver, form.basics.section); - expect(await wait.andGetValue(driver, form.basics.title)).toBe( - campaign.basics.title_copied - ); - expect(await wait.andGetValue(driver, form.basics.description)).toBe( - campaign.basics.description - ); - expect(await wait.andGetValue(driver, form.basics.dueBy)).toBe(""); - }); - it("verifies Contacts section", async () => { - await wait.andClick(driver, form.contacts.section); - const uploadedContacts = await driver.findElements( - form.contacts.uploadedContacts - ); - expect(uploadedContacts.length > 0).toBeFalsy(); - }); - it("verifies Texters section", async () => { - await wait.andClick(driver, form.texters.section); - const assignedContacts = await driver.findElements( - form.texters.texterAssignmentByText(campaign.texter.given_name) - ); - expect(assignedContacts.length > 0).toBeFalsy(); - }); - it("verifies Interactions section", async () => { - await wait.andClick(driver, form.interactions.section); - expect( - await wait.andGetValue(driver, form.interactions.editorLaunch) - ).toBe(campaign.interaction.script); - expect( - await wait.andGetValue(driver, form.interactions.questionText) - ).toBe(campaign.interaction.question); - // Verify Answers - const allChildInteractions = await driver.findElements( - form.interactions.childInteraction - ); - expect(allChildInteractions.length).toBe( - campaign.interaction.answers.length - ); - }); - it("verifies Canned Responses section", async () => { - await wait.andClick(driver, form.cannedResponse.section); - expect( - await wait.andGetEl( - driver, - form.cannedResponse.createdResponseByText( - campaign.cannedResponses[0].title - ) - ) - ).toBeDefined(); - expect( - await wait.andGetEl( - driver, - form.cannedResponse.createdResponseByText( - campaign.cannedResponses[0].script - ) - ) - ).toBeDefined(); - }); - }); - }, - editCampaign(driver, campaign) { - it("opens the Campaigns tab", async () => { - await driver.get(urlBuilder.admin.root()); - await wait.andClick(driver, pom.navigation.sections.campaigns); - }); - - it("clicks on an existing campaign", async () => { - await wait.andClick( - driver, - pom.campaigns.campaignRowByText(campaign.basics.title), - { goesStale: true } - ); - }); - - it("clicks edit in Stats", async () => { - await wait.andClick(driver, pom.campaigns.stats.edit, { - waitAfterVisible: 2000, - goesStale: true - }); - }); - - it("changes the title in the Basics section", async () => { - // Expand Basics section - await wait.andClick(driver, form.basics.section); - // Change Title - await wait.andType( - driver, - form.basics.title, - campaign.basics.title_changed, - { clear: false } - ); - // Save - await wait.andClick(driver, form.save); - }); - - it("reopens the Basics section to verify title", async () => { - // Expand Basics section - await wait.andClick(driver, form.basics.section, { - waitAfterVisible: 2000 - }); - // Verify Title - expect(await wait.andGetValue(driver, form.basics.title)).toBe( - campaign.basics.title_changed - ); - }); - }, - sendReplies(driver, campaign) { - it("sends Replies", async () => { - const sendRepliesUrl = - global.e2e.newCampaignUrl.substring( - 0, - global.e2e.newCampaignUrl.indexOf("edit?new=true") - ) + "send-replies"; - await driver.get(sendRepliesUrl); - }); - describe("simulates the assigned contacts sending replies", () => { - _.times(campaign.texters.contactLength, n => { - it(`sends reply ${n}`, async () => { - await wait.andType( - driver, - pom.campaigns.replyByIndex(n), - campaign.standardReply - ); - await wait.andClick(driver, pom.campaigns.sendByIndex(n)); - }); - }); - }); - } -}; diff --git a/__test__/e2e/page-functions/index.js b/__test__/e2e/page-functions/index.js deleted file mode 100644 index 37511f521..000000000 --- a/__test__/e2e/page-functions/index.js +++ /dev/null @@ -1,5 +0,0 @@ -export * from "./campaigns"; -export * from "./login"; -export * from "./main"; -export * from "./people"; -export * from "./texter"; diff --git a/__test__/e2e/page-functions/login.js b/__test__/e2e/page-functions/login.js deleted file mode 100644 index 4880cfa7f..000000000 --- a/__test__/e2e/page-functions/login.js +++ /dev/null @@ -1,95 +0,0 @@ -import { until } from "selenium-webdriver"; -import config from "../util/config"; -import { wait, urlBuilder } from "../util/helpers"; -import pom from "../page-objects/index"; - -// For legibility -const auth0 = pom.login.auth0; - -export const login = { - landing(driver) { - it("gets the landing page", async () => { - await driver.get(config.baseUrl); - }); - - it("clicks the login link", async () => { - // Click on the login button - wait.andClick(driver, pom.login.loginGetStarted, { - msWait: 50000, - waitAfterVisible: 2000 - }); - - // Wait until the Auth0 login page loads - await driver.wait(until.urlContains(urlBuilder.login)); - }); - }, - signUpTab(driver, user) { - let skip = false; // Assume that these tests will proceed - it("opens the Sign Up tab", async () => { - skip = !!global.e2e[user.name].loginSucceeded; // Skip tests if the login succeeded - if (!skip) { - wait.andClick(driver, auth0.tabs.signIn, { msWait: 20000 }); - } - }); - - it("fills in the new user details", async () => { - if (!skip) { - await driver.sleep(3000); // Allow time for the client to populate the email - await wait.andType(driver, auth0.form.email, user.email); - await wait.andType(driver, auth0.form.password, user.password); - await wait.andType(driver, auth0.form.given_name, user.given_name); - await wait.andType(driver, auth0.form.family_name, user.family_name); - await wait.andType(driver, auth0.form.cell, user.cell); - } - }); - - it("accepts the user agreement", async () => { - if (!skip) await wait.andClick(driver, auth0.form.agreement); - }); - - it("clicks the submit button", async () => { - if (!skip) await wait.andClick(driver, auth0.form.submit); - }); - - it("authorizes Auth0 to access tenant", async () => { - if (!skip) await wait.andClick(driver, auth0.authorize.allow); - }); - }, - signUp(driver, user) { - this.landing(driver, user); - this.signUpTab(driver, user); - }, - logIn(driver, user) { - it("opens the Log In tab", async () => { - await wait.andClick(driver, auth0.tabs.logIn, { msWait: 50000 }); - }); - - it("fills in the existing user details", async () => { - await wait.andType(driver, auth0.form.email, user.email); - await wait.andType(driver, auth0.form.password, user.password); - }); - - it("clicks the submit button", async () => { - await wait.andClick(driver, auth0.form.submit, { - waitAfterVisible: 1000 - }); - }); - }, - tryLoginThenSignUp(driver, user) { - this.logIn(driver, user); - it("looks for an error", async () => { - global.e2e[user.name] = {}; // Set a global object for the user - await driver.sleep(5000); // Wait for login attempt to return. Takes about 1 sec - const errors = await driver.findElements(auth0.form.error); - global.e2e[user.name].loginSucceeded = errors.length === 0; - }); - describe("Sign Up if Login Fails", () => { - /** - * Note - * This always runs, as the test suite is defined before any tests run - * However, all tests will skip if global.e2e[user.name].loginSucceeded - */ - this.signUpTab(driver, user); - }); - } -}; diff --git a/__test__/e2e/page-functions/main.js b/__test__/e2e/page-functions/main.js deleted file mode 100644 index 1409e07bf..000000000 --- a/__test__/e2e/page-functions/main.js +++ /dev/null @@ -1,104 +0,0 @@ -import { until } from "selenium-webdriver"; -import { wait } from "../util/helpers"; -import config from "../util/config"; -import pom from "../page-objects/index"; - -export const main = { - createOrg(driver, name) { - it("fills in the organization name", async () => { - await wait.andType(driver, pom.main.organization.name, name); - }); - - it("clicks the submit button", async () => { - await wait.andClick(driver, pom.main.organization.submit); - await driver.wait(until.urlContains("admin")); - const url = await driver.getCurrentUrl(); - const re = /\/admin\/(\d+)\//g; - global.e2e.organization = await re.exec(url)[1]; - }); - }, - editUser(driver, user) { - it("opens the User menu", async () => { - await wait.andClick(driver, pom.main.userMenuButton); - }); - - it("click on the user name", async () => { - await wait.andClick(driver, pom.main.userMenuDisplayName); - }); - - it("changes user details", async () => { - await wait.andType( - driver, - pom.people.edit.firstName, - user.given_name_changed, - { clear: false } - ); - await wait.andType( - driver, - pom.people.edit.lastName, - user.family_name_changed, - { clear: false } - ); - await wait.andType(driver, pom.people.edit.email, user.email_changed, { - clear: false - }); - await wait.andType(driver, pom.people.edit.cell, user.cell_changed, { - clear: false - }); - // Save - await wait.andClick(driver, pom.people.edit.save); - // Verify edits - expect(await wait.andGetValue(driver, pom.people.edit.firstName)).toBe( - user.given_name_changed - ); - expect(await wait.andGetValue(driver, pom.people.edit.lastName)).toBe( - user.family_name_changed - ); - expect(await wait.andGetValue(driver, pom.people.edit.email)).toBe( - user.email_changed - ); - }); - - it("reverts user details back to original settings", async () => { - await wait.andType(driver, pom.people.edit.firstName, user.given_name, { - clear: false - }); - await wait.andType(driver, pom.people.edit.lastName, user.family_name, { - clear: false - }); - await wait.andType(driver, pom.people.edit.email, user.email, { - clear: false - }); - await wait.andType(driver, pom.people.edit.cell, user.cell, { - clear: false - }); - // Save - await wait.andClick(driver, pom.people.edit.save); - // Verify edits - expect(await wait.andGetValue(driver, pom.people.edit.firstName)).toBe( - user.given_name - ); - expect(await wait.andGetValue(driver, pom.people.edit.lastName)).toBe( - user.family_name - ); - expect(await wait.andGetValue(driver, pom.people.edit.email)).toBe( - user.email - ); - }); - }, - logOutUser(driver) { - it("gets the landing page", async () => { - await driver.get(config.baseUrl); - }); - - it("opens the User menu", async () => { - await wait.andClick(driver, pom.main.userMenuButton); - }); - - it("clicks on log out", async () => { - await wait.andClick(driver, pom.main.logOut, { waitAfterVisible: 3000 }); - const re = /http[s]*:\/\/[^\/]+[\/]*$/g; - await driver.wait(until.urlMatches(re)); - }); - } -}; diff --git a/__test__/e2e/page-functions/people.js b/__test__/e2e/page-functions/people.js deleted file mode 100644 index 2deda6d59..000000000 --- a/__test__/e2e/page-functions/people.js +++ /dev/null @@ -1,99 +0,0 @@ -import { wait, urlBuilder } from "../util/helpers"; -import pom from "../page-objects/index"; - -export const people = { - invite(driver) { - it("opens the People tab", async () => { - await driver.get(urlBuilder.admin.root()); - await wait.andClick(driver, pom.navigation.sections.people); - }); - - it("clicks on the + button to Invite a User", async () => { - await wait.andClick(driver, pom.people.add); - }); - - it("views the invitation link", async () => { - // Store Invite - global.e2e.joinUrl = await wait.andGetValue( - driver, - pom.people.invite.joinUrl - ); - // OK - await wait.andClick(driver, pom.people.invite.ok); - }); - }, - editUser(driver, user) { - it("opens the People tab", async () => { - await driver.get(urlBuilder.admin.root()); - await wait.andClick(driver, pom.navigation.sections.people); - }); - - it("clicks on the Edit button next to name", async () => { - await wait.andClick( - driver, - pom.people.editButtonByName(user.given_name), - { waitAfterVisible: 2000 } - ); - }); - - it("changes user details", async () => { - await wait.andType( - driver, - pom.people.edit.firstName, - user.given_name_changed, - { clear: false, waitAfterVisible: 2000 } - ); - await wait.andType( - driver, - pom.people.edit.lastName, - user.family_name_changed, - { clear: false } - ); - await wait.andType(driver, pom.people.edit.email, user.email_changed, { - clear: false - }); - await wait.andType(driver, pom.people.edit.cell, user.cell_changed, { - clear: false - }); - // Save - await wait.andClick(driver, pom.people.edit.save); - // Verify edits - expect( - await wait.andGetEl( - driver, - pom.people.getRowByName(user.given_name_changed) - ) - ).toBeDefined(); - }); - - it("clicks on the Edit button next to name", async () => { - await wait.andClick( - driver, - pom.people.editButtonByName(user.given_name), - { waitAfterVisible: 2000 } - ); - }); - - it("reverts user details back to original settings", async () => { - await wait.andType(driver, pom.people.edit.firstName, user.given_name, { - clear: false, - waitAfterVisible: 2000 - }); - await wait.andType(driver, pom.people.edit.lastName, user.family_name, { - clear: false - }); - await wait.andType(driver, pom.people.edit.email, user.email, { - clear: false - }); - await wait.andType(driver, pom.people.edit.cell, user.cell, { - clear: false - }); - // Save - await wait.andClick(driver, pom.people.edit.save); - // Verify edits - expect( - await wait.andGetEl(driver, pom.people.getRowByName(user.given_name)) - ).toBeDefined(); - }); - } -}; diff --git a/__test__/e2e/page-functions/texter.js b/__test__/e2e/page-functions/texter.js deleted file mode 100644 index bee441b62..000000000 --- a/__test__/e2e/page-functions/texter.js +++ /dev/null @@ -1,59 +0,0 @@ -import _ from "lodash"; -import { wait, urlBuilder } from "../util/helpers"; -import pom from "../page-objects/index"; - -export const texter = { - sendTexts(driver, campaign) { - it("refreshes Dashboard", async () => { - await driver.get(urlBuilder.app.todos()); - await wait.andClick(driver, pom.texter.sendFirstTexts); - }); - describe("works though the list of assigned contacts", () => { - _.times(campaign.texters.contactLength, n => { - it(`sends text ${n}`, async () => { - await wait.andClick(driver, pom.texter.send); - }); - }); - it("should have an empty todo list", async () => { - await driver.get(urlBuilder.app.todos()); - expect(await wait.andGetEl(driver, pom.texter.emptyTodo)).toBeDefined(); - }); - }); - }, - optOutContact(driver) { - it("clicks the Opt Out button", async () => { - await wait.andClick(driver, pom.texter.optOut.button); - }); - it("clicks Send", async () => { - await wait.andClick(driver, pom.texter.optOut.send); - await driver.sleep(3000); - }); - }, - viewInvite(driver) { - it("follows the link to the invite", async () => { - await driver.get(global.e2e.joinUrl); - }); - }, - viewReplies(driver, campaign) { - it("refreshes Dashboard", async () => { - await driver.get(urlBuilder.app.todos()); - await wait.andClick(driver, pom.texter.sendReplies); - }); - it("verifies reply", async () => { - expect( - await wait.andGetEl( - driver, - pom.texter.replyByText(campaign.standardReply) - ) - ).toBeDefined(); - }); - }, - viewSendFirstTexts(driver) { - it("verifies that Send First Texts button is present", async () => { - await driver.get(urlBuilder.app.todos()); - expect( - await wait.andGetEl(driver, pom.texter.sendFirstTexts) - ).toBeDefined(); - }); - } -}; diff --git a/__test__/e2e/page-objects/campaigns.js b/__test__/e2e/page-objects/campaigns.js deleted file mode 100644 index 2292a8d6b..000000000 --- a/__test__/e2e/page-objects/campaigns.js +++ /dev/null @@ -1,108 +0,0 @@ -import { By } from "selenium-webdriver"; - -export const campaigns = { - add: By.css("[data-test=addCampaign]"), - start: By.css("[data-test=startCampaign]:not([disabled])"), - campaignRowByText(text) { - return By.xpath( - `//*[contains(text(),'${text}')]/ancestor::*[@data-test="campaignRow"]` - ); - }, - warningIcon: By.css("[data-test=warningIcon]"), - replyByIndex(index) { - return By.xpath(`(//input[@data-test='reply'])[${index + 1}]`); - }, - sendByIndex(index) { - return By.xpath(`(//button[@data-test='send'])[${index + 1}]`); - }, - form: { - basics: { - section: By.css("[data-test=basics]"), - title: By.css("[data-test=title]"), - description: By.css("[data-test=description]"), - dueBy: By.css("[data-test=dueBy]") - }, - datePickerDialog: { - // This selector is fragile and alternate means of finding an enabled date should be investigated. - nextMonth: By.css( - "body > div:nth-child(5) > div > div:nth-child(1) > div > div > div > div > div:nth-child(2) > div:nth-child(1) > div:nth-child(1) > button:nth-child(3)" - ), - enabledDate: By.css( - 'body > div:nth-child(5) > div > div:nth-child(1) > div > div > div > div > div:nth-child(2) > div:nth-child(1) > div:nth-child(3) > div > div button[tabindex="0"]' - ) - }, - contacts: { - section: By.css("[data-test=contacts]"), - uploadButton: By.css("[data-test=uploadButton]"), - input: By.css("#contact-upload"), - uploadedContacts: By.css("[data-test=uploadedContacts]"), - uploadedContactsByQty(n) { - return By.xpath( - `//*[@data-test='uploadedContacts']/descendant::*[contains(text(),'${n} contact')]` - ); - } - }, - texters: { - section: By.css("[data-test=texters]"), - useDynamicAssignment: By.css("[data-test=useDynamicAssignment]"), - joinUrl: By.css("[data-test=joinUrl]"), - addAll: By.css("[data-test=addAll]"), - autoSplit: By.css("[data-test=autoSplit]"), - texterAssignmentByText(text) { - return By.xpath( - `//*[@data-test='texterName' and contains(text(),'${text}')]/ancestor::*[@data-test='texterRow']/descendant::input[@data-test='texterAssignment']` - ); - }, - texterAssignmentByIndex(index) { - return By.xpath( - `(//*[@data-test='texterRow'])[${index + - 1}]/descendant::input[@data-test='texterAssignment']` - ); - } - }, - interactions: { - section: By.css("[data-test=interactions]"), - questionText: By.css("[data-test=questionText]"), - addResponse: By.css("[data-test=addResponse]:nth-child(1)"), - childInteraction: By.css("[data-test=childInteraction]"), - questionTextChildByIndex(index) { - return By.xpath( - `(//*[@data-test='childInteraction']/descendant::*[@data-test='questionText'])[${index + - 1}]` - ); - }, - editorLaunch: By.css("[data-test=editorInteraction]"), - editorLaunchChildByIndex(index) { - return By.xpath( - `(//*[@data-test='childInteraction']/descendant::*[@data-test='editorInteraction'])[${index + - 1}]` - ); - }, - answerOptionChildByIndex(index) { - return By.xpath( - `(//*[@data-test='childInteraction']/descendant::*[@data-test='answerOption'])[${index + - 1}]` - ); - }, - submit: By.css("[data-test=interactionSubmit]") - }, - cannedResponse: { - section: By.css("[data-test=cannedResponses]"), - addNew: By.css("[data-test=newCannedResponse]"), - title: By.css("[data-test=title]"), - editorLaunch: By.css("[data-test=editorResponse]"), - createdResponseByText(text) { - return By.xpath( - `//span[@data-test='cannedResponse']/descendant::*[contains(text(),'${text}')]` - ); - }, - submit: By.css("[data-test=addResponse]") - }, - save: By.css("[type=submit]:not([disabled])") - }, - stats: { - copy: By.css("[data-test=copyCampaign]"), - edit: By.css("[data-test=editCampaign]") - }, - isStarted: By.css("[data-test=campaignIsStarted]") -}; diff --git a/__test__/e2e/page-objects/index.js b/__test__/e2e/page-objects/index.js deleted file mode 100644 index d920ce90f..000000000 --- a/__test__/e2e/page-objects/index.js +++ /dev/null @@ -1,17 +0,0 @@ -import { campaigns } from "./campaigns"; -import { main } from "./main"; -import { login } from "./login"; -import { navigation } from "./navigation"; -import { people } from "./people"; -import { scriptEditor } from "./scriptEditor"; -import { texter } from "./texter"; - -export default { - campaigns, - login, - main, - navigation, - people, - scriptEditor, - texter -}; diff --git a/__test__/e2e/page-objects/login.js b/__test__/e2e/page-objects/login.js deleted file mode 100644 index c46e92913..000000000 --- a/__test__/e2e/page-objects/login.js +++ /dev/null @@ -1,26 +0,0 @@ -import { By } from "selenium-webdriver"; - -export const login = { - auth0: { - tabs: { - logIn: By.css(".auth0-lock-tabs>li:nth-child(1)"), - signIn: By.css(".auth0-lock-tabs>li:nth-child(2)") - }, - form: { - email: By.css("div.auth0-lock-input-email > div > input"), - password: By.css("div.auth0-lock-input-password > div > input"), - given_name: By.css("div.auth0-lock-input-given_name > div > input"), - family_name: By.css("div.auth0-lock-input-family_name > div > input"), - cell: By.css("div.auth0-lock-input-cell > div > input"), - agreement: By.css( - "span.auth0-lock-sign-up-terms-agreement > label > input" - ), // Checkbox - submit: By.css("button.auth0-lock-submit"), - error: By.css("div.auth0-global-message-error") - }, - authorize: { - allow: By.css("#allow") - } - }, - loginGetStarted: By.css("#login") -}; diff --git a/__test__/e2e/page-objects/main.js b/__test__/e2e/page-objects/main.js deleted file mode 100644 index 3ce7e8763..000000000 --- a/__test__/e2e/page-objects/main.js +++ /dev/null @@ -1,20 +0,0 @@ -import { By } from "selenium-webdriver"; - -export const main = { - organization: { - name: By.css("[data-test=organization]"), - submit: By.css('button[name="submit"]') - }, - userMenuButton: By.css("[data-test=userMenuButton]"), - userMenuDisplayName: By.css("[data-test=userMenuDisplayName]"), - edit: { - editButton: By.css("[data-test=editPerson]"), - firstName: By.css("[data-test=firstName]"), - lastName: By.css("[data-test=lastName]"), - email: By.css("[data-test=email]"), - cell: By.css("[data-test=cell]"), - save: By.css("[type=submit]") - }, - home: By.css("[data-test=home]"), - logOut: By.css("[data-test=userMenuLogOut]") -}; diff --git a/__test__/e2e/page-objects/navigation.js b/__test__/e2e/page-objects/navigation.js deleted file mode 100644 index 20f109139..000000000 --- a/__test__/e2e/page-objects/navigation.js +++ /dev/null @@ -1,12 +0,0 @@ -import { By } from "selenium-webdriver"; - -export const navigation = { - sections: { - campaigns: By.css("[data-test=navCampaigns]"), - people: By.css("[data-test=navPeople]"), - optouts: By.css("[data-test=navOptouts]"), - messageReview: By.css("[data-test=navIncoming]"), - settings: By.css("[data-test=navSettings]"), - switchToTexter: By.css("[data-test=navSwitchToTexter]") - } -}; diff --git a/__test__/e2e/page-objects/people.js b/__test__/e2e/page-objects/people.js deleted file mode 100644 index c3f7d4478..000000000 --- a/__test__/e2e/page-objects/people.js +++ /dev/null @@ -1,25 +0,0 @@ -import { By } from "selenium-webdriver"; - -export const people = { - add: By.css("[data-test=addPerson]"), - invite: { - joinUrl: By.css("[data-test=joinUrl]"), - ok: By.css("[data-test=inviteOk]") - }, - getRowByName(name) { - return By.xpath(`//td[contains(text(),'${name}')]/ancestor::tr`); - }, - editButtonByName(name) { - return By.xpath( - `//td[contains(text(),'${name}')]/ancestor::tr/descendant::button[@data-test='editPerson']` - ); - }, - edit: { - editButton: By.css("[data-test=editPerson]"), - firstName: By.css("[data-test=firstName]"), - lastName: By.css("[data-test=lastName]"), - email: By.css("[data-test=email]"), - cell: By.css("[data-test=cell]"), - save: By.css("[type=submit]") - } -}; diff --git a/__test__/e2e/page-objects/scriptEditor.js b/__test__/e2e/page-objects/scriptEditor.js deleted file mode 100644 index 52cecde48..000000000 --- a/__test__/e2e/page-objects/scriptEditor.js +++ /dev/null @@ -1,7 +0,0 @@ -import { By } from "selenium-webdriver"; - -export const scriptEditor = { - editor: By.css(".public-DraftEditor-content"), - done: By.css("[data-test=scriptDone]"), - cancel: By.css("[data-test=scriptCancel]") -}; diff --git a/__test__/e2e/page-objects/texter.js b/__test__/e2e/page-objects/texter.js deleted file mode 100644 index 9ebbcfc35..000000000 --- a/__test__/e2e/page-objects/texter.js +++ /dev/null @@ -1,17 +0,0 @@ -import { By } from "selenium-webdriver"; - -export const texter = { - sendFirstTexts: By.css("[data-test=sendFirstTexts]"), - sendReplies: By.css("[data-test=sendReplies]"), - send: By.css("[data-test=send]:not([disabled])"), - replyByText(text) { - return By.xpath( - `//*[@data-test='messageList']/descendant::*[contains(text(),'${text}')]` - ); - }, - emptyTodo: By.css("[data-test=empty]"), - optOut: { - button: By.css("[data-test=optOut]"), - send: By.css("[type=submit]") - } -}; diff --git a/__test__/e2e/util/config.js b/__test__/e2e/util/config.js deleted file mode 100644 index 9b2fc124e..000000000 --- a/__test__/e2e/util/config.js +++ /dev/null @@ -1,21 +0,0 @@ -const config = { - baseUrl: global.BASE_URL, - sauceLabs: { - username: process.env.SAUCE_USERNAME, - accessKey: process.env.SAUCE_ACCESS_KEY, - capabilities: { - name: "Spoke - Chrome E2E Tests", - browserName: "chrome", - idleTimeout: 240, // 4 minute idle - "tunnel-identifier": process.env.TRAVIS_JOB_NUMBER, - username: process.env.SAUCE_USERNAME, - accessKey: process.env.SAUCE_ACCESS_KEY, - build: process.env.TRAVIS_BUILD_NUMBER - }, - server: `http://${process.env.SAUCE_USERNAME}:${process.env.SAUCE_ACCESS_KEY}@ondemand.saucelabs.com:80/wd/hub`, - host: "localhost", - port: 4445 - } -}; - -export default config; diff --git a/__test__/e2e/util/helpers.js b/__test__/e2e/util/helpers.js deleted file mode 100644 index c915a419a..000000000 --- a/__test__/e2e/util/helpers.js +++ /dev/null @@ -1,109 +0,0 @@ -import { Builder, until } from "selenium-webdriver"; -import remote from "selenium-webdriver/remote"; -import config from "./config"; -import _ from "lodash"; - -import SauceLabs from "saucelabs"; - -const saucelabs = new SauceLabs({ - username: process.env.SAUCE_USERNAME, - password: process.env.SAUCE_ACCESS_KEY -}); - -const defaultWait = 10000; - -export const selenium = { - buildDriver(options) { - const capabilities = _.assign({}, config.sauceLabs.capabilities, options); - const driver = process.env.npm_config_saucelabs - ? new Builder() - .withCapabilities(capabilities) - .usingServer(config.sauceLabs.server) - .build() - : new Builder().forBrowser("chrome").build(); - driver.setFileDetector(new remote.FileDetector()); - return driver; - }, - async quitDriver(driver) { - await driver.getSession().then(async session => { - if (process.env.npm_config_saucelabs) { - const sessionId = session.getId(); - process.env.SELENIUM_ID = sessionId; - await saucelabs.updateJob(sessionId, { - passed: global.e2e.failureCount === 0 - }); - console.log( - `SauceOnDemandSessionID=${sessionId} job-name=${process.env - .TRAVIS_JOB_NUMBER || ""}` - ); - } - }); - await driver.quit(); - }, - reporter: { - specDone: async result => { - global.e2e.failureCount = - global.e2e.failureCount + result.failedExpectations.length || 0; - }, - suiteDone: async result => { - global.e2e.failureCount = - global.e2e.failureCount + result.failedExpectations.length || 0; - } - } -}; - -export const urlBuilder = { - login: `${config.baseUrl}/login`, - admin: { - root() { - return `${config.baseUrl}/admin/${global.e2e.organization}`; - } - }, - app: { - todos() { - return `${config.baseUrl}/app/${global.e2e.organization}/todos`; - } - } -}; - -const waitAnd = async (driver, locator, options) => { - const el = await driver.wait( - until.elementLocated(locator, options.msWait || defaultWait) - ); - if (options.elementIsVisible !== false) - await driver.wait(until.elementIsVisible(el)); - if (options.waitAfterVisible) await driver.sleep(options.waitAfterVisible); - if (options.click) await el.click(); - if (options.keys) await driver.sleep(500); - if (options.clear) await el.clear(); - if (options.keys) await el.sendKeys(options.keys); - if (options.goesStale) await driver.wait(until.stalenessOf(el)); - return el; -}; - -export const wait = { - async untilLocated(driver, locator, options) { - return await waitAnd(driver, locator, _.assign({}, options)); - }, - async andGetEl(driver, locator, options) { - return await waitAnd(driver, locator, _.assign({}, options)); - }, - async andClick(driver, locator, options) { - return await waitAnd(driver, locator, _.assign({ click: true }, options)); - }, - async andType(driver, locator, keys, options) { - return await waitAnd( - driver, - locator, - _.assign({ keys, clear: true, click: true }, options) - ); - }, - async andGetValue(driver, locator, options) { - const el = await waitAnd(driver, locator, _.assign({}, options)); - return await el.getAttribute("value"); - }, - async andIsEnabled(driver, locator, options) { - const el = await waitAnd(driver, locator, _.assign({}, options)); - return await el.isEnabled(); - } -}; diff --git a/__test__/e2e/util/setup.js b/__test__/e2e/util/setup.js deleted file mode 100644 index dbfaac3a8..000000000 --- a/__test__/e2e/util/setup.js +++ /dev/null @@ -1,3 +0,0 @@ -// This script will execute before the entire end to end run -jest.setTimeout(1 * 60 * 1000); // Set the test callback timeout to 1 minute -global.e2e = {}; // Pass global information around using the global object as Jasmine context isn't available. diff --git a/__test__/integrations/action-handlers/action-network.test.js b/__test__/extensions/action-handlers/action-network.test.js similarity index 99% rename from __test__/integrations/action-handlers/action-network.test.js rename to __test__/extensions/action-handlers/action-network.test.js index a4e5e0400..271054bbb 100644 --- a/__test__/integrations/action-handlers/action-network.test.js +++ b/__test__/extensions/action-handlers/action-network.test.js @@ -1,6 +1,6 @@ import nock from "nock"; import moment from "moment"; -const ActionNetwork = require("../../../src/integrations/action-handlers/action-network"); +const ActionNetwork = require("../../../src/extensions/action-handlers/action-network"); expect.extend({ stringifiedObjectEqualObject(receivedString, expectedObject) { diff --git a/__test__/extensions/message-handlers/ngpvan.test.js b/__test__/extensions/message-handlers/ngpvan.test.js new file mode 100644 index 000000000..67af0357b --- /dev/null +++ b/__test__/extensions/message-handlers/ngpvan.test.js @@ -0,0 +1,154 @@ +const Config = require("../../../src/server/api/lib/config"); +const Van = require("../../../src/extensions/message-handlers/ngpvan"); +const VanAction = require("../../../src/extensions/action-handlers/ngpvan-action"); +const ActionHandlers = require("../../../src/extensions/action-handlers"); + +describe("extensions.message-handlers.ngpvan", () => { + afterEach(async () => { + jest.restoreAllMocks(); + }); + + describe("postMessageSave", () => { + let message; + let contact; + let organization; + + beforeEach(async () => { + message = { + is_from_contact: false + }; + + contact = { + message_status: "needsMessage" + }; + + organization = { + id: 1 + }; + + jest.spyOn(Config, "getConfig").mockReturnValue(undefined); + jest.spyOn(Van, "available").mockReturnValue(true); + jest.spyOn(ActionHandlers, "getActionChoiceData").mockResolvedValue([ + { + name: "Texted", + details: JSON.stringify({ data: "fake_data" }) + } + ]); + + jest.spyOn(VanAction, "postCanvassResponse").mockResolvedValue(null); + }); + + it("delegates to its dependencies", async () => { + const result = await Van.postMessageSave({ + message, + contact, + organization + }); + + expect(result).toEqual({}); + + expect(ActionHandlers.getActionChoiceData.mock.calls).toEqual([ + [VanAction, organization] + ]); + + expect(Config.getConfig.mock.calls).toEqual([ + ["NGP_VAN_INITIAL_TEXT_CANVASS_RESULT", organization] + ]); + + expect(VanAction.postCanvassResponse.mock.calls).toEqual([ + [ + contact, + organization, + { + data: "fake_data" + } + ] + ]); + }); + + describe.only("when NGP_VAN_INITIAL_TEXT_CANVASS_RESULT is not found in the actions", () => { + beforeEach(async () => { + ActionHandlers.getActionChoiceData.mockRestore(); + jest.spyOn(ActionHandlers, "getActionChoiceData").mockResolvedValue([]); + jest.spyOn(console, "error"); + }); + + it("Does not call Van.postCanvassResponse and logs an error", async () => { + await Van.postMessageSave({ + message, + contact, + organization + }); + + // eslint-disable-next-line no-console + expect(console.error.mock.calls).toEqual([ + [ + "NGPVAN message handler -- not handling message because no action choice data found for Texted" + ] + ]); + expect(VanAction.postCanvassResponse).not.toHaveBeenCalled(); + }); + }); + + describe("when the handler is not available", () => { + beforeEach(async () => { + Van.available.mockReturnValue(false); + }); + + it("returns an empty object and doesn't call getActionChoiceData", async () => { + const result = await Van.postMessageSave({ + message, + contact, + organization + }); + expect(result).toEqual({}); + expect(ActionHandlers.getActionChoiceData).not.toHaveBeenCalled(); + }); + }); + + describe("when the message is from a contact", () => { + beforeEach(async () => { + message.is_from_contact = true; + }); + + it("returns an empty object and doesn't call getActionChoiceData", async () => { + const result = await Van.postMessageSave({ + message, + contact, + organization + }); + expect(result).toEqual({}); + expect(ActionHandlers.getActionChoiceData).not.toHaveBeenCalled(); + }); + }); + + describe("when contact is null or undefined", () => { + it("returns an empty object and doesn't call getActionChoiceData", async () => { + const result = await Van.postMessageSave({ + message, + organization + }); + expect(result).toEqual({}); + expect(ActionHandlers.getActionChoiceData).not.toHaveBeenCalled(); + }); + }); + + describe("when this is not the first message to the contact", () => { + beforeEach(async () => { + contact.message_status = "messaged"; + }); + + it("returns an empty object and doesn't call getActionChoiceData", async () => { + const result = await Van.postMessageSave({ + message, + contact, + organization + }); + expect(result).toEqual({}); + expect(ActionHandlers.getActionChoiceData).not.toHaveBeenCalled(); + }); + }); + }); + + // +}); diff --git a/__test__/lib/dst-helper.test.js b/__test__/lib/dst-helper.test.js index f505d5b63..32ebb02b1 100644 --- a/__test__/lib/dst-helper.test.js +++ b/__test__/lib/dst-helper.test.js @@ -10,26 +10,18 @@ describe("test DstHelper", () => { it("helps us figure out if we're in DST in February in New York", () => { MockDate.set("2018-02-01T15:00:00Z"); - let d = new DateTime( - new Date(), - DateFunctions.Get, - zone("America/New_York") - ); - expect(DstHelper.isOffsetDst(d.offset(), "America/New_York")).toBeFalsy(); - expect(DstHelper.isDateTimeDst(d, "America/New_York")).toBeFalsy(); - expect(DstHelper.isDateDst(new Date(), "America/New_York")).toBeFalsy(); + let d = new DateTime(new Date(), DateFunctions.Get, zone("US/Eastern")); + expect(DstHelper.isOffsetDst(d.offset(), "US/Eastern")).toBeFalsy(); + expect(DstHelper.isDateTimeDst(d, "US/Eastern")).toBeFalsy(); + expect(DstHelper.isDateDst(new Date(), "US/Eastern")).toBeFalsy(); }); it("helps us figure out if we're in DST in July in New York", () => { MockDate.set("2018-07-21T15:00:00Z"); - let d = new DateTime( - new Date(), - DateFunctions.Get, - zone("America/New_York") - ); - expect(DstHelper.isOffsetDst(d.offset(), "America/New_York")).toBeTruthy(); - expect(DstHelper.isDateTimeDst(d, "America/New_York")).toBeTruthy(); - expect(DstHelper.isDateDst(new Date(), "America/New_York")).toBeTruthy(); + let d = new DateTime(new Date(), DateFunctions.Get, zone("US/Eastern")); + expect(DstHelper.isOffsetDst(d.offset(), "US/Eastern")).toBeTruthy(); + expect(DstHelper.isDateTimeDst(d, "US/Eastern")).toBeTruthy(); + expect(DstHelper.isDateDst(new Date(), "US/Eastern")).toBeTruthy(); }); it("helps us figure out if we're in DST in February in Sydney", () => { @@ -89,8 +81,8 @@ describe("test DstHelper", () => { }); it("correctly reports a timezone's offset and whether it has DST", () => { - expect(DstHelper.getTimezoneOffsetHours("America/New_York")).toEqual(-5); - expect(DstHelper.timezoneHasDst("America/New_York")).toBeTruthy(); + expect(DstHelper.getTimezoneOffsetHours("US/Eastern")).toEqual(-5); + expect(DstHelper.timezoneHasDst("US/Eastern")).toBeTruthy(); expect(DstHelper.getTimezoneOffsetHours("US/Arizona")).toEqual(-7); expect(DstHelper.timezoneHasDst("US/Arizona")).toBeFalsy(); expect(DstHelper.getTimezoneOffsetHours("Europe/Paris")).toEqual(1); diff --git a/__test__/lib/search-helpers.test.js b/__test__/lib/search-helpers.test.js new file mode 100644 index 000000000..fdcf85fdc --- /dev/null +++ b/__test__/lib/search-helpers.test.js @@ -0,0 +1,37 @@ +import { searchFor } from "../../src/lib/search-helpers"; + +const objects = [ + { + field1: "red", + field2: { inner: "purple" } + }, + { + field1: "blue", + field2: { inner: "yellow" } + }, + { + field1: "green", + field2: { inner: " blue " } + } +]; + +describe("searchFor", () => { + test("returns correct object based on one property", () => { + const result = searchFor("blue", objects, ["field1"]); + expect(result).toEqual([{ field1: "blue", field2: { inner: "yellow" } }]); + }); + + test("returns correct objects based on different (nested) properties", () => { + const result = searchFor("blue", objects, ["field1", "field2.inner"]); + expect(result).toEqual([ + { + field1: "blue", + field2: { inner: "yellow" } + }, + { + field1: "green", + field2: { inner: " blue " } + } + ]); + }); +}); diff --git a/__test__/lib/timezones.test.js b/__test__/lib/timezones.test.js index 895a2bab5..43dff5f62 100644 --- a/__test__/lib/timezones.test.js +++ b/__test__/lib/timezones.test.js @@ -101,7 +101,7 @@ const buildIsBetweenTextingHoursExpectWithNoOffset = (start, end) => { 0, 0, false, - makeCampignTextingHoursConfig(true, start, end, "America/New_York") + makeCampignTextingHoursConfig(true, start, end, "US/Eastern") ) ) ); @@ -566,7 +566,11 @@ describe("test defaultTimezoneIsBetweenTextingHours", () => { describe("test convertOffsetsToStrings", () => { it("works", () => { - let test_offsets = [[1, true], [2, false], [-1, true]]; + let test_offsets = [ + [1, true], + [2, false], + [-1, true] + ]; let strings_returned = convertOffsetsToStrings(test_offsets); expect(strings_returned).toHaveLength(3); expect(strings_returned[0]).toBe("1_1"); @@ -664,7 +668,7 @@ describe("test getContactTimezone", () => { true, 14, 16, - "America/New_York" + "US/Eastern" ), {} ) @@ -885,7 +889,11 @@ describe("test defaultTimezoneIsBetweenTextingHours", () => { describe("test convertOffsetsToStrings", () => { it("works", () => { - let test_offsets = [[1, true], [2, false], [-1, true]]; + let test_offsets = [ + [1, true], + [2, false], + [-1, true] + ]; let strings_returned = convertOffsetsToStrings(test_offsets); expect(strings_returned).toHaveLength(3); expect(strings_returned[0]).toBe("1_1"); @@ -983,7 +991,7 @@ describe("test getContactTimezone", () => { true, 14, 16, - "America/New_York" + "US/Eastern" ), {} ) @@ -1004,7 +1012,7 @@ describe("test getContactTimezone", () => { true, 14, 16, - "America/New_York" + "US/Eastern" ), {} ) @@ -1024,7 +1032,7 @@ describe("test getContactTimezone", () => { true, 14, 16, - "America/New_York" + "US/Eastern" ), {} ) @@ -1044,37 +1052,37 @@ describe("test getUtcFromOffsetAndHour", () => { it("returns the correct UTC during northern hemisphere summer", () => { MockDate.set("2018-07-01T11:00:00.000-05:00"); - expect( - getUtcFromOffsetAndHour(-5, true, 12, "America/New_York").unix() - ).toEqual(moment("2018-07-01T16:00:00.000Z").unix()); + expect(getUtcFromOffsetAndHour(-5, true, 12, "US/Eastern").unix()).toEqual( + moment("2018-07-01T16:00:00.000Z").unix() + ); }); it("returns the correct UTC during northern hemisphere summer with result being next day", () => { MockDate.set("2018-07-01T11:00:00.000-05:00"); - expect( - getUtcFromOffsetAndHour(-5, true, 23, "America/New_York").unix() - ).toEqual(moment("2018-07-02T03:00:00.000Z").unix()); + expect(getUtcFromOffsetAndHour(-5, true, 23, "US/Eastern").unix()).toEqual( + moment("2018-07-02T03:00:00.000Z").unix() + ); }); it("returns the correct UTC during northern hemisphere winter", () => { MockDate.set("2018-02-01T11:00:00.000-05:00"); - expect( - getUtcFromOffsetAndHour(-5, true, 12, "America/New_York").unix() - ).toEqual(moment("2018-02-01T17:00:00.000Z").unix()); + expect(getUtcFromOffsetAndHour(-5, true, 12, "US/Eastern").unix()).toEqual( + moment("2018-02-01T17:00:00.000Z").unix() + ); }); it("returns the correct UTC during northern hemisphere summer if offset doesn't have DST", () => { MockDate.set("2018-07-01T11:00:00.000-05:00"); - expect( - getUtcFromOffsetAndHour(-5, false, 12, "America/New_York").unix() - ).toEqual(moment("2018-07-01T17:00:00.000Z").unix()); + expect(getUtcFromOffsetAndHour(-5, false, 12, "US/Eastern").unix()).toEqual( + moment("2018-07-01T17:00:00.000Z").unix() + ); }); it("returns the correct UTC during northern hemisphere winter if offset doesn't have DST", () => { MockDate.set("2018-02-01T11:00:00.000-05:00"); - expect( - getUtcFromOffsetAndHour(-5, false, 12, "America/New_York").unix() - ).toEqual(moment("2018-02-01T17:00:00.000Z").unix()); + expect(getUtcFromOffsetAndHour(-5, false, 12, "US/Eastern").unix()).toEqual( + moment("2018-02-01T17:00:00.000Z").unix() + ); }); }); @@ -1085,21 +1093,21 @@ describe("test getUtcFromTimezoneAndHour", () => { it("returns the correct UTC during northern hemisphere summer", () => { MockDate.set("2018-07-01T11:00:00.000-05:00"); - expect(getUtcFromTimezoneAndHour("America/New_York", 12).unix()).toEqual( + expect(getUtcFromTimezoneAndHour("US/Eastern", 12).unix()).toEqual( moment("2018-07-01T16:00:00.000Z").unix() ); }); it("returns the correct UTC during northern hemisphere summer with result being next day", () => { MockDate.set("2018-07-01T11:00:00.000-05:00"); - expect(getUtcFromTimezoneAndHour("America/New_York", 23).unix()).toEqual( + expect(getUtcFromTimezoneAndHour("US/Eastern", 23).unix()).toEqual( moment("2018-07-02T03:00:00.000Z").unix() ); }); it("returns the correct UTC during northern hemisphere winter", () => { MockDate.set("2018-02-01T11:00:00.000-05:00"); - expect(getUtcFromTimezoneAndHour("America/New_York", 12).unix()).toEqual( + expect(getUtcFromTimezoneAndHour("US/Eastern", 12).unix()).toEqual( moment("2018-02-01T17:00:00.000Z").unix() ); }); @@ -1171,7 +1179,7 @@ describe("test getSendBeforeTimewUtc", () => { overrideOrganizationTextingHours: true, textingHoursEnforced: true, textingHoursEnd: 21, - timezone: "America/New_York" + timezone: "US/Eastern" } ).unix() ).toEqual(moment("2018-09-04T01:00:00.000Z").unix()); @@ -1190,14 +1198,14 @@ describe("test getSendBeforeTimewUtc", () => { overrideOrganizationTextingHours: true, textingHoursEnforced: true, textingHoursEnd: 21, - timezone: "America/New_York" + timezone: "US/Eastern" } ).unix() ).toEqual(moment("2018-09-04T01:00:00.000Z").unix()); }); it("returns correct time if campaign does not override and TZ is set", () => { - tzHelpers.getProcessEnvTz.mockImplementation(() => "America/New_York"); + tzHelpers.getProcessEnvTz.mockImplementation(() => "US/Eastern"); expect( getSendBeforeTimeUtc( {}, diff --git a/__test__/lib/tz-helpers.test.js b/__test__/lib/tz-helpers.test.js index 89b60a474..40c74e3e3 100644 --- a/__test__/lib/tz-helpers.test.js +++ b/__test__/lib/tz-helpers.test.js @@ -4,6 +4,6 @@ jest.unmock("../../src/lib/tz-helpers"); describe("test getProcessEnvDstReferenceTimezone", () => { it("works", () => { - expect(getProcessEnvDstReferenceTimezone()).toEqual("America/New_York"); + expect(getProcessEnvDstReferenceTimezone()).toEqual("US/Eastern"); }); }); diff --git a/__test__/test_data/female_scientists.csv b/__test__/test_data/female_scientists.csv index 975cc187f..33bbb3039 100644 --- a/__test__/test_data/female_scientists.csv +++ b/__test__/test_data/female_scientists.csv @@ -276,11 +276,11 @@ Angeliki,Panajiotatou,2125550173,30273 Kathleen,I. Pritchard,2125550174,30274 Frieda,Robscheit-Robbins,2125550175,90275 Ora,Mendelsohn Rosen,2125550176,90276 -Una,Ryan,2125550177,90277 -Una,M. Ryan,2125550178,90278 -Velma,Scantlebury,2125550179,90279 -Lise,Thiry,2125550180,90280 -Helen,Rodríguez Trías,2125550181,90281 +Una,Ryan,2125550177,96704 +Una,M. Ryan,2125550178,96704 +Velma,Scantlebury,2125550179,96704 +Lise,Thiry,2125550180,96704 +Helen,Rodríguez Trías,2125550181,96704 Marie,Stopes,2125550182,90282 Elizabeth,M. Ward,2125550183,30283 Elsie,Widdowson,2125550184,30284 diff --git a/__test__/test_helpers.js b/__test__/test_helpers.js index 4e6c10976..d52498c71 100644 --- a/__test__/test_helpers.js +++ b/__test__/test_helpers.js @@ -9,6 +9,12 @@ import { } from "../src/server/models/"; import { graphql } from "graphql"; +// Cypress integration tests do not use jest but do use these helpers +// They would benefit from mocking mail services, though, so something to look in to. +if (global.jest) { + global.jest.mock("../src/server/mail"); +} + export async function setupTest() { // FUTURE: only run this once maybe and then truncateTables() from models? await createTables(); @@ -531,7 +537,6 @@ export async function createCannedResponses(admin, campaign, cannedResponses) { ); } -jest.mock("../src/server/mail"); export async function startCampaign(admin, campaign) { const rootValue = {}; const startCampaignQuery = `mutation startCampaign($campaignId: String!) { diff --git a/app.json b/app.json index 3abf02442..eb705d1d7 100644 --- a/app.json +++ b/app.json @@ -163,7 +163,13 @@ "DST_REFERENCE_TIMEZONE": { "description": "Timezone to use to determine whether DST is in effect for a date", "required": true, - "value": "America/New_York" + "value": "US/Eastern" + }, + + "DEFAULT_TZ": { + "description": "Timezone", + "required": true, + "value": "US/Eastern" }, "HEROKU_APP_NAME": { diff --git a/cypress.json b/cypress.json index f7e8c0d76..d0ccaa08d 100644 --- a/cypress.json +++ b/cypress.json @@ -1,12 +1,9 @@ { - "baseUrl": "http://localhost:3000", + "baseUrl": "http://localhost:3001", "integrationFolder": "__test__/cypress/integration", "fixturesFolder": "__test__/cypress/fixtures", "pluginsFile": "__test__/cypress/plugins/index.js", "supportFile": "__test__/cypress/support/index.js", "testFiles": "*.test.js", - "env": { - "SUPPRESS_ORG_CREATION": false, - "TEST_ORGANIZATION_ID": null - } + "video": true } diff --git a/deploy/lambda-env.json b/deploy/lambda-env.json index d4c401842..7ece85a1c 100644 --- a/deploy/lambda-env.json +++ b/deploy/lambda-env.json @@ -38,6 +38,7 @@ "ROLLBAR_CLIENT_TOKEN": "set_this_in_production", "ROLLBAR_ACCESS_TOKEN": "set_this_in_production", "ROLLBAR_ENDPOINT": "https://api.rollbar.com/api/1/item/", - "DST_REFERENCE_TIMEZONE": "America/New_York", - "TZ": "America/New_York" + "PHONE_NUMBER_COUNTRY": "US", + "DST_REFERENCE_TIMEZONE": "US/Eastern", + "TZ": "US/Eastern" } diff --git a/deploy/lambda-scheduled-event.json b/deploy/lambda-scheduled-event.json index 80172f5c8..d6750b720 100644 --- a/deploy/lambda-scheduled-event.json +++ b/deploy/lambda-scheduled-event.json @@ -1,4 +1,5 @@ { "command": "dispatchProcesses", + "maxCount": "2", "description": "see docs/DEPLOYING_AWS_LAMBDA.md" } diff --git a/deploy/spoke-pm2.config.js.template b/deploy/spoke-pm2.config.js.template index ce50c39e1..e19c6db45 100644 --- a/deploy/spoke-pm2.config.js.template +++ b/deploy/spoke-pm2.config.js.template @@ -43,7 +43,7 @@ const env_production = { ROLLBAR_ACCESS_TOKEN:'', ROLLBAR_ENDPOINT:'https://api.rollbar.com/api/1/item/', ALLOW_SEND_ALL: false, - DST_REFERENCE_TIMEZONE: 'America/New_York' + DST_REFERENCE_TIMEZONE: 'US/Eastern' } module.exports = { diff --git a/dev-tools/.env.test b/dev-tools/.env.test new file mode 100644 index 000000000..341b8b3d4 --- /dev/null +++ b/dev-tools/.env.test @@ -0,0 +1,28 @@ +# Configuration for local integration test server +NODE_ENV=test +SUPPRESS_SELF_INVITE= +JOBS_SAME_PROCESS=1 +DEV_APP_PORT=8091 +OUTPUT_DIR=./build +ASSETS_DIR=./build/client/assets +ASSETS_MAP_FILE=assets.json +DB_HOST=127.0.0.1 +DB_NAME=spoke_test +DB_USER=spoke_test +DB_PASSWORD=spoke_test +DB_TYPE=pg +DB_MIN_POOL=2 +DB_MAX_POOL=10 +DB_USE_SSL=false +DB_DEBUG=false +WEBPACK_HOT_RELOAD=1 +WEBPACK_HOST=localhost +WEBPACK_PORT=3001 +BASE_URL=http://localhost:3001 +SESSION_SECRET=set_this_in_production +DEFAULT_SERVICE=fakeservice +PHONE_NUMBER_COUNTRY=US +PHONE_INVENTORY=1 +ALLOW_SEND_ALL=false +DST_REFERENCE_TIMEZONE='America/New_York' +PASSPORT_STRATEGY=local diff --git a/dev-tools/Procfile.test b/dev-tools/Procfile.test new file mode 100644 index 000000000..e43269a6c --- /dev/null +++ b/dev-tools/Procfile.test @@ -0,0 +1,5 @@ +# Starts a Spoke server against which integration tests will run +# Enables babel and webpack hot reloading so you can do TDD :) +# Usage: yarn start:test +server: npm run start:test-babel +webpack: ./dev-tools/babel-run ./webpack/server.js \ No newline at end of file diff --git a/docs/HOWTO-run_tests.md b/docs/HOWTO-run_tests.md index 0d6e98165..c33dc877e 100644 --- a/docs/HOWTO-run_tests.md +++ b/docs/HOWTO-run_tests.md @@ -31,10 +31,16 @@ Redis is used for caching and is separate from the backend DB so can be used wit 1) Run `yarn test-rediscache` -## End-To-End Testing +## Integration Testing -1. Run your local development environment with DEFAULT_SERVICE=fakeservice. -2. Run `yarn run cypress open` for the interactive test runner. For non-interactive - run `yarn run cypress run --browser `. +The integration test suite automates real world user scenarios to verify that Spoke behaves as intended. The integration testing suite uses [Cypress]((https://docs.cypress.io/guides/guides/command-line.html)) to drive a web browser to test user experience scenarios. It runs a separate test instance of Spoke, configured separately from the config in your `.env`, using the `spoke_test` PostgreSQL database, and running at `http://localhost:3001`. -See [the Cypress documentation](https://docs.cypress.io/guides/guides/command-line.html) for more info. +To run the integration test suite in development: + +1. Set up PostgreSQL as described above +3. Start test instance of Spoke with `yarn start:test` +4. Run integration test suite interactively with `yarn test-cypress` + +The integration suite runs in CI using a Github Actions workflow defined in `.github/workflows/cypress-tests.yaml`. + +When developing new features for Spoke, please consider writing a Cypress tests. The test server will hot reload your code changes so that you can test drive your feature development. \ No newline at end of file diff --git a/docs/HOWTO-use-texter-sideboxes.md b/docs/HOWTO-use-texter-sideboxes.md index c318202ba..34ce5fe2f 100644 --- a/docs/HOWTO-use-texter-sideboxes.md +++ b/docs/HOWTO-use-texter-sideboxes.md @@ -65,6 +65,14 @@ can use it themselves to keep a specific conversation in a browser window to ref If your campaign has an account with freshdesk/freshworks, you can enable their [help widget](https://freshdesk.com/customer-engagement/help-widget) for texters to ask for help from the sidebox. +### hide-media + +Enabled this hides contact images and video sent back to texters in replies which can have offensive content. + +### mobilize-event-shifter + +Mobilize (America) event scheduling. User clicks the button and the MOBILIZE_EVENT_SHIFTER_URL opens up in a Dialog as an iframe, which is meant to be an organization's main event list. If contact has a zip, it is included to filter the mobilize events. If the campaign contacts include an event_id column in the CSV, or provides a default event id for the campaign/organization, it adds a tab for the mobilize event in an iframe and prefills the first name, last name, cell, email, and zip into the fields via the query string. Texter can then switch between tabs of the specific event or the general event list. [Pull request with screenshots](https://github.com/MoveOnOrg/Spoke/pull/1812) + ### tag-contact If you create tags related to 'escalation' or something you want texters to be able to mark @@ -89,6 +97,12 @@ non-initial contact (needsMessage), you can also use that one by setting that ba Combined, these extensions allow 'split' assignment -- a different group to send initial texts than to handle replies. +### texter-feedback + +Must be enabled for Admins to be able to review contacts' replies and give feedback. To customize the +variables feedback is given dump valid JSON blob based on this [Working Families Party example](https://github.com/MoveOnOrg/Spoke/blob/stage-main-10-c/src/extensions/texter-sideboxes/texter-feedback/config-wfp-example.json) +into the custom field. Note that invalid JSON will break Spoke! + ## Texter Sidebox configuration options diff --git a/docs/HOWTO_TEXTING_HOURS_ENFORCEMENT.md b/docs/HOWTO_TEXTING_HOURS_ENFORCEMENT.md index 0ac9998f0..bde6615f2 100644 --- a/docs/HOWTO_TEXTING_HOURS_ENFORCEMENT.md +++ b/docs/HOWTO_TEXTING_HOURS_ENFORCEMENT.md @@ -22,7 +22,7 @@ Spoke will not send texts to contacts before the start time or after the end tim If the `TZ` environment variable is set, Spoke will assume that all contacts are located in the time zone specified by the variable. The current time in that time zone -- with Daylight Savings applied if it is summer in that area and the time zone has Daylight Savings Time -- is considered the current time for purposes of deciding whether it is OK to send texts. -The timezone in New York City is specified by the string `America/New_York`. Other time zone names are listed [here.](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) +The timezone in New York City is specified by the string `US/Eastern`. Other time zone names are listed [here.](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) ## Contact ZIP code diff --git a/docs/REFERENCE-best-practices-conformance-messaging.md b/docs/REFERENCE-best-practices-conformance-messaging.md index e7bdd475b..df390ef5c 100644 --- a/docs/REFERENCE-best-practices-conformance-messaging.md +++ b/docs/REFERENCE-best-practices-conformance-messaging.md @@ -22,7 +22,7 @@ This document is meant to detail the default configurations and how as a system ## One click or keypress per-message -Spoke’s default requires the texter to make a single click or key-press for each message sent. Each time a texter presses a key or clicks their mouse button from their queued list of contacts, it will send another message. Spoke cannot send a message unless an individual clicks or presses a key. +Spoke’s default requires the texter to make a single click or key-press, one message at a time, for each message sent. Each time a texter presses a key or clicks their mouse button from their queued list of contacts, it will send another message. Spoke cannot send a message unless an individual clicks or presses a key. ### Use cases for sending fewer messages: diff --git a/docs/REFERENCE-environment_variables.md b/docs/REFERENCE-environment_variables.md index a747c8384..a11d5f04e 100644 --- a/docs/REFERENCE-environment_variables.md +++ b/docs/REFERENCE-environment_variables.md @@ -27,14 +27,16 @@ | DB_TYPE | Database connection type for [Knex](http://knexjs.org/#Installation-client). _Options_: mysql, pg, sqlite3. _Default_: sqlite3. | | DB_USE_SSL | Boolean value to determine whether database connections should use SSL. _Default_: false. | | DEBUG_SCALING | Emit console.log on events related to scaling issues. _Default_: false. | -| DEFAULT_SERVICE | Default SMS service. _Options_: twilio, nexmo, fakeservice. | | DEFAULT_ORG | Set only with FIX_ORGLESS. Set to integer organization.id corresponding to the organization you want orgless users to be assigned to. | +| DEFAULT_SERVICE | Default SMS service. _Options_: twilio, nexmo, fakeservice. | +| DEPRECATED_TEXTERUI | For a limited time, you can set DPRECATED_TEXTERUI=`GONE_SOON` to restore the old texter ui. This will be removed in an upcoming release, but is meant to give you time to update training materials and fallback, in case a major regression is found. You can also just add `?old=1` to the end of the url. | +| DEFAULT_TZ | Default timezone region for determining timezone when a contact timezone is unavailable | | DEFAULT_RESPONSEWINDOW | Default number of hours after when a campaign's contacts that need a response (after they reply) -- this is changeable per-campaign, but this sets the default. | | DEV_APP_PORT | Port for development Webpack server. Required for development. | | DOWNTIME | When enabled it will redirect users to a /downtime page. If set to a string, it will show the string as a message to users on the downtime page. Use this to take the system down for maintenance. It will NOT stop graphql requests and will NOT stop users that are already in the app. | | DOWNTIME_NO_DB | On AWS Lambda this blocks the site from loading the app at all and swaps out a system that redirects users to /downtime. This is useful for DB maintenance. For non-Lambda environments, just run the src/server/downtime app instead of src/server/index default app | | DOWNTIME_TEXTER | Setting DOWNTIME_TEXTER to a text message (without quotes, please) will give the message as a text to texters when they arrive on the site, but the admin pages will still be accessible. This could be useful if you want to stop new texters from landing on the site and texting, while you debug things. | -| DST_REFERENCE_TIMEZONE | Timezone to use to determine whether DST is in effect. If it's DST in this timezone, we assume it's DST everywhere. _Default_: "America/New_York". (The default will work for any campaign in the US. For example, if the campaign is in Australia, use "Australia/Sydney" or some other timezone in Australia. Note that DST is opposite in the northern and souther hemispheres.) | +| DST_REFERENCE_TIMEZONE | Timezone to use to determine whether DST is in effect. If it's DST in this timezone, we assume it's DST everywhere. _Default_: "US/Eastern". (The default will work for any campaign in the US. For example, if the campaign is in Australia, use "Australia/Sydney" or some other timezone in Australia. Note that DST is opposite in the northern and souther hemispheres.) | | EMAIL_FROM | Email from address. _Required to send email from either Mailgun **or** a custom SMTP server_. | | EMAIL_HOST | Email server host. _Required for custom SMTP server usage_. | | EMAIL_HOST_PASSWORD | Email server password. _Required for custom SMTP server usage_. | @@ -56,6 +58,8 @@ | MAX_CONTACTS | If set each campaign can only have a maximum of the value (an integer). This is good for staging/QA/evaluation instances. _Default_: false (i.e. there is no maximum) | | MAX_CONTACTS_PER_TEXTER | Maximum contacts that a texter can send to, per campaign. This is particularly useful for dynamic assignment. This must not be blank/empty and must be a number greater than 0. | | MAX_MESSAGE_LENGTH | The maximum size for a message that a texter can send. When you send a SMS message over 160 characters the message will be split, so you might want to set this as 160 or less if you have a high SMS-only target demographic. _Default_: 99999 | +| MAX_TEXTERS_PER_CAMPAIGN | Maximum texters that can join a campaign before joining with a dynamic assignment campaign link will block the texter from joining with a message that the campaign is full. | +| MOBILIZE_EVENT_SHIFTER_URL | For the texter sidebox, mobilize-event-shifter. This should be the base mobilize link for the organization, i.e. https://www.mobilize.us/{org_name}. Can be overridden in the campaign/organization admin settings. | | MAX_TEXTERS_PER_CAMPAIGN | Maximum texters that can join a campaign before joining with a dynamic assignment campaign link will block the texter from joining with a message that the campaign is full. | | MULTI_TENANT | Set to true if instance can host more than one organization. | | NEXMO_API_KEY | Nexmo API key. Required if using Nexmo. | @@ -78,7 +82,7 @@ | PEOPLE_PAGE_CAMPAIGN_FILTER_SORT | The order in which to display the campaigns in the campaigns filter in **People**. Optional. If set, it must be one of `DUE_DATE_ASC`, `DUE_DATE_DESC`, `ID_ASC`, `ID_DESC`, `TITLE`. _Default_: `ID_ASC`. | | PEOPLE_PAGE_ROW_SIZES | The list of options for the number of people to show on each page in **People**. If set this must be an array of integers. The numbers in the array do not need to be sorted. The first number in the array will be the default page size. _Default_: [100, 200, 500, 1000]. | | PGSSLMODE | Postgres SSL mode. Due to a [Knex bug](https://github.com/tgriesser/knex/issues/852), this environment variable must be used in order to specify the SSL mode directly in the driver. This must be set to `PGSSLMODE=require` to work with Heroku databases above the free tier (see [Heroku Postgres & SSL](https://devcenter.heroku.com/articles/heroku-postgresql#heroku-postgres-ssl)). | -| PHONE_NUMBER_COUNTRY | Country code for phone number formatting. _Default_: US. | +| PHONE_NUMBER_COUNTRY | Country code for phone number formatting. Does _not_ default to US. If left blank, phone numbers you upload must have a country code. | | PORT | Port for Heroku servers. | | REDIS_URL | This enables caching using the [`url` option in redis library](https://github.com/NodeRedis/node_redis#options-object-properties). This is an area of active development. More can be seen at [server/models/cacheable_queries/README](../src/server/models/cacheable_queries/README.md) and the [project board](https://github.com/MoveOnOrg/Spoke/projects/4) | | REVERE_SQS_URL | SQS URL to process outgoing Revere SMS Messages. | diff --git a/docs/REFERENCE-shortcut-rules.md b/docs/REFERENCE-shortcut-rules.md index 3209c1c40..47f18088b 100644 --- a/docs/REFERENCE-shortcut-rules.md +++ b/docs/REFERENCE-shortcut-rules.md @@ -1,13 +1,29 @@ -# Texter ShortCuts and when they are displayed +# Texter ShortCuts---Button Labels and When They Are Displayed -On the texter conversation screen, the UI creates "shortcut buttons" for some question responses -and canned replies. +On the texter conversation screen, the UI creates "shortcut buttons" for some question responses and canned replies. -Currently there is no UI to choose the shortcuts or enable/disable them or preview their visibility, -however we hope to do that in the future. +Currently there is no UI to choose the shortcuts or enable/disable them or preview their visibility (the hope is that one will exist in the future). In the meantime, there is a set of rules around character count and punctuation usage that cause shortcut buttons to be shown automatically based on the way the titles of responses are crafted. + +The rules assume that buttons will have short labels and that there won't be too many buttons. The general idea is that a texter using a mobile device will see some shortcut buttons and still be able to read the conversation. + +## Illustration +![](https://i.imgur.com/rlTEhos.png) +*Above: Some survey responses show up as shortcuts because they are under 36 characters (Yes, No, Undecided, Already Voted + 2 spaces for each = 28+2+2+2+2 = 36). After the survey responses some Other Responses are displayed (Empty text, Who are you?, Wrong Number).* + +![](https://i.imgur.com/lPZKFNG.png) +*Above: The corresponding "All Responses" menu may show more survey responses and way more Other Responses. Note how "Another Option" was excluded because of the "-" in front of it. If the "-" hadn't been used the entire set of shortcuts would have disappeared because the character count would have been too high and the rules don't pick and choose for you.* + +## TL;DR +Use short labels or strategically use punctuation to get the labels of responses short enough that shortcut buttons will appear. + +Survey responses can be **excluded** by starting with a "-" + +Other responses can be **included** by starting with a "+" + +Adding a punctuation mark after the first word in a response label will truncate that label to just one word (e.g. "**Yes,** you can count on me" -> "**Yes**") + +Tip: Use the staging server and open two tabs to test out a set of responses and see what will work. In one tab edit your interactions and canned responses. In the other tab, keep the responding ui open and refresh. This is the best way to experiment quickly and learn the rules either by trial and error or by reading below and trying out each permutation. -In the meantime we document the rules of when they are shown automatically. These rules are meant -to balance real-estate on the screen while avoiding confusion for the texter in some situations. ## Terminology diff --git a/docs/RELEASE_NOTES.md b/docs/RELEASE_NOTES.md index 7629615b4..33b1544b8 100644 --- a/docs/RELEASE_NOTES.md +++ b/docs/RELEASE_NOTES.md @@ -178,7 +178,7 @@ This is a major release and therefore requires a schema change which you can run - There is a small migration to the `campaign` table which needs to be run before/during migration (either by leaving/disabling SUPPRESS_MIGRATIONS="" or for [AWS Lambda, see the db migration instructions](https://github.com/MoveOnOrg/Spoke/blob/main/docs/DEPLOYING_AWS_LAMBDA.md#migrating-the-database) ### New Features/Improvements -- Use of Spoke is subject to legal restrictions which each organization should review and understand, including recent guidance from an FCC ruling. Spoke 8.0 has several changes related to this guidance and we recommend system administrators review the settings outlined [here](https://github.com/MoveOnOrg/Spoke/blob/main/docs/REFERENCE-best-practices-conformance-messaging.md) along with consulting your own legal advice. +- Use of Spoke is subject to legal restrictions which each organization should review and understand, including recent guidance from an FCC ruling. We recommend system administrators review the settings outlined [here](https://github.com/MoveOnOrg/Spoke/blob/main/docs/REFERENCE-best-practices-conformance-messaging.md) along with consulting your own legal advice. - **_Experimental_ Phone number management for campaigns**: A much requested feature for scaling past the 400 phone numbers limit. - turn this on with `EXPERIMENTAL_CAMPAIGN_NUMBERS` - **_Experimental_ Release Texts**: Dynamic Assignment will also include a way for texters to release texts! That way when a texter is done for the day they can release texts without admin needing to go in and reassign them. diff --git a/jest.config.e2e.js b/jest.config.e2e.js deleted file mode 100644 index edaae8e03..000000000 --- a/jest.config.e2e.js +++ /dev/null @@ -1,25 +0,0 @@ -const _ = require("lodash"); -const config = require("./jest.config"); - -const overrides = { - setupTestFrameworkScriptFile: "/__test__/e2e/util/setup.js", - testMatch: ["**/__test__/e2e/**/*.test.js"], - testPathIgnorePatterns: [ - "/node_modules/", - "/__test__/e2e/util/", - "/__test__/e2e/pom/" - ], - bail: true // To learn about errors sooner -}; -const merges = { - // Merge in changes to deeper objects - globals: { - // This sets the BASE_URL for the target of the e2e tests (what the tests are testing) - BASE_URL: "localhost:3000" - } -}; - -module.exports = _.chain(config) - .assign(overrides) - .merge(merges) - .value(); diff --git a/jest.config.js b/jest.config.js index 03a1e4228..d95f42db7 100644 --- a/jest.config.js +++ b/jest.config.js @@ -19,7 +19,7 @@ module.exports = { JOBS_SAME_PROCESS: "1", RETHINK_KNEX_NOREFS: "1", // avoids db race conditions DEFAULT_SERVICE: "fakeservice", - DST_REFERENCE_TIMEZONE: "America/New_York", + DST_REFERENCE_TIMEZONE: "US/Eastern", DATABASE_SETUP_TEARDOWN_TIMEOUT: 60000, PASSPORT_STRATEGY: "local", SESSION_SECRET: "it is JUST a test! -- it better be!", @@ -49,7 +49,6 @@ module.exports = { setupTestFrameworkScriptFile: "/__test__/setup.js", testPathIgnorePatterns: [ "/node_modules/", - "/__test__/cypress/", - "/__test__/e2e/" + "/__test__/cypress/" ] }; diff --git a/migrations/20201001164842_message_media.js b/migrations/20201001164842_message_media.js new file mode 100644 index 000000000..4c6b276e0 --- /dev/null +++ b/migrations/20201001164842_message_media.js @@ -0,0 +1,11 @@ +exports.up = knex => { + return knex.schema.table("message", table => { + table.jsonb("media").defaultTo(null); + }); +}; + +exports.down = knex => { + return knex.schema.table("message", table => { + table.dropColumn("media"); + }); +}; diff --git a/migrations/20201010173732_add_assignment_feedback.js b/migrations/20201010173732_add_assignment_feedback.js index ef589c559..e3758ae6e 100644 --- a/migrations/20201010173732_add_assignment_feedback.js +++ b/migrations/20201010173732_add_assignment_feedback.js @@ -1,11 +1,28 @@ -exports.up = knex => { - return knex.schema.table("assignment", table => { - table.text("feedback").defaultTo(null); +exports.up = async knex => { + await knex.schema.createTable("assignment_feedback", table => { + table.increments(); + table + .integer("assignment_id") + .notNullable() + .references("id") + .inTable("assignment"); + table + .integer("creator_id") + .nullable() + .references("id") + .inTable("user"); + table.timestamp("created_at").defaultTo(knex.fn.now()); + table + .jsonb("feedback") + .nullable() + .defaultTo(null); + table.boolean("is_acknowledged").defaultTo(false); + table.boolean("complete").defaultTo(false); + + table.unique("assignment_id"); }); }; -exports.down = knex => { - return knex.schema.table("assignment", table => { - table.dropColumn("feedback"); - }); +exports.down = async knex => { + await knex.schema.dropTable("assignment_feedback"); }; diff --git a/package.json b/package.json index 122deabd2..ea84eaa23 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "test-cache": "REDIS_FAKE=1 jest --runInBand --forceExit --detectOpenHandles", "test-rediscache": "REDIS_URL=redis://localhost:6379 CACHE_PREFIX=test jest --runInBand --forceExit --detectOpenHandles", "test-rediscache-contactcache": "REDIS_URL=redis://localhost:6379 CACHE_PREFIX=test REDIS_CONTACT_CACHE=1 jest --runInBand --forceExit --detectOpenHandles", - "test-e2e": "jest --runInBand --config jest.config.e2e.js", + "test-cypress": "NODE_ENV=test DB_TYPE=pg DEFAULT_SERVICE=fakeservice SESSION_SECRET=secret DB_NAME=spoke_test DB_USER=spoke_test DB_PASSWORD=spoke_test cypress open", "test-sqlite": "jest --config jest.config.sqlite.js --runInBand --forceExit --detectOpenHandles", "test-coverage": "jest --coverage --detectOpenHandles --runInBand", "test-coverage-bothbackends": "jest --runInBand --config jest.config.sqlite.js -- __test__/backend.test.js && jest --coverage", @@ -29,6 +29,8 @@ "install-config-file": "if [ \"$CONFIG_FILE\" != \"\" ] ; then cp $CONFIG_FILE ./CONFIG_FILE.json; fi", "start": "node ./build/server/server", "start:heroku": "BASE_URL=$(node ./build/server/heroku/print-base-url) npm start", + "start:test": "nf start -w --env ./dev-tools/.env.test --procfile ./dev-tools/Procfile.test", + "start:test-babel": "nodemon --signal SIGTERM -e js,jsx,md,mustache -w ./src --exec ./dev-tools/babel-run -- ./src/server", "dev-heroku-dispatch": "nodemon --signal SIGTERM -e js,jsx -w ./src --exec ./dev-tools/babel-run-with-env.js -- ./src/heroku/heroku-dispatch", "prod-heroku-dispatch": "node ./build/server/heroku/heroku-dispatch.js", "dev-message-sender-01": "nodemon --signal SIGTERM -e js,jsx -w ./src --exec ./dev-tools/babel-run -- ./src/workers/message-sender-01", @@ -49,7 +51,6 @@ "generate-contacts": "./dev-tools/babel-run ./dev-tools/generate-contacts.js", "jest-test": "jest __test__/backend.test.js", "dev": "nf start -w --procfile ./dev-tools/Procfile.dev", - "dev-nowrap": "nf start --trim 500 --procfile ./dev-tools/Procfile.dev --env ./__test__/e2e/.env.e2e", "debug-server": "nf start -w --procfile ./dev-tools/Procfile-debug-server.dev", "buy-numbers": "./dev-tools/babel-run-with-env.js ./dev-tools/buy-numbers.js" }, @@ -69,7 +70,7 @@ "field" ], "author": "Axle Factory", - "license": "MIT", + "license": "GPL-3.0 with section 7 additions, see LICENSE", "bugs": { "url": "https://github.com/MoveOnOrg/Spoke/issues" }, @@ -85,7 +86,7 @@ "apollo-link-http": "^1.5.17", "apollo-server-express": "^1.2.0", "apollo-utilities": "^1.3.4", - "auth0-js": "^9.10.0", + "auth0-js": "^9.14.1", "aws-sdk": "^2.6.3", "aws-serverless-express": "^3.3.6", "babel-cli": "^6.26.0", @@ -140,7 +141,8 @@ "passport-auth0": "^0.6.1", "passport-local": "^1.0.0", "passport-local-authenticate": "^1.2.0", - "pg": "^6.4.2", + "pg": "^8.0.3", + "pg-connection-string": "^2.4.0", "pg-query-stream": "^1.1.1", "prop-types": "^15.6.0", "query-string": "^4.1.0", @@ -175,8 +177,9 @@ "babel-jest": "^23.4.2", "babel-plugin-transform-runtime": "^6.23.0", "babel-preset-es2017": "^6.24.1", - "cypress": "^4.4.1", + "cypress": "5.6.0", "cypress-file-upload": "^4.0.6", + "cypress-wait-until": "^1.7.1", "enzyme": "^3.3.0", "enzyme-adapter-react-15": "^1.0.5", "eslint": "2.13.1", @@ -198,7 +201,6 @@ "react-scripts": "^2.1.3", "react-test-renderer": "15", "regenerator-runtime": "^0.10.5", - "saucelabs": "^1.5.0", "selenium-webdriver": "^3.6.0", "sqlite3": "^4.1.1", "wait-on": "^2.1.0", diff --git a/src/api/campaign.js b/src/api/campaign.js index e7cac2179..43faf016b 100644 --- a/src/api/campaign.js +++ b/src/api/campaign.js @@ -80,6 +80,12 @@ export const schema = gql` count: Int! } + type CampaignExportData { + error: String + campaignExportUrl: String + campaignMessagesExportUrl: String + } + type Campaign { id: ID organization: Organization @@ -109,6 +115,7 @@ export const schema = gql` stats: CampaignStats completionStats: CampaignCompletionStats pendingJobs: [JobRequest] + exportResults: CampaignExportData ingestMethodsAvailable: [IngestMethod] ingestMethod: IngestMethod useDynamicAssignment: Boolean diff --git a/src/api/message.js b/src/api/message.js index a037e5510..dcf059093 100644 --- a/src/api/message.js +++ b/src/api/message.js @@ -1,7 +1,13 @@ export const schema = ` + type MediaItem { + type: String + url: String + } + type Message { id: ID text: String + media: [MediaItem] userNumber: String contactNumber: String createdAt: Date diff --git a/src/api/organization.js b/src/api/organization.js index daea28c1e..225d00c75 100644 --- a/src/api/organization.js +++ b/src/api/organization.js @@ -77,6 +77,7 @@ export const schema = gql` twilioAuthToken: String twilioMessageServiceSid: String fullyConfigured: Boolean + emailEnabled: Boolean phoneInventoryEnabled: Boolean! campaignPhoneNumbersEnabled: Boolean! pendingPhoneNumberJobs: [BuyPhoneNumbersJobRequest] diff --git a/src/api/schema.js b/src/api/schema.js index 7183812a5..161ccb251 100644 --- a/src/api/schema.js +++ b/src/api/schema.js @@ -148,7 +148,7 @@ const rootSchema = gql` type CampaignIdAssignmentId { campaignId: String! - assignmentId: String! + assignmentId: String } input TagInput { @@ -263,6 +263,7 @@ const rootSchema = gql` campaignId: String queryParams: String ): Organization + resetOrganizationJoinLink(organizationId: String!): Organization editOrganizationRoles( organizationId: String! userId: String! @@ -310,7 +311,11 @@ const rootSchema = gql` interactionStepIds: [String] campaignContactId: String! ): CampaignContact - updateFeedback(assignmentId: String!, feedback: String): Assignment + updateFeedback( + assignmentId: String! + feedback: JSON + acknowledge: Boolean + ): Assignment updateContactTags( tags: [ContactTagInput] campaignContactId: String! @@ -362,6 +367,7 @@ const rootSchema = gql` limit: Int! addToOrganizationMessagingService: Boolean ): JobRequest + deletePhoneNumbers(organizationId: ID!, areaCode: String!): JobRequest releaseCampaignNumbers(campaignId: ID!): Campaign! clearCachedOrgAndExtensionCaches(organizationId: String!): String } diff --git a/src/components/AdminCampaignList/CampaignTable.jsx b/src/components/AdminCampaignList/CampaignTable.jsx index e85e59132..74f930793 100644 --- a/src/components/AdminCampaignList/CampaignTable.jsx +++ b/src/components/AdminCampaignList/CampaignTable.jsx @@ -168,6 +168,13 @@ export class CampaignTable extends React.Component { sortable: true, style: { width: "5em" + }, + render: (columnKey, campaign) => { + let org = ""; + if (this.props.organizationId != campaign.organization.id) { + org = ` (${campaign.organization.id})`; + } + return `${campaign.id}${org}`; } }, ...timezoneColumn, @@ -197,7 +204,7 @@ export class CampaignTable extends React.Component { ...inlineStyles.campaignLink, ...linkStyle }} - to={`/admin/${this.props.organizationId}/campaigns/${campaign.id}${editLink}`} + to={`/admin/${campaign.organization.id}/campaigns/${campaign.id}${editLink}`} > {campaign.title} @@ -248,16 +255,14 @@ export class CampaignTable extends React.Component { render: (columnKey, row) => organization.cacheable > 1 && row.completionStats.assignedCount !== null ? ( - + {row.completionStats.contactsCount - row.completionStats.assignedCount} ) : row.hasUnassignedContacts ? ( diff --git a/src/components/AdminDashboard.jsx b/src/components/AdminDashboard.jsx index 21b2d809a..3abe585e4 100644 --- a/src/components/AdminDashboard.jsx +++ b/src/components/AdminDashboard.jsx @@ -66,6 +66,7 @@ class AdminDashboard extends React.Component { // HACK: Setting params.adminPerms helps us hide non-supervolunteer functionality params.adminPerms = hasRole("ADMIN", roles || []); + params.ownerPerms = hasRole("OWNER", roles || []); let sections = [ { @@ -96,7 +97,7 @@ class AdminDashboard extends React.Component { { name: "Phone Numbers", path: "phone-numbers", - role: "OWNER" + role: "ADMIN" } ]; diff --git a/src/components/AssignmentSummary.jsx b/src/components/AssignmentSummary.jsx index b4a66988d..5f01b439a 100644 --- a/src/components/AssignmentSummary.jsx +++ b/src/components/AssignmentSummary.jsx @@ -8,7 +8,7 @@ import Badge from "material-ui/Badge"; import Divider from "material-ui/Divider"; import { withRouter } from "react-router"; import { dataTest } from "../lib/attributes"; -import AssignmentTexterFeedback from "./AssignmentTexterFeedback"; +import AssignmentTexterFeedback from "../extensions/texter-sideboxes/texter-feedback/AssignmentTexterFeedback"; import { getSideboxes, @@ -57,8 +57,10 @@ export class AssignmentSummary extends Component { }; goToTodos(contactsFilter, assignmentId) { - const { organizationId, router } = this.props; - + const { organizationId, router, todoLink } = this.props; + if (todoLink) { + return todoLink(contactsFilter, assignmentId, router); + } if (contactsFilter) { router.push( `/app/${organizationId}/todos/${assignmentId}/${contactsFilter}` @@ -135,31 +137,25 @@ export class AssignmentSummary extends Component { ); const sideboxProps = { assignment, campaign, texter, settingsData }; const enabledSideboxes = getSideboxes(sideboxProps, "TexterTodoList"); - const sideboxList = enabledSideboxes.map(sb => - renderSummary(sb, settingsData, this, sideboxProps) - ); + // if there's a sidebox marked popup, then we will only show that sidebox and little else + const hasPopupSidebox = enabledSideboxes.popups.length; + const sideboxList = enabledSideboxes + .filter(sb => + hasPopupSidebox ? sb.name === enabledSideboxes.popups[0] : true + ) + .map(sb => renderSummary(sb, settingsData, this, sideboxProps)); const cardTitleTextColor = setContrastingColor(primaryColor); - const hasFeedbackToAcknowledge = - feedback && - feedback.message && - feedback.sweepComplete && - feedback.isAcknowledged === false; - // NOTE: we bring back archived campaigns if they have feedback // but want to get rid of them once feedback is acknowledged - if (campaign.isArchived && !hasFeedbackToAcknowledge) return null; + if (campaign.isArchived && !hasPopupSidebox) return null; return (
- + ) : null} - {hasFeedbackToAcknowledge && ( - - )} - {(window.NOT_IN_USA && window.ALLOW_SEND_ALL) || - hasFeedbackToAcknowledge + {hasPopupSidebox && sideboxList} + + {(window.NOT_IN_USA && window.ALLOW_SEND_ALL) || hasPopupSidebox ? "" : this.renderBadgedButton({ dataTestText: "sendFirstTexts", @@ -202,8 +193,7 @@ export class AssignmentSummary extends Component { contactsFilter: "text", hideIfZero: true })} - {(window.NOT_IN_USA && window.ALLOW_SEND_ALL) || - hasFeedbackToAcknowledge + {(window.NOT_IN_USA && window.ALLOW_SEND_ALL) || hasPopupSidebox ? "" : this.renderBadgedButton({ dataTestText: "Respond", @@ -235,9 +225,7 @@ export class AssignmentSummary extends Component { contactsFilter: "skipped", hideIfZero: true })} - {window.NOT_IN_USA && - window.ALLOW_SEND_ALL && - !hasFeedbackToAcknowledge + {window.NOT_IN_USA && window.ALLOW_SEND_ALL && !hasPopupSidebox ? this.renderBadgedButton({ assignment, title: "Send messages", @@ -257,7 +245,7 @@ export class AssignmentSummary extends Component { contactsFilter: null, hideIfZero: true })} - {sideboxList.length && !hasFeedbackToAcknowledge ? ( + {sideboxList.length && !hasPopupSidebox ? (
{sideboxList}
@@ -283,7 +271,8 @@ AssignmentSummary.propTypes = { router: PropTypes.object, assignment: PropTypes.object, texter: PropTypes.object, - refreshData: PropTypes.func + refreshData: PropTypes.func, + todoLink: PropTypes.func }; export default withRouter(AssignmentSummary); diff --git a/src/components/AssignmentTexter/ContactController.jsx b/src/components/AssignmentTexter/ContactController.jsx index 14b3f2516..5336e9084 100644 --- a/src/components/AssignmentTexter/ContactController.jsx +++ b/src/components/AssignmentTexter/ContactController.jsx @@ -284,6 +284,8 @@ export class ContactController extends React.Component { this.setState({ finishedContactId: contactId }, () => { if (!this.props.reviewContactId) { this.props.refreshData(); + this.clearContactIdOldData(contactId); + this.updateCurrentContactIndex(this.state.currentContactIndex); } }); } diff --git a/src/components/AssignmentTexter/Controls.jsx b/src/components/AssignmentTexter/Controls.jsx index 665c22a7a..3205a2c30 100644 --- a/src/components/AssignmentTexter/Controls.jsx +++ b/src/components/AssignmentTexter/Controls.jsx @@ -19,7 +19,9 @@ import yup from "yup"; import theme from "../../styles/theme"; import Form from "react-formal"; import Popover from "material-ui/Popover"; +import SearchBar from "material-ui-search-bar"; import { messageListStyles, inlineStyles, flexStyles } from "./StyleControls"; +import { searchFor } from "../../lib/search-helpers"; import { renderSidebox } from "../../extensions/texter-sideboxes/components"; @@ -48,8 +50,17 @@ export class AssignmentTexterContactControls extends React.Component { questionResponses, props.campaign.interactionSteps ); + + let currentInteractionStep = null; + if (availableSteps.length > 0) { + currentInteractionStep = availableSteps[availableSteps.length - 1]; + currentInteractionStep.question.filteredAnswerOptions = + currentInteractionStep.question.answerOptions; + } + this.state = { questionResponses, + filteredCannedResponses: props.campaign.cannedResponses, optOutMessageText: props.campaign.organization.optOutMessage, responsePopoverOpen: false, answerPopoverOpen: false, @@ -62,10 +73,8 @@ export class AssignmentTexterContactControls extends React.Component { messageFocus: false, availableSteps, messageReadOnly: false, - currentInteractionStep: - availableSteps.length > 0 - ? availableSteps[availableSteps.length - 1] - : null + hideMedia: false, + currentInteractionStep }; } @@ -159,7 +168,7 @@ export class AssignmentTexterContactControls extends React.Component { // console.log('KEYBOARD', evt.key, document.activeElement); if ( // SEND: Ctrl-Enter/Ctrl-z - (evt.key === "Enter" || evt.key === "z") && + evt.key === "Enter" && // need to use ctrlKey in non-first texting context for accessibility evt.ctrlKey ) { @@ -228,6 +237,28 @@ export class AssignmentTexterContactControls extends React.Component { } }; + handleSearchChange = searchValue => { + // filter answerOptions for this step's question + const answerOptions = this.state.currentInteractionStep.question + .answerOptions; + const filteredAnswerOptions = searchFor(searchValue, answerOptions, [ + "value", + "nextInteractionStep.script" + ]); + this.state.currentInteractionStep.question.filteredAnswerOptions = filteredAnswerOptions; + + const filteredCannedResponses = searchFor( + searchValue, + this.props.campaign.cannedResponses, + ["title", "text"] + ); + + this.setState({ + currentInteractionStep: this.state.currentInteractionStep, + filteredCannedResponses + }); + }; + handleCannedResponseChange = cannedResponseScript => { const currentCannedResponseId = this.state.cannedResponseScript && this.state.cannedResponseScript.id; @@ -312,29 +343,27 @@ export class AssignmentTexterContactControls extends React.Component { handleMessageFormChange = ({ messageText }) => this.setState({ messageText }); - handleOpenAnswerPopover = event => { + handleOpenAnswerResponsePopover = event => { event.preventDefault(); - this.setState({ + const newState = { answerPopoverAnchorEl: event.currentTarget, - answerPopoverOpen: true - }); + answerPopoverOpen: true, + responsePopoverAnchorEl: event.currentTarget, + responsePopoverOpen: true, + filteredCannedResponses: this.props.campaign.cannedResponses + }; + if (this.state.currentInteractionStep) { + this.state.currentInteractionStep.question.filteredAnswerOptions = this.state.currentInteractionStep.question.answerOptions; + newState.currentInteractionStep = this.state.currentInteractionStep; + } + this.setState(newState); }; - handleCloseAnswerPopover = () => { + handleCloseAnswerResponsePopover = () => { this.setState({ answerPopoverOpen: false }); - }; - - handleOpenResponsePopover = event => { - event.preventDefault(); - this.setState({ - responsePopoverAnchorEl: event.currentTarget, - responsePopoverOpen: true - }); - }; - handleCloseResponsePopover = () => { // delay to avoid accidental tap pass-through with focusing on // the text field -- this is annoying on mobile where the keyboard // pops up, inadvertantly @@ -395,7 +424,9 @@ export class AssignmentTexterContactControls extends React.Component { const { answerPopoverOpen, questionResponses, - cannedResponseScript + cannedResponseScript, + currentInteractionStep, + filteredCannedResponses } = this.state; const { messages } = contact; @@ -405,9 +436,9 @@ export class AssignmentTexterContactControls extends React.Component { ); const otherResponsesLink = - this.state.currentInteractionStep && - this.state.currentInteractionStep.question.answerOptions.length > 6 && - campaign.cannedResponses.length ? ( + currentInteractionStep && + currentInteractionStep.question.filteredAnswerOptions.length > 6 && + filteredCannedResponses.length ? (
) : null; + const searchBar = + currentInteractionStep && + currentInteractionStep.question.answerOptions.length + + campaign.cannedResponses.length > + 5 ? ( + + ) : null; + return ( + {searchBar} } role="button" - onClick={!disabled ? this.handleOpenAnswerPopover : noAction => {}} + onClick={ + !disabled ? this.handleOpenAnswerResponsePopover : noAction => {} + } className={css(flexStyles.flatButton)} labelStyle={inlineStyles.flatButtonLabel} backgroundColor={ @@ -937,7 +984,7 @@ export class AssignmentTexterContactControls extends React.Component { ); } - // TODO: max height and scroll-y + // TODO: max height return
{sideboxList}
; } @@ -996,6 +1043,7 @@ export class AssignmentTexterContactControls extends React.Component { organizationId={this.props.organizationId} review={this.props.review} styles={messageListStyles} + hideMedia={this.state.hideMedia} />, enabledSideboxes ), diff --git a/src/components/AssignmentTexter/Demo.jsx b/src/components/AssignmentTexter/Demo.jsx index 418d6d937..4d674ef78 100644 --- a/src/components/AssignmentTexter/Demo.jsx +++ b/src/components/AssignmentTexter/Demo.jsx @@ -529,19 +529,144 @@ export const tests = testName => { hasUnassignedContactsForTexter: 200 }, texter: { + id: 123, firstName: "Carlos", lastName: "Tlastname" + }, + currentUser: { + id: 123, + roles: ["SUSPENDED", "TEXTER", "VETTED_TEXTER"] } - } + }, // other tests: // c: current question response is deeper in the state // d: no questions at all // e: opted out + todos1: { + organizationId: "fake", + texter: { + id: 123, + profileComplete: true, + terms: true, + roles: ["SUSPENDED", "TEXTER"] + }, + assignment: { + id: "fakeassignment", + hasUnassignedContactsForTexter: true, + allContactsCount: 100, + unmessagedCount: 10, + unrepliedCount: 5, + badTimezoneCount: 20, + totalMessagedCount: 5, + pastMessagesCount: 5, + skippedMessagesCount: 5, + campaign: { + id: "fakecampaign", + title: "Fake Campaign", + description: "Will save the world", + batchSize: 100, + useDynamicAssignment: true, + introHtml: null, + primaryColor: null, + texterUIConfig: { + options: JSON.stringify(sideboxOptions), + sideboxChoices + }, + organization: { + id: "fake" + } + } + }, + refreshData: () => { + console.log("Summary refresh triggered"); + }, + todoLink: (contactsFilter, aId, router) => { + console.log("todoLink", contactsFilter, aId); + if (contactsFilter === "text") { + router.push("/demo/text"); + } else { + router.push("/demo/reply"); + } + } + }, + todos2: { + organizationId: "fake", + texter: { + id: 123, + profileComplete: true, + terms: true, + roles: ["SUSPENDED", "TEXTER"] + }, + assignment: { + id: "fakeassignment", + hasUnassignedContactsForTexter: false, + allContactsCount: 100, + unmessagedCount: 10, + unrepliedCount: 5, + badTimezoneCount: 20, + totalMessagedCount: 5, + pastMessagesCount: 5, + skippedMessagesCount: 5, + campaign: { + id: "fakecampaign", + title: "Fake Campaign", + description: "Will save the world", + batchSize: 100, + useDynamicAssignment: true, + introHtml: null, + primaryColor: null, + texterUIConfig: { + options: JSON.stringify(sideboxOptions), + sideboxChoices + }, + organization: { + id: "fake" + } + }, + feedback: { + isAcknowledged: false, + createdBy: { + name: "Mx Reviewer" + }, + message: + "You did so well! Note this is a demo and issues, skills and messaging are customizable, and this would be a final message written by the reviewer.", + issueCounts: { + optOut: 1, + tagging: 1, + response: 1, + scriptEdit: 1, + engagement: 1 + }, + skillCounts: { + extraOptOut: 1, + jumpAhead: 1, + multiMessage: 1, + composing: 1 + }, + sweepComplete: true + } + }, + refreshData: () => { + console.log("Summary refresh triggered"); + }, + todoLink: (contactsFilter, aId, router) => { + console.log("todoLink", contactsFilter, aId); + if (contactsFilter === "text") { + router.push("/demo/text"); + } else { + router.push("/demo/reply"); + } + } + } }; return testData[testName]; }; +export const assignmentSummaryTestProps = { + summaryA: {} +}; + export function generateDemoTexterContact(testName) { const test = tests(testName); const DemoAssignmentTexterContact = function(props) { @@ -566,6 +691,7 @@ export function generateDemoTexterContact(testName) { campaign={test.assignment.campaign} texter={test.texter} assignment={test.assignment} + currentUser={test.currentUser} navigationToolbarChildren={test.navigationToolbarChildren} enabledSideboxes={props.enabledSideboxes} messageStatusFilter={test.messageStatusFilter} @@ -588,6 +714,7 @@ export function generateDemoTexterContact(testName) { }; const DemoTexterTest = function(props) { + console.log("DemoTexterTest", test); return ( ); }; diff --git a/src/components/AssignmentTexter/MessageList.jsx b/src/components/AssignmentTexter/MessageList.jsx index c3cf21038..fa8315109 100644 --- a/src/components/AssignmentTexter/MessageList.jsx +++ b/src/components/AssignmentTexter/MessageList.jsx @@ -2,10 +2,17 @@ import PropTypes from "prop-types"; import React from "react"; import { Link } from "react-router"; import { List, ListItem } from "material-ui/List"; +import { Card, CardHeader, CardMedia } from "material-ui/Card"; +import Avatar from "material-ui/Avatar"; import moment from "moment"; +import AttachmentIcon from "material-ui/svg-icons/file/attachment"; +import AudioIcon from "material-ui/svg-icons/hardware/headset"; +import ImageIcon from "material-ui/svg-icons/image/image"; +import VideoIcon from "material-ui/svg-icons/hardware/tv"; import ProhibitedIcon from "material-ui/svg-icons/av/not-interested"; import Divider from "material-ui/Divider"; import { red300 } from "material-ui/styles/colors"; +import theme from "../../styles/theme"; const defaultStyles = { optOut: { @@ -20,6 +27,10 @@ const defaultStyles = { received: { fontSize: "13px", marginRight: "24px" + }, + mediaItem: { + marginTop: "5px", + backgroundColor: "rgba(255,255,255,.5)" } }; @@ -54,7 +65,14 @@ function SecondaryText(props) { } const MessageList = function MessageList(props) { - const { contact, styles, review, currentUser, organizationId } = props; + const { + contact, + styles, + review, + currentUser, + organizationId, + hideMedia + } = props; const { optOut, messages } = contact; const received = (styles && styles.messageReceived) || defaultStyles.received; @@ -77,6 +95,58 @@ const MessageList = function MessageList(props) { "" ); + const renderMsg = message => ( +
+
{message.text}
+ {!hideMedia && + message.media && + message.media.map(media => { + let type, icon, embed, subtitle; + if (media.type.startsWith("image")) { + type = "Image"; + icon = ; + embed = Media; + } else if (media.type.startsWith("video")) { + type = "Video"; + icon = ; + embed = ( + + ); + } else if (media.type.startsWith("audio")) { + type = "Audio"; + icon = ; + embed = ( + + ); + } else { + type = "Unsupprted media"; + icon = ; + subtitle = `Type: ${media.type}`; + } + return ( + + + } + /> + {embed && {embed}} + + ); + })} +
+ ); + return ( {messages.map(message => ( @@ -84,7 +154,7 @@ const MessageList = function MessageList(props) { disabled style={message.isFromContact ? received : sent} key={message.id} - primaryText={message.text} + primaryText={renderMsg(message)} secondaryText={ ) : null} - {step.question.answerOptions.map((answerOption, index) => ( + {step.question.filteredAnswerOptions.map((answerOption, index) => ( { diff --git a/src/components/AssignmentTexter/Toolbar.jsx b/src/components/AssignmentTexter/Toolbar.jsx index ca1b55611..b5a7be545 100644 --- a/src/components/AssignmentTexter/Toolbar.jsx +++ b/src/components/AssignmentTexter/Toolbar.jsx @@ -198,13 +198,7 @@ const ContactToolbar = function ContactToolbar(props) { className={css(styles.contactToolbarIconButton)} style={{ flex: "0 0 56px", width: "45px" }} > - +
{navigationToolbarChildren.title} @@ -216,13 +210,7 @@ const ContactToolbar = function ContactToolbar(props) { className={css(styles.contactToolbarIconButton)} style={{ flex: "0 0 56px", width: "45px" }} > - +
diff --git a/src/components/CampaignDynamicAssignmentForm.jsx b/src/components/CampaignDynamicAssignmentForm.jsx index 979dd18be..5b642e447 100644 --- a/src/components/CampaignDynamicAssignmentForm.jsx +++ b/src/components/CampaignDynamicAssignmentForm.jsx @@ -158,7 +158,7 @@ export default class CampaignDynamicAssignmentForm extends React.Component { extraProps={listedTag => ({ backgroundColor: theme.colors.lightGray, onClick: evt => { - if (evt.ctrlKey) { + if (evt.ctrlKey || evt.altKey) { this.onChange({ batchPolicies: [...batchPolicies, listedTag.id] }); diff --git a/src/components/CampaignInteractionStepsForm.jsx b/src/components/CampaignInteractionStepsForm.jsx index 3791f750f..54ddb913f 100644 --- a/src/components/CampaignInteractionStepsForm.jsx +++ b/src/components/CampaignInteractionStepsForm.jsx @@ -162,6 +162,52 @@ export default class CampaignInteractionStepsForm extends React.Component { }; } + bumpStep(id) { + return () => { + const step = this.state.interactionSteps.find(is => is.id === id); + var livingSiblings = []; + var otherRelatives = []; + for (let is of this.state.interactionSteps) { + if ( + is.parentInteractionId !== step.parentInteractionId || + step.isDeleted + ) { + otherRelatives.push(is); + } else { + livingSiblings.push(is); + } + } + const i = livingSiblings.findIndex(is => is.id === id); + if (i > 0) { + livingSiblings.splice(i, 1); + livingSiblings.splice(i - 1, 0, step); + this.setState({ + interactionSteps: otherRelatives.concat(livingSiblings) + }); + } + }; + } + + topStep(id) { + return () => { + const target = this.state.interactionSteps.filter(x => x.id === id); + const others = this.state.interactionSteps.filter(x => x.id !== id); + this.setState({ + interactionSteps: target.concat(others) + }); + }; + } + + bottomStep(id) { + return () => { + const target = this.state.interactionSteps.filter(x => x.id === id); + const others = this.state.interactionSteps.filter(x => x.id !== id); + this.setState({ + interactionSteps: others.concat(target) + }); + }; + } + handleFormChange(event) { const handler = event.answerActions && @@ -209,6 +255,28 @@ export default class CampaignInteractionStepsForm extends React.Component { return (
+ {interactionStep.parentInteractionId ? ( +
+ + + + +
+ ) : ( + "" + )} - ) : ( - "" - )} {interactionStep.parentInteractionId && this.props.availableActions && this.props.availableActions.length ? ( diff --git a/src/components/CampaignTextersForm.jsx b/src/components/CampaignTextersForm.jsx index 0b027d650..d9e84981a 100644 --- a/src/components/CampaignTextersForm.jsx +++ b/src/components/CampaignTextersForm.jsx @@ -374,6 +374,10 @@ export default class CampaignTextersForm extends React.Component { + m.texters.find(t => t.id === texter.id).assignment + .needsMessageCount + } hintText="Contacts" fullWidth onFocus={() => this.setState({ focusedTexterId: texter.id })} diff --git a/src/components/CampaignTextingHoursForm.jsx b/src/components/CampaignTextingHoursForm.jsx index 18a1720e2..952d5488b 100644 --- a/src/components/CampaignTextingHoursForm.jsx +++ b/src/components/CampaignTextingHoursForm.jsx @@ -169,13 +169,12 @@ export default class CampaignTextingHoursForm extends React.Component { "Override organization texting hours?" )} + {this.addToggleFormField( + "textingHoursEnforced", + "Texting hours enforced?" + )} {this.props.formValues.overrideOrganizationTextingHours ? (
- {this.addToggleFormField( - "textingHoursEnforced", - "Texting hours enforced?" - )} - {this.props.formValues.textingHoursEnforced ? (
{this.addAutocompleteFormField( diff --git a/src/components/IncomingMessageActions.jsx b/src/components/IncomingMessageActions.jsx index 7ef85ba17..d8c7e211f 100644 --- a/src/components/IncomingMessageActions.jsx +++ b/src/components/IncomingMessageActions.jsx @@ -101,6 +101,7 @@ class IncomingMessageActions extends Component { texterNodes.sort((left, right) => { return left.text.localeCompare(right.text, "en", { sensitivity: "base" }); }); + texterNodes.splice(0, 0, dataSourceItem("Unassign", -2)); const hasCampaignsFilter = this.props.campaignsFilter && diff --git a/src/components/IncomingMessageList/MessageResponse.jsx b/src/components/IncomingMessageList/MessageResponse.jsx index 2129b0fda..f1a1d74c3 100644 --- a/src/components/IncomingMessageList/MessageResponse.jsx +++ b/src/components/IncomingMessageList/MessageResponse.jsx @@ -24,7 +24,8 @@ class MessageResponse extends Component { this.state = { messageText: "", isSending: false, - sendError: "" + sendError: "", + doneFirstClick: false }; this.handleCloseErrorDialog = this.handleCloseErrorDialog.bind(this); @@ -43,14 +44,21 @@ class MessageResponse extends Component { handleMessageFormChange = ({ messageText }) => this.setState({ messageText }); handleMessageFormSubmit = async ({ messageText }) => { - const { campaignContactId } = this.props.conversation; - const message = this.createMessageToContact(messageText); if (this.state.isSending) { return; // stops from multi-send } + + if (window.TEXTER_TWOCLICK && !this.state.doneFirstClick) { + this.setState({ doneFirstClick: true }); // Enforce TEXTER_TWOCLICK + return; + } + + const { campaignContactId } = this.props.conversation; + const message = this.createMessageToContact(messageText); + this.setState({ isSending: true }); - const finalState = { isSending: false }; + const finalState = { isSending: false, doneFirstClick: false }; try { const response = await this.props.mutations.sendMessage( message, @@ -82,7 +90,7 @@ class MessageResponse extends Component { .max(window.MAX_MESSAGE_LENGTH) }); - const { messageText, isSending } = this.state; + const { messageText, isSending, doneFirstClick } = this.state; const isSendDisabled = isSending || messageText.trim() === ""; const errorActions = [ @@ -114,6 +122,7 @@ class MessageResponse extends Component {
diff --git a/src/components/SendButton.jsx b/src/components/SendButton.jsx index 39a7e0a92..47d6afc45 100644 --- a/src/components/SendButton.jsx +++ b/src/components/SendButton.jsx @@ -1,8 +1,10 @@ import PropTypes from "prop-types"; import React, { Component } from "react"; -import RaisedButton from "material-ui/RaisedButton"; +import FlatButton from "material-ui/FlatButton"; import { StyleSheet, css } from "aphrodite"; import { dataTest } from "../lib/attributes"; +import theme from "../styles/theme"; +import { inlineStyles, flexStyles } from "./AssignmentTexter/StyleControls"; // This is because the Toolbar from material-ui seems to only apply the correct margins if the // immediate child is a Button or other type it recognizes. Can get rid of this if we remove material-ui @@ -16,11 +18,27 @@ class SendButton extends Component { render() { return (
-
@@ -30,7 +48,8 @@ class SendButton extends Component { SendButton.propTypes = { onFinalTouchTap: PropTypes.func, - disabled: PropTypes.bool + disabled: PropTypes.bool, + doneFirstClick: PropTypes.bool }; export default SendButton; diff --git a/src/components/TexterFrequentlyAskedQuestions.jsx b/src/components/TexterFrequentlyAskedQuestions.jsx index bc8c1cc85..59908f87c 100644 --- a/src/components/TexterFrequentlyAskedQuestions.jsx +++ b/src/components/TexterFrequentlyAskedQuestions.jsx @@ -1,3 +1,24 @@ +// Spoke: A mass-contact text/SMS peer-to-peer messaging tool +// Copyright (c) 2016-2021 MoveOn Civic Action +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 3 as +// published by the Free Software Foundation, +// with the Additional Term under Section 7(b) to include preserving +// the following author attribution statement in the Spoke application: +// +// Spoke is developed and maintained by people committed to fighting +// oppressive systems and structures, including economic injustice, +// racism, patriarchy, and militarism +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program (see ./LICENSE). If not, see . + import React from "react"; import PropTypes from "prop-types"; import { Card, CardTitle, CardText } from "material-ui/Card"; @@ -7,13 +28,23 @@ const TexterFaqs = ({ faqs }) => {

Frequently Asked Questions

{faqs.map((faq, idx) => ( - +

{faq.answer}

))} + + + +

+ Spoke is developed and maintained by people committed to fighting + oppressive systems and structures, including economic injustice, + racism, patriarchy, and militarism. +

+
+
); }; diff --git a/src/containers/AdminCampaignEdit.jsx b/src/containers/AdminCampaignEdit.jsx index 82de2f18a..5bdd612a0 100644 --- a/src/containers/AdminCampaignEdit.jsx +++ b/src/containers/AdminCampaignEdit.jsx @@ -759,7 +759,9 @@ export class AdminCampaignEdit extends React.Component { .fullyConfigured; const { isArchived } = this.props.campaignData.campaign; const settingsLink = `/admin/${this.props.organizationData.organization.id}/settings`; - let isCompleted = this.props.campaignData.campaign.pendingJobs.length === 0; + let isCompleted = !this.props.campaignData.campaign.pendingJobs.filter( + j => j.status >= 0 + ).length; this.sections().forEach(section => { if ( (section.blocksStarting && !this.checkSectionCompleted(section)) || diff --git a/src/containers/AdminCampaignList.jsx b/src/containers/AdminCampaignList.jsx index 79be990ac..bc7774945 100644 --- a/src/containers/AdminCampaignList.jsx +++ b/src/containers/AdminCampaignList.jsx @@ -157,7 +157,6 @@ export class AdminCampaignList extends React.Component { onSearchRequested={this.handleSearchRequested} searchString={this.state.campaignsFilter.searchString} onCancelSearch={this.handleCancelSearch} - hintText="Search for campaign title. Hit enter to search." />
) @@ -373,6 +372,9 @@ const campaignInfoFragment = ` description timezone dueBy + organization { + id + } creator { displayName } diff --git a/src/containers/AdminCampaignStats.jsx b/src/containers/AdminCampaignStats.jsx index e468e8c40..56ddac21c 100644 --- a/src/containers/AdminCampaignStats.jsx +++ b/src/containers/AdminCampaignStats.jsx @@ -177,7 +177,7 @@ class AdminCampaignStats extends React.Component { } render() { - const { data, params } = this.props; + const { data, params, organizationData } = this.props; const { adminPerms, organizationId, campaignId } = params; const campaign = data.campaign; const currentExportJob = this.props.data.campaign.pendingJobs.filter( @@ -327,6 +327,36 @@ class AdminCampaignStats extends React.Component {
+ {campaign.exportResults ? ( + + ) : null} {campaign.joinToken && campaign.useDynamicAssignment ? ( + Export started - + {this.props.organizationData && + this.props.organizationData.emailEnabled + ? " we'll e-mail you when it's done." + : null} + {campaign.cacheable ? ( + + { + this.props.data.refetch(); + }} + style={{ textDecoration: "underline" }} + > + Reload the page + {" "} + to see a download link when its ready. + + ) : null} + + } + autoHideDuration={campaign.cacheable ? null : 5000} onRequestClose={() => { this.setState({ exportMessageOpen: false }); }} @@ -434,6 +485,11 @@ const queries = { unrepliedCount: contactsCount(contactsFilter: $needsResponseFilter) contactsCount } + exportResults { + error + campaignExportUrl + campaignMessagesExportUrl + } pendingJobs { id jobType @@ -462,6 +518,7 @@ const queries = { link } } + cacheable } } `, @@ -489,6 +546,7 @@ const queries = { organization(id: $organizationId) { id campaignPhoneNumbersEnabled + emailEnabled } } `, diff --git a/src/containers/AdminOrganizationsDashboard.jsx b/src/containers/AdminOrganizationsDashboard.jsx index bb8a44891..5e2aa44b7 100644 --- a/src/containers/AdminOrganizationsDashboard.jsx +++ b/src/containers/AdminOrganizationsDashboard.jsx @@ -155,6 +155,7 @@ class AdminOrganizationsDashboard extends React.Component { this.props.data.organizations.reverse(); } }} + initialSort={{ column: "id", order: "desc" }} />
{this.renderActionButton()} diff --git a/src/containers/AdminPersonList.jsx b/src/containers/AdminPersonList.jsx index 3cabd3977..bd563091d 100644 --- a/src/containers/AdminPersonList.jsx +++ b/src/containers/AdminPersonList.jsx @@ -44,7 +44,8 @@ class AdminPersonList extends React.Component { this.state = { open: false, userEdit: false, - passwordResetHash: "" + passwordResetHash: "", + resetLink: false }; } @@ -162,6 +163,12 @@ class AdminPersonList extends React.Component { this.setState({ open: false, passwordResetHash: "" }); } + handleResetInviteLink = async () => { + console.log("handleResetInviteLink"); + await this.props.mutations.resetOrganizationJoinLink(); + this.setState({ resetLink: false }); + }; + handleSortByChanged = (event, index, sortBy) => { this.handleFilterChange({ sortBy }); }; @@ -246,7 +253,38 @@ class AdminPersonList extends React.Component { const { userData: { currentUser } } = this.props; - + const joinActions = [ + + ]; + if (currentUser.roles.indexOf("ADMIN") !== -1) { + if (this.state.resetLink) { + joinActions.unshift( + , + this.setState({ resetLink: false })} + /> + ); + } else { + joinActions.unshift( + this.setState({ resetLink: true })} + /> + ); + } + } return (
@@ -295,14 +333,7 @@ class AdminPersonList extends React.Component {
- ]} + actions={joinActions} modal={false} open={this.state.open} onRequestClose={this.handleClose} @@ -327,6 +358,22 @@ AdminPersonList.propTypes = { location: PropTypes.object }; +const mutations = { + resetOrganizationJoinLink: ownProps => () => ({ + mutation: gql` + mutation resetOrganizationJoinLink($organizationId: String!) { + resetOrganizationJoinLink(organizationId: $organizationId) { + id + uuid + } + } + `, + variables: { + organizationId: ownProps.organizationData.organization.id + } + }) +}; + const queries = { userData: { query: gql` @@ -374,4 +421,4 @@ const queries = { } }; -export default loadData({ queries })(withRouter(AdminPersonList)); +export default loadData({ queries, mutations })(withRouter(AdminPersonList)); diff --git a/src/containers/AdminPhoneNumberInventory.js b/src/containers/AdminPhoneNumberInventory.js index 8030c0687..f149b39a9 100644 --- a/src/containers/AdminPhoneNumberInventory.js +++ b/src/containers/AdminPhoneNumberInventory.js @@ -3,6 +3,7 @@ import React from "react"; import gql from "graphql-tag"; import ContentAdd from "material-ui/svg-icons/content/add"; +import DeleteIcon from "material-ui/svg-icons/action/delete-forever"; import DataTables from "material-ui-datatables"; import Dialog from "material-ui/Dialog"; import Paper from "material-ui/Paper"; @@ -16,7 +17,12 @@ import Form from "react-formal"; import theme from "../styles/theme"; import { dataTest } from "../lib/attributes"; import loadData from "./hoc/load-data"; -import { CircularProgress, FlatButton, Toggle } from "material-ui"; +import { + CircularProgress, + FlatButton, + RaisedButton, + Toggle +} from "material-ui"; const inlineStyles = { column: { @@ -42,6 +48,7 @@ const inlineStyles = { class AdminPhoneNumberInventory extends React.Component { static propTypes = { data: PropTypes.object, + params: PropTypes.object, mutations: PropTypes.object }; @@ -55,7 +62,8 @@ class AdminPhoneNumberInventory extends React.Component { }, sortCol: "state", sortOrder: "asc", - filters: {} + filters: {}, + deleteNumbersDialogOpen: false }; } @@ -124,6 +132,32 @@ class AdminPhoneNumberInventory extends React.Component { }); }; + handleDeleteNumbersOpen = row => { + this.setState({ + deleteNumbersDialogOpen: true, + deleteNumbersAreaCode: row.areaCode, + deleteNumbersCount: row.availableCount + }); + }; + + handleDeleteNumbersCancel = () => { + this.setState({ + deleteNumbersDialogOpen: false, + deleteNumbersAreaCode: null, + deleteNumbersCount: 0 + }); + }; + + handleDeletePhoneNumbersSubmit = async () => { + await this.props.mutations.deletePhoneNumbers( + this.state.deleteNumbersAreaCode + ); + this.setState({ + deleteNumbersDialogOpen: false, + deleteNumbersAreaCode: null + }); + }; + tableColumns() { const { pendingPhoneNumberJobs } = this.props.data.organization; return [ @@ -157,6 +191,18 @@ class AdminPhoneNumberInventory extends React.Component { textAlign: "center" } }, + { + key: "deleteButton", + label: "", + style: inlineStyles.column, + render: (columnKey, row) => + this.props.params.ownerPerms ? ( + } + onTouchTap={() => this.handleDeleteNumbersOpen(row)} + /> + ) : null + }, // TODO: display additional information here about pending and past jobs { key: "pendingJobs", @@ -320,13 +366,16 @@ class AdminPhoneNumberInventory extends React.Component { initialSort={{ column: "state", order: "asc" }} onSortOrderChange={handleSortOrderChange} /> - - - + {this.props.params.ownerPerms ? ( + + + + ) : null} + {this.renderBuyNumbersForm()} + , + + ]} + > + Do you want to delete availale numbers for the  + {this.state.deleteNumbersAreaCode} area code? This will + permanently remove numbers not allocated to a campaign/messaging + service from both Spoke and your Twilio account. +
); } @@ -405,6 +477,23 @@ const mutations = { addToOrganizationMessagingService }, refetchQueries: () => ["getOrganizationData"] + }), + deletePhoneNumbers: ownProps => areaCode => ({ + mutation: gql` + mutation deletePhoneNumbers($organizationId: ID!, $areaCode: String!) { + deletePhoneNumbers( + organizationId: $organizationId + areaCode: $areaCode + ) { + id + } + } + `, + variables: { + organizationId: ownProps.params.organizationId, + areaCode + }, + refetchQueries: () => ["getOrganizationData"] }) }; diff --git a/src/containers/TexterTodo.jsx b/src/containers/TexterTodo.jsx index bac8a3bbe..a01190396 100644 --- a/src/containers/TexterTodo.jsx +++ b/src/containers/TexterTodo.jsx @@ -34,6 +34,10 @@ export const contactDataFragment = ` id createdAt text + media { + type + url + } isFromContact userId } diff --git a/src/containers/TexterTodoList.jsx b/src/containers/TexterTodoList.jsx index 858ff73fe..fb8c11a0a 100644 --- a/src/containers/TexterTodoList.jsx +++ b/src/containers/TexterTodoList.jsx @@ -17,28 +17,25 @@ class TexterTodoList extends React.Component { } renderTodoList(assignments) { - const organizationId = this.props.params.organizationId; return assignments .sort((x, y) => { - const xToText = x.unmessagedCount + x.unrepliedCount; - const yToText = y.unmessagedCount + y.unrepliedCount; - if (xToText === yToText) { - return Number(y.id) - Number(x.id); - } - return xToText > yToText ? -1 : 1; - }) - .sort((x, y) => { - // sort again to bring feedback to the top + // Sort with feedback at the top, and then based on Text assignment size const xHasFeedback = x.feedback && x.feedback.sweepComplete && !x.feedback.isAcknowledged; const yHasFeedback = y.feedback && y.feedback.sweepComplete && !y.feedback.isAcknowledged; - if (xHasFeedback && yHasFeedback) { - return 0; - } else if (xHasFeedback && !yHasFeedback) { + if (xHasFeedback && !yHasFeedback) { return -1; } - return 1; + if (yHasFeedback && !xHasFeedback) { + return 1; + } + const xToText = x.unmessagedCount + x.unrepliedCount; + const yToText = y.unmessagedCount + y.unrepliedCount; + if (xToText === yToText) { + return Number(y.id) - Number(x.id); + } + return xToText > yToText ? -1 : 1; }) .map(assignment => { if ( @@ -47,7 +44,7 @@ class TexterTodoList extends React.Component { ) { return ( . + import PropTypes from "prop-types"; import React from "react"; import loadData from "./hoc/load-data"; @@ -10,7 +31,7 @@ import Dialog from "material-ui/Dialog"; import RaisedButton from "material-ui/RaisedButton"; import { StyleSheet, css } from "aphrodite"; import apolloClient from "../network/apollo-client-singleton"; - +import { Card, CardText } from "material-ui/Card"; import { dataTest } from "../lib/attributes"; const styles = StyleSheet.create({ @@ -261,9 +282,10 @@ export class UserEdit extends React.Component { const fieldsNeeded = router && !!router.location.query.fieldsNeeded; return ( -
- {userId ?
User Id: {userId}
: null} +
+ {userId ?
User Id: {userId}
: null}
+ + + Spoke is developed and maintained by people committed to fighting + oppressive systems and structures, including economic injustice, + racism, patriarchy, and militarism. + +
); } diff --git a/src/integrations/action-handlers/action-network.js b/src/extensions/action-handlers/action-network.js similarity index 100% rename from src/integrations/action-handlers/action-network.js rename to src/extensions/action-handlers/action-network.js diff --git a/src/extensions/contact-loaders/s3-pull/index.js b/src/extensions/contact-loaders/s3-pull/index.js new file mode 100644 index 000000000..5fd74ac98 --- /dev/null +++ b/src/extensions/contact-loaders/s3-pull/index.js @@ -0,0 +1,361 @@ +import { + completeContactLoad, + failedContactLoad, + getTimezoneByZip, + sendJobToAWSLambda +} from "../../../workers/jobs"; +import { r } from "../../../server/models"; +import { getConfig, hasConfig } from "../../../server/api/lib/config"; +import { getFormattedPhoneNumber } from "../../../lib/phone-format.js"; +import { log, gunzip } from "../../../lib"; + +import path from "path"; +import Papa from "papaparse"; +import AWS from "aws-sdk"; + +export const name = "s3-pull"; + +export function displayName() { + return "S3 CSV Pull"; +} + +export function serverAdministratorInstructions() { + return { + environmentVariables: [ + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_S3_BUCKET_NAME", + "AWS_REGION" + ], + description: "S3 Contact load", + setupInstructions: "AWS S3 needs to be setup" + }; +} + +export async function available(organization, user) { + /// return an object with two keys: result: true/false + /// these keys indicate if the ingest-contact-loader is usable + /// Sometimes credentials need to be setup, etc. + /// A second key expiresSeconds: should be how often this needs to be checked + /// If this is instantaneous, you can have it be 0 (i.e. always), but if it takes time + /// to e.g. verify credentials or test server availability, + /// then it's better to allow the result to be cached + const result = + hasConfig("AWS_S3_BUCKET_NAME") && + hasConfig("AWS_REGION") && + hasConfig("AWS_ACCESS_KEY_ID") && + hasConfig("AWS_SECRET_ACCESS_KEY"); + return { + result, + expiresSeconds: 0 + }; +} + +export function addServerEndpoints(expressApp) { + /// If you need to create API endpoints for server-to-server communication + /// this is where you would run e.g. app.post(....) + /// Be mindful of security and make sure there's + /// This is NOT where or how the client send or receive contact data + return; +} + +export function clientChoiceDataCacheKey(organization, campaign, user) { + /// returns a string to cache getClientChoiceData -- include items that relate to cacheability + //return `${organization.id}-${campaign.id}`; + return ""; +} + +export async function getClientChoiceData(organization, campaign, user) { + /// data to be sent to the admin client to present options to the component or similar + /// The react-component will be sent this data as a property + /// return a json object which will be cached for expiresSeconds long + /// `data` should be a single string -- it can be JSON which you can parse in the client component + + // FUTURE: maybe expose list of exports visible + return { + data: "", + expiresSeconds: 0 + }; +} + +export async function loadContactS3PullProcessFile(jobEvent, contextVars) { + const { + fileIndex, + campaign_id, + manifestData, + s3Bucket, + s3Path, + indexes, + customIndexes, + region + } = jobEvent; + const s3 = new AWS.S3({ + region, + signatureVersion: "v4" + }); + + if (jobEvent.completeContactLoad) { + await completeContactLoad( + jobEvent, + null, + jobEvent.completeContactLoad.ingestDataReference, + jobEvent.completeContactLoad.ingestResult + ); + return; + } + const fileData = await s3 + .getObject({ + Bucket: s3Bucket, + // removes the protocol/domain parts + Key: manifestData.entries[fileIndex].url + .split("/") + .slice(3) + .join("/") + }) + .promise(); + const fileString = await gunzip(fileData.Body); + const { data, errors } = await new Promise((resolve, reject) => { + Papa.parse(fileString.toString(), { + delimiter: "|", + skipEmptyLines: true, + escapeChar: "\\", + error: (err, file, inputElem, reason) => { + console.log("s3pull ERROR", err, reason); + resolve({ errors: [err] }); + }, + complete: ({ data, errors, meta }) => { + console.log("s3pull data", data.length, errors, meta); + resolve({ data, errors }); + } + }); + }); + + if (data) { + let insertRows = data.map(colData => { + const contact = { + campaign_id, + assignment_id: null, + message_status: "needsMessage" + }; + const customFields = {}; + Object.keys(indexes).forEach(n => { + contact[n] = indexes[n] === -1 ? "" : colData[indexes[n]]; + }); + contact.cell = getFormattedPhoneNumber( + contact.cell, + process.env.PHONE_NUMBER_COUNTRY || "US" + ); + customIndexes.forEach(i => { + customFields[manifestData.schema.elements[i].name] = colData[i]; + }); + contact.custom_fields = JSON.stringify(customFields); + if (customFields.hasOwnProperty("timezone_offset")) { + contact.timezone_offset = customFields["timezone_offset"]; + } + return contact; + }); + // memoize and update all the timezone_offsets: + const tzOffsets = {}; + insertRows + .filter(r => r.zip && !r.timezone_offset) + .forEach(r => { + tzOffsets[r.zip] = ""; + }); + if (Object.keys(tzOffsets).length) { + for (let zip in tzOffsets) { + tzOffsets[zip] = await getTimezoneByZip(zip); + } + insertRows = insertRows.map(r => { + if (r.zip && !r.timezone_offset && tzOffsets[r.zip]) { + r.timezone_offset = tzOffsets[r.zip]; + } + return r; + }); + } + // In case the job was discarded, before we save, + // we confirm the job still exists. + const jobCompleted = await r + .knex("job_request") + .where("id", jobEvent.id) + .select("status") + .first(); + if (!jobCompleted) { + console.log( + "loadContactS3PullProcessFile job no longer exists", + jobEvent + ); + return { alreadyComplete: 1 }; + } + + await r.knex.batchInsert("campaign_contact", insertRows); + } + + if (fileIndex < manifestData.entries.length - 1) { + await r + .knex("job_request") + .where("id", jobEvent.id) + .update({ + status: Math.round( + (100 * (fileIndex + 1)) / manifestData.entries.length + ) + }); + const newJobEvent = { + ...jobEvent, + fileIndex: fileIndex + 1, + command: "loadContactS3PullProcessFileJob" + }; + if (process.env.WAREHOUSE_DB_LAMBDA_ITERATION) { + console.log( + "SENDING TO LAMBDA loadContactS3PullProcessFileJob", + newJobEvent + ); + await sendJobToAWSLambda(newJobEvent); + return { invokedAgain: 1 }; + } else { + return await loadContactS3PullProcessFile(newJobEvent, contextVars); + } + } else { + // Finished last insert + const validationStats = {}; + // delete invalid cells + await r + .knex("campaign_contact") + .whereRaw("length(cell) != 12") + .andWhere("campaign_id", campaign_id) + .delete() + .then(result => { + console.log( + `loadContactS3PullProcessFile # of contacts with invalid cells removed from DW query (${campaign_id}): ${result}` + ); + validationStats.invalidCellCount = result; + }); + if (process.env.WAREHOUSE_DB_LAMBDA_ITERATION) { + await completeContactLoad( + jobEvent, + null, + { manifestData, s3Path }, + { errors, validationStats } + ); + } else { + const newJobEvent = { + ...jobEvent, + completeContactLoad: { + ingestDataReference: { manifestData, s3Path }, + ingestResult: { errors, validationStats } + }, + command: "loadContactS3PullProcessFileJob" + }; + await sendJobToAWSLambda(newJobEvent); + } + } +} + +export async function processContactLoad(job, maxContacts, organization) { + console.log("STARTING s3-pull load", job.id, job.payload); + const jobMessages = []; + const s3Path = JSON.parse(job.payload).s3Path; + const s3Bucket = getConfig("AWS_S3_BUCKET_NAME", organization); + const region = getConfig("AWS_REGION", organization); + const s3 = new AWS.S3({ + region, + signatureVersion: "v4" + }); + + const manifestPath = s3Path.endsWith("manifest") + ? s3Path + : path.join(s3Path, "manifest"); + console.log("s3-pull manifest path: ", manifestPath.replace(/^\//, "")); + let manifestData; + try { + const manifestFile = await s3 + .getObject({ + Bucket: s3Bucket, + Key: manifestPath.replace(/^\//, "") + }) + .promise(); + manifestData = JSON.parse(manifestFile.Body.toString("utf-8")); + } catch (err) { + await failedContactLoad( + job, + null, + { s3Path }, + { + errors: [err], + manifestPath: manifestPath.replace(/^\//, "") + } + ); + return; + } + console.log("s3-pull manifest found", manifestData); + // 1. check manifestData.schema -- if not demand "MANIFEST VERBOSE" + if (!manifestData.schema || !manifestData.schema.elements) { + await failedContactLoad( + job, + null, + { s3Path, manifestData }, + { + errors: [ + "Manifest file did not have schema info -- make sure to run with MANIFEST VERBOSE" + ] + } + ); + return; + } + + // 2. check for cell, first_name, last_name in columns + const indexes = { + first_name: manifestData.schema.elements.findIndex( + x => x.name === "first_name" + ), + last_name: manifestData.schema.elements.findIndex( + x => x.name === "last_name" + ), + cell: manifestData.schema.elements.findIndex(x => x.name === "cell"), + zip: manifestData.schema.elements.findIndex(x => x.name === "zip"), + external_id: manifestData.schema.elements.findIndex( + x => x.name === "external_id" + ) + }; + if ( + indexes.first_name === -1 || + indexes.last_name === -1 || + indexes.cell === -1 + ) { + const colNames = manifestData.schema.elements.map(x => x.name).join(", "); + const error = `Missing at least one required column: first_name, last_name, cell. Columns: ${colNames}`; + await failedContactLoad( + job, + null, + { s3Path, manifestData }, + { + errors: [error] + } + ); + return; + } + const customIndexes = manifestData.schema.elements + .map((x, i) => [x.name, i]) + .filter(x => !(x[0] in indexes)) + .map(x => x[1]); + + // Delete old data; FUTURE: checkbox to 'pick up from before?' + await r + .knex("campaign_contact") + .where("campaign_id", job.campaign_id) + .delete(); + + const event = { + id: job.id, + campaign_id: job.campaign_id, + job_type: "ingest.s3-pull", + // beyond job object: + s3Path, + s3Bucket, + region, + manifestData, + indexes, + customIndexes, + fileIndex: 0 + }; + return await loadContactS3PullProcessFile(event); +} diff --git a/src/extensions/contact-loaders/s3-pull/react-component.js b/src/extensions/contact-loaders/s3-pull/react-component.js new file mode 100644 index 000000000..6eb72dc28 --- /dev/null +++ b/src/extensions/contact-loaders/s3-pull/react-component.js @@ -0,0 +1,121 @@ +import type from "prop-types"; +import React from "react"; +import RaisedButton from "material-ui/RaisedButton"; +import GSForm from "../../../components/forms/GSForm"; +import Form from "react-formal"; +import { ListItem, List } from "material-ui/List"; +import CampaignFormSectionHeading from "../../../components/CampaignFormSectionHeading"; +import theme from "../../../styles/theme"; +import { StyleSheet, css } from "aphrodite"; +import yup from "yup"; + +export class CampaignContactsForm extends React.Component { + constructor(props) { + super(props); + const { lastResult } = props; + let cur = {}; + if (lastResult && lastResult.reference) { + cur = JSON.parse(lastResult.reference); + } + console.log("s3-pull", lastResult, props); + this.state = { + s3Path: cur.s3Path || "" + }; + } + + render() { + const { lastResult } = this.props; + let results = {}; + if (lastResult && lastResult.result) { + results = JSON.parse(lastResult.result); + console.log("s3-pull results", results); + } + return ( +
+ {results.errors && results.errors.length ? ( +
+

Previous Errors

+ + {results.errors.map(e => ( + + ))} + +
+ ) : ( + "" + )} + { + // sets values locally + this.setState({ ...formValues }); + // and now do whatever happens when clicking 'Next' + this.props.onSubmit(); + }} + onChange={formValues => { + console.log("onChange", formValues); + this.setState(formValues); + this.props.onChange(JSON.stringify(formValues)); + }} + > +
+
+ Instead of uploading contacts, you can load them from an AWS S3 + Bucket path as long as Spoke has access to it. Paths should NOT + include the S3 bucket and should start with a '/'. +

+ You can load data from a Redshift instance with a command like + this: +
+ + UNLOAD ('SELECT first_name, last_name, cell, zip FROM ...') +
+ TO + 's3://<YOUR_S3_BUCKET>/<some_path_for_this_campaign>/' +
+ iam_role + 'arn:aws:iam::<AWS_ACCOUNT_ID>:role/<AWS_ROLE_FOR_UNLOAD>' +
+ manifest verbose gzip ESCAPE ALLOWOVERWRITE region + '<REGION>' +
+

+
+ +
+ +
+
+ ); + } +} + +CampaignContactsForm.propTypes = { + onChange: type.func, + onSubmit: type.func, + campaignIsStarted: type.bool, + + icons: type.object, + + saveDisabled: type.bool, + saveLabel: type.string, + + clientChoiceData: type.string, + jobResultMessage: type.string +}; diff --git a/src/extensions/contact-loaders/test-fakedata/index.js b/src/extensions/contact-loaders/test-fakedata/index.js index 0cf733823..29f147d47 100644 --- a/src/extensions/contact-loaders/test-fakedata/index.js +++ b/src/extensions/contact-loaders/test-fakedata/index.js @@ -113,6 +113,17 @@ export async function processContactLoad(job, maxContacts, organization) { return; // bail early } const areaCodes = ["213", "323", "212", "718", "646", "661"]; + // FUTURE -- maybe based on campaign default use 'surrounding' offsets + const timezones = [ + "-12_1", + "-11_0", + "-5_1", + "-4_1", + "0_0", + "5_0", + "10_0", + "" + ]; const contactCount = Math.min( contactData.requestContactCount || 0, maxContacts ? maxContacts : areaCodes.length * 100, @@ -136,6 +147,8 @@ export async function processContactLoad(job, maxContacts, organization) { cell: `+1${ac}555${suffix}`, zip: "10011", custom_fields: genCustomFields(i, campaignId), + timezone_offset: + timezones[parseInt(Math.random() * timezones.length, 10)], message_status: "needsMessage", campaign_id: campaignId }); diff --git a/src/extensions/message-handlers/ngpvan/index.js b/src/extensions/message-handlers/ngpvan/index.js index 6490668f9..aec7a037c 100644 --- a/src/extensions/message-handlers/ngpvan/index.js +++ b/src/extensions/message-handlers/ngpvan/index.js @@ -26,12 +26,16 @@ export const available = organization => // export const preMessageSave = async () => {}; -export const postMessageSave = async ({ contact, organization }) => { - if (!available(organization)) { +export const postMessageSave = async ({ message, contact, organization }) => { + if (!exports.available(organization)) { return {}; } - if (contact.message_status !== "needsMessage") { + if ( + message.is_from_contact || + !contact || + contact.message_status !== "needsMessage" + ) { return {}; } @@ -41,10 +45,19 @@ export const postMessageSave = async ({ contact, organization }) => { DEFAULT_NGP_VAN_INITIAL_TEXT_CANVASS_RESULT; const texted = clientChoiceData.find(ccd => ccd.name === initialTextResult); + if (!texted) { + // eslint-disable-next-line no-console + console.error( + `NGPVAN message handler -- not handling message because no action choice data found for ${initialTextResult}` + ); + + return {}; + } + const body = JSON.parse(texted.details); return Van.postCanvassResponse(contact, organization, body) - .then(() => {}) + .then(() => ({})) .catch(caughtError => { // eslint-disable-next-line no-console console.error( diff --git a/src/extensions/texter-sideboxes/components.js b/src/extensions/texter-sideboxes/components.js index 1f34b82c1..0ec6a3c98 100644 --- a/src/extensions/texter-sideboxes/components.js +++ b/src/extensions/texter-sideboxes/components.js @@ -5,6 +5,8 @@ function getComponents() { "TEXTER_SIDEBOXES" in global ? (global.TEXTER_SIDEBOXES && global.TEXTER_SIDEBOXES.split(",")) || [] : [ + "hide-media", + "texter-feedback", "celebration-gif", "default-dynamicassignment", "default-releasecontacts", diff --git a/src/extensions/texter-sideboxes/default-dynamicassignment/react-component.js b/src/extensions/texter-sideboxes/default-dynamicassignment/react-component.js index 90ef809b6..dcf6fe24f 100644 --- a/src/extensions/texter-sideboxes/default-dynamicassignment/react-component.js +++ b/src/extensions/texter-sideboxes/default-dynamicassignment/react-component.js @@ -120,8 +120,8 @@ export class TexterSideboxClass extends React.Component { diff --git a/src/extensions/texter-sideboxes/hide-media/react-component.js b/src/extensions/texter-sideboxes/hide-media/react-component.js new file mode 100644 index 000000000..40ee72fbd --- /dev/null +++ b/src/extensions/texter-sideboxes/hide-media/react-component.js @@ -0,0 +1,59 @@ +import type from "prop-types"; +import React from "react"; +import yup from "yup"; +import Form from "react-formal"; + +export const displayName = () => + "Hide media including images and videos from texters"; + +export const showSidebox = () => true; + +export class TexterSidebox extends React.Component { + constructor(props) { + super(props); + const { parent } = props; + parent.setState({ + hideMedia: true + }); + } + + render() { + return null; + } +} + +TexterSidebox.propTypes = { + // data + contact: type.object, + campaign: type.object, + assignment: type.object, + texter: type.object, + + // parent state + disabled: type.bool, + navigationToolbarChildren: type.object, + messageStatusFilter: type.string +}; + +export const adminSchema = () => ({}); + +export class AdminConfig extends React.Component { + render() { + return ( +
+

+ When contacts reply with images/media Spoke will have a prompt for the + contact to view the image/media. However, this is often a vehicle for + offensive and graphic responses. We recommend enabling hiding media + for most outreach campaigns, and to enable this for contacts that are + more likely allies. +

+
+ ); + } +} + +AdminConfig.propTypes = { + settingsData: type.object, + onToggle: type.func +}; diff --git a/src/extensions/texter-sideboxes/mobilize-event-shifter/react-component.js b/src/extensions/texter-sideboxes/mobilize-event-shifter/react-component.js new file mode 100644 index 000000000..bba379fcf --- /dev/null +++ b/src/extensions/texter-sideboxes/mobilize-event-shifter/react-component.js @@ -0,0 +1,248 @@ +import type from "prop-types"; +import React from "react"; +import yup from "yup"; +import Form from "react-formal"; +import FlatButton from "material-ui/FlatButton"; +import Dialog from "material-ui/Dialog"; +import CircularProgress from "material-ui/CircularProgress"; +import { Tabs, Tab } from "material-ui/Tabs"; +import { css, StyleSheet } from "aphrodite"; +import { + flexStyles, + inlineStyles +} from "../../../components/AssignmentTexter/StyleControls"; + +export const displayName = () => "Mobilize Event Shifter"; + +export const showSidebox = ({ contact, messageStatusFilter, settingsData }) => + contact && + messageStatusFilter !== "needsMessage" && + (settingsData.mobilizeEventShifterBaseUrl || + window.MOBILIZE_EVENT_SHIFTER_URL); + +const styles = StyleSheet.create({ + dialog: { + paddingTop: 0, + zIndex: 5000 + }, + dialogContentStyle: { + width: "100%" // Still exists a maxWidth of 768px + }, + iframe: { + height: "80vh", + width: "100%", + border: "none" + }, + loader: { + paddingTop: 50, + paddingLeft: "calc(50% - 25px)" + } +}); + +export class TexterSidebox extends React.Component { + constructor(props) { + super(props); + + const { settingsData, contact } = props; + + const customFields = contact.customFields || {}; + const eventId = + customFields.event_id || settingsData.mobilizeEventShifterDefaultEventId; + + this.state = { + dialogOpen: false, + eventiFrameLoading: true, + alliFrameLoading: true, + dialogTab: eventId ? "event" : "all" + }; + } + + cleanPhoneNumber = phone => { + // take the last 10 digits + return phone + .replace(/[^\d]/g, "") + .split("") + .reverse() + .slice(0, 10) + .reverse() + .join(""); + }; + + openDialog = () => { + this.setState({ + dialogOpen: true + }); + }; + + iframeLoaded = iframeLoadingName => { + const update = {}; + update[iframeLoadingName] = false; + this.setState(update); + }; + + changeTab = e => { + this.setState({ + dialogTab: e + }); + }; + + closeDialog = () => { + this.setState({ + dialogOpen: false, + eventiFrameLoading: true, + alliFrameLoading: true + }); + }; + + buildUrlParamString = urlParams => { + return _.map( + urlParams, + (val, key) => `${key}=${encodeURIComponent(val)}` + ).join("&"); + }; + + render() { + const { settingsData, contact, campaign } = this.props; + + const customFields = contact.customFields || {}; + + const eventId = + customFields.event_id || settingsData.mobilizeEventShifterDefaultEventId; + const urlParams = { + first_name: contact.firstName || "", + last_name: contact.lastName || "", + phone: this.cleanPhoneNumber(contact.cell || ""), + email: customFields.email || "", + zip: customFields.zip || "", + source: "P2P" + }; + + const urlParamString = this.buildUrlParamString(urlParams); + const allEventsUrlParams = this.buildUrlParamString({ + zip: customFields.zip || "" + }); + + const mobilizeBaseUrl = + settingsData.mobilizeEventShifterBaseUrl || + window.MOBILIZE_EVENT_SHIFTER_URL; + + return ( +
+ this.setState({ dialogOpen: true })} + className={css(flexStyles.flatButton)} + labelStyle={inlineStyles.flatButtonLabel} + /> + + ]} + open={this.state.dialogOpen} + onRequestClose={this.closeDialog} + className={css(styles.dialog)} + contentClassName={css(styles.dialogContentStyle)} + > + {eventId ? ( + + + + + ) : ( + "" + )} + {eventId ? ( +
+ {this.state.eventiFrameLoading ? ( + + ) : ( + "" + )} +