56 Commits

Author SHA1 Message Date
riggraz
1b9df2163e (unrelated) fix broken link to oauth learn more 2025-03-01 12:55:51 +01:00
riggraz
b87d5214de Show OAuth with proper logo 2025-03-01 12:55:42 +01:00
riggraz
864d785cd6 Fix active_storage_blob_patch 2025-03-01 12:55:18 +01:00
riggraz
c8413685d8 remove skip_forgery_protection 2025-02-06 22:19:04 +01:00
riggraz
71cf5b2e1c make active storage work only when set up to locally store files 2025-02-06 22:15:33 +01:00
riggraz
bf69381e1a fix style 2025-02-06 21:23:56 +01:00
riggraz
ced5a5b094 fix styling 2025-02-06 21:21:54 +01:00
riggraz
76110c04a0 remove old_site_logo from tenant when updating logo 2025-02-06 21:06:40 +01:00
riggraz
03f94e3608 small fix 2025-02-06 20:56:24 +01:00
riggraz
3d6f8631bc remove useless step in run-tests workflow 2025-02-06 20:48:28 +01:00
riggraz
851b657f72 change a purge_later to purge 2025-02-06 20:36:16 +01:00
riggraz
4c0bf7b8d4 enable comment edit attachments 2025-02-05 22:53:22 +01:00
riggraz
83d4b2e84b add icon to post list to signal whether a post has some attached images 2025-02-05 21:52:28 +01:00
riggraz
edc1a853aa disable image upload for anonymous feedback 2025-02-05 15:51:37 +01:00
riggraz
60819ce357 don't show attachment button in comments if attachments disabled site-wide 2025-02-05 15:46:49 +01:00
riggraz
064f7a59f3 add attachments to comments + add attachment upload on comment create 2025-02-05 15:36:47 +01:00
riggraz
cbd1ebebd1 fix attachment validation for o_auth and post entities 2025-02-05 13:47:01 +01:00
riggraz
e4ddf0e5f3 fix spec 2025-02-04 16:20:20 +01:00
riggraz
8de94b4e12 add possibility to upload attachments from post edit form 2025-02-04 14:54:59 +01:00
riggraz
ec0e59be2d enable deletion of post attachments 2025-02-04 14:29:33 +01:00
riggraz
acd9d598e2 Enable/disable image upload based on tenant settings 2025-02-04 13:28:53 +01:00
riggraz
9bc7a0257e add image upload to posts 2025-01-28 16:55:48 +01:00
riggraz
7d7d29ab5f Add allow_attachment_upload flag to tenant_settings 2025-01-27 12:57:44 +01:00
riggraz
a853ac44df Change from button to actionlink for cancel in new post form 2025-01-27 12:48:41 +01:00
riggraz
7eca5e3d4b Improve react-hook-form state management + add possibility to remove image dropped in dropzone 2025-01-27 12:42:23 +01:00
riggraz
14caaeb7be switch from url_for to blob.url 2025-01-23 12:47:35 +01:00
riggraz
2e89e3ed67 fix oauth spec 2025-01-23 12:15:27 +01:00
riggraz
1e798c3874 remove 'logo' from o_auths factory 2025-01-23 12:04:15 +01:00
riggraz
0e8c187d39 add attachment logo to oauth 2025-01-23 11:29:24 +01:00
riggraz
4dd897061a Add site favicon attachment 2025-01-22 14:04:08 +01:00
riggraz
61948f40fe enable deletion of site logo 2025-01-22 11:52:01 +01:00
riggraz
a712c97882 fix spec 2025-01-22 10:06:43 +01:00
riggraz
86e0657532 fix 2025-01-22 10:06:39 +01:00
riggraz
640785181c Add upload for site logo 2025-01-22 09:55:36 +01:00
riggraz
5c9253ebe3 Add initializer for active storage CDN (not working rn) 2025-01-09 22:08:25 +01:00
riggraz
8748c792db Rename some env vars 2025-01-09 21:37:07 +01:00
riggraz
4d6b847ad7 setup cloud storage for active storage 2025-01-09 21:22:24 +01:00
riggraz
f40ff47c81 Add button to remove user avatar 2025-01-09 11:54:18 +01:00
riggraz
f594a9e211 Show avatar if present, otherwise fallback to gravatar 2025-01-09 10:55:44 +01:00
riggraz
2ebce8aa31 Remove log in cicd pipeline 2025-01-04 12:57:09 +01:00
riggraz
230161c0fd fix 2025-01-04 12:49:13 +01:00
riggraz
7eb04a1370 i'm stupid 2025-01-04 12:18:32 +01:00
riggraz
7e7ec53385 test 2025-01-04 12:17:51 +01:00
riggraz
1bbcbac0b0 Fix yq version 2025-01-04 12:13:56 +01:00
riggraz
366c83d056 Fix tests pipeline 2025-01-04 12:12:08 +01:00
riggraz
2561801bad init activestorage and add avatar upload in user profile 2025-01-03 18:09:03 +01:00
Riccardo Graziosi
bb7d3e8218 Update README.md (#453)
Add features section to README
2025-01-03 16:19:09 +01:00
dependabot[bot]
370f050b36 Bump rails-html-sanitizer from 1.6.0 to 1.6.1 (#448)
Bumps [rails-html-sanitizer](https://github.com/rails/rails-html-sanitizer) from 1.6.0 to 1.6.1.
- [Release notes](https://github.com/rails/rails-html-sanitizer/releases)
- [Changelog](https://github.com/rails/rails-html-sanitizer/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rails/rails-html-sanitizer/compare/v1.6.0...v1.6.1)

---
updated-dependencies:
- dependency-name: rails-html-sanitizer
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Riccardo Graziosi <31478034+riggraz@users.noreply.github.com>
2025-01-03 12:14:59 +01:00
Riccardo Graziosi
a12a95eccc Add webhooks (#447) 2024-12-20 14:06:48 +01:00
Riccardo Graziosi
2290cff507 Fix frontend regex for email address (#449) 2024-12-10 11:51:28 +01:00
Riccardo Graziosi
17c3e621b9 Fix Sidekiq Cron initializer (#443) 2024-11-27 15:46:33 +01:00
Riccardo Graziosi
87b267998b Fix sidekiq cron SendRecapEmails job scheduling creation (#442) 2024-11-25 18:56:24 +01:00
Riccardo Graziosi
9b57df60a4 Fix get_url_for helper method (#441) 2024-11-25 17:31:46 +01:00
Riccardo Graziosi
c0d70186f6 Send recap emails for new feedback (#440) 2024-11-19 17:17:05 +01:00
dependabot[bot]
ace50e1089 Bump cross-spawn from 7.0.3 to 7.0.5 (#439)
Bumps [cross-spawn](https://github.com/moxystudio/node-cross-spawn) from 7.0.3 to 7.0.5.
- [Changelog](https://github.com/moxystudio/node-cross-spawn/blob/master/CHANGELOG.md)
- [Commits](https://github.com/moxystudio/node-cross-spawn/compare/v7.0.3...v7.0.5)

---
updated-dependencies:
- dependency-name: cross-spawn
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-16 14:58:11 +01:00
Riccardo Graziosi
fb441564b8 Add sidekiq as a possible ActiveJob backend (#436) 2024-11-16 14:25:27 +01:00
178 changed files with 5458 additions and 481 deletions

View File

@@ -42,7 +42,7 @@ RUN RSWAG_SWAGGERIZE=true RAILS_ENV=test bundle exec rake rswag:specs:swaggerize
# Compile assets if production # Compile assets if production
# SECRET_KEY_BASE=1 is a workaround (see https://github.com/rails/rails/issues/32947) # SECRET_KEY_BASE=1 is a workaround (see https://github.com/rails/rails/issues/32947)
RUN if [ "$ENVIRONMENT" = "production" ]; then SECRET_KEY_BASE=1 ./bin/rails assets:precompile; fi RUN if [ "$ENVIRONMENT" = "production" ]; then SECRET_KEY_BASE=1 ACTIVE_STORAGE_PRIVATE_SERVICE=test ./bin/rails assets:precompile; fi
### ###
### Dev stage ### ### Dev stage ###

17
Gemfile
View File

@@ -3,7 +3,7 @@ git_source(:github) { |repo| "https://github.com/#{repo}.git" }
ruby '3.0.6' ruby '3.0.6'
gem 'rake', '12.3.3' gem 'rake', '13.2.1'
gem 'rails', '6.1.7.9' gem 'rails', '6.1.7.9'
@@ -61,6 +61,21 @@ gem 'capybara', '3.40.0'
# CORS policy # CORS policy
gem 'rack-cors', '2.0.2' gem 'rack-cors', '2.0.2'
# ActiveJob backend
gem 'sidekiq', '7.3.5'
# Cron jobs with sidekiq
gem 'sidekiq-cron', '2.0.1'
# Template language
gem 'liquid', '5.5.1'
# S3 for ActiveStorage
gem 'aws-sdk-s3', '1.176.1', require: false
# ActiveStorage validation
gem 'active_storage_validations', '1.4'
group :development, :test do group :development, :test do
gem 'byebug', platforms: [:mri, :mingw, :x64_mingw] gem 'byebug', platforms: [:mri, :mingw, :x64_mingw]

View File

@@ -39,6 +39,12 @@ GEM
erubi (~> 1.4) erubi (~> 1.4)
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.1, >= 1.2.0) rails-html-sanitizer (~> 1.1, >= 1.2.0)
active_storage_validations (1.4.0)
activejob (>= 6.1.4)
activemodel (>= 6.1.4)
activestorage (>= 6.1.4)
activesupport (>= 6.1.4)
marcel (>= 1.0.3)
activejob (6.1.7.9) activejob (6.1.7.9)
activesupport (= 6.1.7.9) activesupport (= 6.1.7.9)
globalid (>= 0.3.6) globalid (>= 0.3.6)
@@ -62,12 +68,28 @@ GEM
zeitwerk (~> 2.3) zeitwerk (~> 2.3)
addressable (2.8.7) addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0) public_suffix (>= 2.0.2, < 7.0)
aws-eventstream (1.3.0)
aws-partitions (1.1030.0)
aws-sdk-core (3.214.1)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.96.0)
aws-sdk-core (~> 3, >= 3.210.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.176.1)
aws-sdk-core (~> 3, >= 3.210.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.10.1)
aws-eventstream (~> 1, >= 1.0.2)
babel-source (5.8.35) babel-source (5.8.35)
babel-transpiler (0.7.0) babel-transpiler (0.7.0)
babel-source (>= 4.0, < 6) babel-source (>= 4.0, < 6)
execjs (~> 2.0) execjs (~> 2.0)
base64 (0.2.0) base64 (0.2.0)
bcrypt (3.1.18) bcrypt (3.1.20)
bindex (0.8.1) bindex (0.8.1)
bootsnap (1.12.0) bootsnap (1.12.0)
msgpack (~> 1.2) msgpack (~> 1.2)
@@ -83,8 +105,11 @@ GEM
regexp_parser (>= 1.5, < 3.0) regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2) xpath (~> 3.2)
concurrent-ruby (1.3.4) concurrent-ruby (1.3.4)
connection_pool (2.2.5) connection_pool (2.4.1)
crass (1.0.6) crass (1.0.6)
cronex (0.15.0)
tzinfo
unicode (>= 0.4.4.5)
cssbundling-rails (1.1.2) cssbundling-rails (1.1.2)
railties (>= 6.0.0) railties (>= 6.0.0)
date (3.4.0) date (3.4.0)
@@ -94,17 +119,22 @@ GEM
railties (>= 4.1.0) railties (>= 4.1.0)
responders responders
warden (~> 1.2.3) warden (~> 1.2.3)
diff-lcs (1.5.0) diff-lcs (1.5.1)
erubi (1.13.0) erubi (1.13.0)
execjs (2.8.1) et-orbi (1.2.11)
tzinfo
execjs (2.10.0)
factory_bot (5.0.2) factory_bot (5.0.2)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
factory_bot_rails (5.0.2) factory_bot_rails (5.0.2)
factory_bot (~> 5.0.2) factory_bot (~> 5.0.2)
railties (>= 4.2.0) railties (>= 4.2.0)
ffi (1.15.5) ffi (1.17.0)
friendly_id (5.5.1) friendly_id (5.5.1)
activerecord (>= 4.0.0) activerecord (>= 4.0.0)
fugit (1.11.1)
et-orbi (~> 1, >= 1.2.11)
raabro (~> 1.4)
globalid (1.2.1) globalid (1.2.1)
activesupport (>= 6.1) activesupport (>= 6.1)
httparty (0.21.0) httparty (0.21.0)
@@ -117,6 +147,7 @@ GEM
jbuilder (2.11.5) jbuilder (2.11.5)
actionview (>= 5.0.0) actionview (>= 5.0.0)
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
jmespath (1.6.2)
jsbundling-rails (1.1.1) jsbundling-rails (1.1.1)
railties (>= 6.0.0) railties (>= 6.0.0)
json-schema (5.0.1) json-schema (5.0.1)
@@ -133,9 +164,11 @@ GEM
activerecord activerecord
kaminari-core (= 1.2.2) kaminari-core (= 1.2.2)
kaminari-core (1.2.2) kaminari-core (1.2.2)
liquid (5.5.1)
listen (3.5.1) listen (3.5.1)
rb-fsevent (~> 0.10, >= 0.10.3) rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10) rb-inotify (~> 0.9, >= 0.9.10)
logger (1.6.1)
loofah (2.23.1) loofah (2.23.1)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.12.0) nokogiri (>= 1.12.0)
@@ -148,11 +181,11 @@ GEM
matrix (0.4.2) matrix (0.4.2)
method_source (1.1.0) method_source (1.1.0)
mini_mime (1.1.5) mini_mime (1.1.5)
mini_portile2 (2.8.7) mini_portile2 (2.8.8)
minitest (5.25.1) minitest (5.25.1)
msgpack (1.5.2) msgpack (1.7.5)
multi_xml (0.6.0) multi_xml (0.6.0)
net-imap (0.4.17) net-imap (0.4.18)
date date
net-protocol net-protocol
net-pop (0.1.2) net-pop (0.1.2)
@@ -162,7 +195,7 @@ GEM
net-smtp (0.5.0) net-smtp (0.5.0)
net-protocol net-protocol
nio4r (2.7.4) nio4r (2.7.4)
nokogiri (1.16.7) nokogiri (1.16.8)
mini_portile2 (~> 2.8.2) mini_portile2 (~> 2.8.2)
racc (~> 1.4) racc (~> 1.4)
orm_adapter (0.5.0) orm_adapter (0.5.0)
@@ -172,6 +205,7 @@ GEM
nio4r (~> 2.0) nio4r (~> 2.0)
pundit (2.2.0) pundit (2.2.0)
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.8.1) racc (1.8.1)
rack (2.2.10) rack (2.2.10)
rack-attack (6.7.0) rack-attack (6.7.0)
@@ -199,18 +233,18 @@ GEM
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
minitest minitest
nokogiri (>= 1.6) nokogiri (>= 1.6)
rails-html-sanitizer (1.6.0) rails-html-sanitizer (1.6.1)
loofah (~> 2.21) loofah (~> 2.21)
nokogiri (~> 1.14) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
railties (6.1.7.9) railties (6.1.7.9)
actionpack (= 6.1.7.9) actionpack (= 6.1.7.9)
activesupport (= 6.1.7.9) activesupport (= 6.1.7.9)
method_source method_source
rake (>= 12.2) rake (>= 12.2)
thor (~> 1.0) thor (~> 1.0)
rake (12.3.3) rake (13.2.1)
rb-fsevent (0.11.2) rb-fsevent (0.11.2)
rb-inotify (0.10.1) rb-inotify (0.11.1)
ffi (~> 1.0) ffi (~> 1.0)
react-rails (2.6.2) react-rails (2.6.2)
babel-transpiler (>= 0.7.0) babel-transpiler (>= 0.7.0)
@@ -218,19 +252,21 @@ GEM
execjs execjs
railties (>= 3.2) railties (>= 3.2)
tilt tilt
redis-client (0.22.2)
connection_pool
regexp_parser (2.9.2) regexp_parser (2.9.2)
responders (3.0.1) responders (3.1.1)
actionpack (>= 5.0) actionpack (>= 5.2)
railties (>= 5.0) railties (>= 5.2)
rexml (3.3.9) rexml (3.3.9)
rspec-core (3.12.2) rspec-core (3.13.2)
rspec-support (~> 3.12.0) rspec-support (~> 3.13.0)
rspec-expectations (3.12.3) rspec-expectations (3.13.3)
diff-lcs (>= 1.2.0, < 2.0) diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.12.0) rspec-support (~> 3.13.0)
rspec-mocks (3.12.5) rspec-mocks (3.13.2)
diff-lcs (>= 1.2.0, < 2.0) diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.12.0) rspec-support (~> 3.13.0)
rspec-rails (4.0.2) rspec-rails (4.0.2)
actionpack (>= 4.2) actionpack (>= 4.2)
activesupport (>= 4.2) activesupport (>= 4.2)
@@ -241,7 +277,7 @@ GEM
rspec-support (~> 3.10) rspec-support (~> 3.10)
rspec-retry (0.6.2) rspec-retry (0.6.2)
rspec-core (> 3.3) rspec-core (> 3.3)
rspec-support (3.12.0) rspec-support (3.13.1)
rswag-api (2.15.0) rswag-api (2.15.0)
activesupport (>= 5.2, < 8.0) activesupport (>= 5.2, < 8.0)
railties (>= 5.2, < 8.0) railties (>= 5.2, < 8.0)
@@ -256,6 +292,16 @@ GEM
rexml (~> 3.2, >= 3.2.5) rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 3.0) rubyzip (>= 1.2.2, < 3.0)
websocket (~> 1.0) websocket (~> 1.0)
sidekiq (7.3.5)
connection_pool (>= 2.3.0)
logger
rack (>= 2.2.4)
redis-client (>= 0.22.2)
sidekiq-cron (2.0.1)
cronex (>= 0.13.0)
fugit (~> 1.8, >= 1.11.1)
globalid (>= 1.0.1)
sidekiq (>= 6.5.0)
spring (2.1.1) spring (2.1.1)
spring-watcher-listen (2.0.1) spring-watcher-listen (2.0.1)
listen (>= 2.7, < 4.0) listen (>= 2.7, < 4.0)
@@ -269,16 +315,17 @@ GEM
sprockets (>= 3.0.0) sprockets (>= 3.0.0)
stripe (11.2.0) stripe (11.2.0)
thor (1.3.2) thor (1.3.2)
tilt (2.0.10) tilt (2.4.0)
timeout (0.4.2) timeout (0.4.2)
turbolinks (5.2.1) turbolinks (5.2.1)
turbolinks-source (~> 5.2) turbolinks-source (~> 5.2)
turbolinks-source (5.2.0) turbolinks-source (5.2.0)
tzinfo (2.0.6) tzinfo (2.0.6)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
unicode (0.4.4.5)
warden (1.2.9) warden (1.2.9)
rack (>= 2.0.9) rack (>= 2.0.9)
web-console (4.2.0) web-console (4.2.1)
actionview (>= 6.0.0) actionview (>= 6.0.0)
activemodel (>= 6.0.0) activemodel (>= 6.0.0)
bindex (>= 0.4.0) bindex (>= 0.4.0)
@@ -295,6 +342,8 @@ PLATFORMS
ruby ruby
DEPENDENCIES DEPENDENCIES
active_storage_validations (= 1.4)
aws-sdk-s3 (= 1.176.1)
bootsnap (= 1.12.0) bootsnap (= 1.12.0)
byebug byebug
capybara (= 3.40.0) capybara (= 3.40.0)
@@ -307,6 +356,7 @@ DEPENDENCIES
jbuilder (= 2.11.5) jbuilder (= 2.11.5)
jsbundling-rails (= 1.1.1) jsbundling-rails (= 1.1.1)
kaminari (= 1.2.2) kaminari (= 1.2.2)
liquid (= 5.5.1)
listen (= 3.5.1) listen (= 3.5.1)
pg (= 1.3.5) pg (= 1.3.5)
puma (= 5.6.9) puma (= 5.6.9)
@@ -314,13 +364,15 @@ DEPENDENCIES
rack-attack (= 6.7.0) rack-attack (= 6.7.0)
rack-cors (= 2.0.2) rack-cors (= 2.0.2)
rails (= 6.1.7.9) rails (= 6.1.7.9)
rake (= 12.3.3) rake (= 13.2.1)
react-rails (= 2.6.2) react-rails (= 2.6.2)
rspec-rails (= 4.0.2) rspec-rails (= 4.0.2)
rspec-retry (= 0.6.2) rspec-retry (= 0.6.2)
rswag-api (= 2.15.0) rswag-api (= 2.15.0)
rswag-specs (= 2.15.0) rswag-specs (= 2.15.0)
selenium-webdriver (= 4.17.0) selenium-webdriver (= 4.17.0)
sidekiq (= 7.3.5)
sidekiq-cron (= 2.0.1)
spring (= 2.1.1) spring (= 2.1.1)
spring-watcher-listen (= 2.0.1) spring-watcher-listen (= 2.0.1)
stripe (= 11.2.0) stripe (= 11.2.0)

View File

@@ -19,6 +19,16 @@ Astuto is an open source customer feedback tool. It helps you collect, manage an
<img src="./images/hero-image.png" /> <img src="./images/hero-image.png" />
</a> </a>
## Features
- **Roadmap**: show users what you're working on
- **Simple Sign In**: let users log in with email or any OAuth2 provider
- **Webhooks**: integrate with your existing tools (e.g. Jira, Trello, Slack)
- **API**: programmatically manage your feedback space with our REST API
- **Moderation Queue**: decide whether to show new feedback immediately or request approval
- **Anonymous Feedback**: enable unregistered users to publish feedback
- **... and more**: invitation system, brand customization, recap emails for administrators, private site settings, and more!
## Get started ## Get started
### Hosted ### Hosted
@@ -73,7 +83,7 @@ volumes:
## Documentation ## Documentation
Check out [docs.astuto.io](https://docs.astuto.io/) to learn how to deploy Astuto, configure custom OAuth providers, customize appearance and more! Check out [docs.astuto.io](https://docs.astuto.io/) to learn how to deploy Astuto, configure custom OAuth providers and webhooks, use our REST API and more!
## Contributing ## Contributing

View File

@@ -31,6 +31,7 @@
@import 'components/SiteSettings/Authentication'; @import 'components/SiteSettings/Authentication';
@import 'components/SiteSettings/Appearance/'; @import 'components/SiteSettings/Appearance/';
@import 'components/SiteSettings/Invitations'; @import 'components/SiteSettings/Invitations';
@import 'components/SiteSettings/Webhooks';
/* Moderation Components */ /* Moderation Components */
@import 'components/Moderation/Feedback'; @import 'components/Moderation/Feedback';

View File

@@ -23,6 +23,10 @@
padding: 15px; padding: 15px;
margin: 0 auto; margin: 0 auto;
label[for=user_notifications_enabled] {
@extend .mb-0;
}
} }
.apiKeyGenerateButton { width: 100%; } .apiKeyGenerateButton { width: 100%; }

View File

@@ -173,6 +173,7 @@ body {
} }
.badgeWarning { @extend .badge-warning; } .badgeWarning { @extend .badge-warning; }
.badgeDanger { @extend .badge-danger; } .badgeDanger { @extend .badge-danger; }
.badgeSuccess { @extend .badge-success; }
.container { .container {
max-width: 960px; max-width: 960px;
@@ -188,7 +189,7 @@ body {
height: 2px; height: 2px;
} }
.gravatar { .avatar {
border-radius: 100%; border-radius: 100%;
} }
@@ -349,4 +350,59 @@ body {
position: relative; position: relative;
bottom: 8px; bottom: 8px;
scale: 80%; scale: 80%;
}
.dropzone {
border: 2px dashed var(--astuto-grey);
border-radius: 8px;
padding: 8px;
cursor: pointer;
}
.dropzone-disabled {
cursor: not-allowed;
}
.dropzone-accept {
border-color: rgb(0, 189, 0);
}
.dropzone-reject {
border-color: rgb(255, 0, 0);
}
.thumbnailsContainer {
display: flex;
flex-direction: row;
flex-wrap: wrap;
margin-top: 16px;
.thumbnail {
display: inline-flex;
border-radius: 2px;
border: 1px solid #eaeaea;
margin-bottom: 8px;
margin-right: 8px;
width: 80px;
height: 80px;
padding: 4px;
box-sizing: border-box;
.thumbnailInner {
display: flex;
min-width: 0;
overflow: hidden;
.thumbnailImage {
display: block;
width: auto;
height: 100%;
}
}
&.thumbnailToDelete {
border: 2px solid red;
.thumbnailInner { filter: grayscale(100%); }
}
}
} }

View File

@@ -1,6 +1,8 @@
.commentsContainer { .commentsContainer {
@extend .mt-2; @extend .mt-2;
.attachFilesSectionHidden { display: none; }
.commentForm { .commentForm {
@extend @extend
.form-control, .form-control,
@@ -22,22 +24,42 @@
.flex-column, .flex-column,
.mt-4; .mt-4;
.commentBodyForm { .newCommentBodyForm {
@extend .d-flex; @extend .d-flex;
} }
.newCommentFooter {
@extend
.d-flex,
.justify-content-between,
.align-items-center;
.attachFilesSection {
margin-left: 58px;
margin-top: 1rem;
}
}
.commentIsUpdateForm { .commentIsUpdateForm {
@extend @extend
.d-flex, .d-flex,
.justify-content-between, .flex-column,
.align-self-start,
.mt-3; .mt-3;
margin-left: 58px; &.commentIsUpdateFormWithAttachment {
margin-right: 108px;
.checkboxSwitch { align-self: flex-end; }
}
&.commentIsUpdateFormWithoutAttachment {
margin-left: 58px;
.checkboxSwitch { align-self: flex-start; }
}
} }
.currentUserAvatar { .currentUserAvatar {
@extend @extend
.gravatar, .avatar,
.align-self-end, .align-self-end,
.mr-2; .mr-2;
} }
@@ -53,6 +75,13 @@
} }
.editCommentForm { .editCommentForm {
@extend .my-3;
background-color: rgb(255 255 215);
padding: 16px;
.commentFormContainer, .editCommentFormAttachments { @extend .d-block; }
textarea { textarea {
@extend .my-2; @extend .my-2;
} }
@@ -63,6 +92,13 @@
.justify-content-between; .justify-content-between;
} }
.editCommentFormFooter {
@extend
.d-flex,
.justify-content-between,
.align-items-center;
}
.editCommentFormActions { @extend .d-flex; } .editCommentFormActions { @extend .d-flex; }
} }
@@ -114,6 +150,17 @@
p:nth-child(1) { @extend .m-0; } p:nth-child(1) { @extend .m-0; }
} }
.commentAttachments {
@extend .mb-4;
.commentAttachment {
@extend .mx-2, .p-2;
height: 60px;
border: thin solid var(--astuto-grey-light);
}
}
.commentFooter { .commentFooter {
@extend .d-flex; @extend .d-flex;

View File

@@ -15,7 +15,7 @@ ul.usersList {
.my-2, .my-2,
.p-3; .p-3;
.userGravatar { .userAvatar {
@extend .mr-3, .align-self-center; @extend .mr-3, .align-self-center;
} }

View File

@@ -71,7 +71,7 @@
.p-2, .p-2,
.my-1; .my-1;
.gravatar { .avatar {
@extend .align-self-center; @extend .align-self-center;
} }
@@ -149,13 +149,24 @@
} }
} }
.postAttachments {
@extend .mb-4;
.postAttachment {
@extend .mx-2, .p-2;
height: 80px;
border: thin solid var(--astuto-grey-light);
}
}
.postFooter { .postFooter {
.postAuthor { .postAuthor {
@extend @extend
.mutedText, .mutedText,
.mb-2; .mb-2;
.postAuthorAvatar { @extend .gravatar; } .postAuthorAvatar { @extend .avatar; }
} }
.postFooterActions { @extend .d-flex; } .postFooterActions { @extend .d-flex; }
@@ -173,7 +184,7 @@
} }
.postEditFormButtons { .postEditFormButtons {
@extend .d-flex, .justify-content-end; @extend .d-flex, .justify-content-end, .mt-3;
} }
#selectPickerBoard { margin-right: 4px !important; } #selectPickerBoard { margin-right: 4px !important; }

View File

@@ -52,4 +52,27 @@
font-size: 18px; font-size: 18px;
} }
.oAuthLogoPreview {
@extend .d-block, .my-2;
position: relative;
display: inline-block;
width: fit-content;
height: fit-content;
}
.oAuthLogoPreview .oAuthLogoPreviewImg {
display: block;
height: 50px;
width: auto;
}
.oAuthLogoPreview.oAuthLogoPreviewShouldDelete {
border: 2px solid red;
.oAuthLogoPreviewImg { filter: grayscale(100%); }
}
.oAuthLogoActions { @extend .d-flex; }
} }

View File

@@ -8,4 +8,29 @@
.generalSiteSettingsSubmit { .generalSiteSettingsSubmit {
@extend .mb-4; @extend .mb-4;
} }
.siteLogoPreview, .siteFaviconPreview {
@extend .d-block, .my-2;
position: relative;
display: inline-block;
width: fit-content; /* Container matches the image size */
height: fit-content; /* Adjusts height to match the image */
}
.siteLogoPreview .siteLogoPreviewImg, .siteFaviconPreview .siteFaviconPreviewImg {
display: block;
height: 50px; /* Fixed height for the image */
width: auto; /* Maintain the aspect ratio */
}
.siteLogoPreview.siteLogoPreviewShouldDelete, .siteFaviconPreview.siteFaviconPreviewShouldDelete {
border: 2px solid red;
.siteLogoPreviewImg, .siteFaviconPreviewImg {
filter: grayscale(100%);
}
}
.siteLogoActions, .siteFaviconActions { @extend .d-flex; }
} }

View File

@@ -59,7 +59,7 @@
.pl-0; .pl-0;
list-style: none; list-style: none;
height: 500px; max-height: 500px;
overflow-y: scroll; overflow-y: scroll;
li.invitationListItem { li.invitationListItem {

View File

@@ -0,0 +1,126 @@
.previewStyling {
@extend .mt-4, .p-3;
max-height: 400px;
max-width: 600px;
background-color: #f4f4f4;
border-radius: 8px;
}
a.backButton {
@extend .mb-2, .align-self-start;
font-size: 18px;
}
.webhooksIndexPage {
.webhooksTitle {
@extend
.d-flex,
.mb-2;
button {
@extend .ml-2;
height: min-content;
}
}
.webhooksList {
@extend .pl-1;
list-style: none;
h4 { @extend .mb-0, .mt-2; }
ul { @extend .pl-0; }
.webhookListItem {
@extend
.d-flex,
.justify-content-between,
.mb-2,
.p-2;
.webhookInfo {
@extend .d-flex;
column-gap: 32px;
.webhookName { font-size: 18px; }
.webhookDescription {
@extend .mutedText, .mb-1;
font-size: smaller;
}
}
.webhookActions {
@extend .d-flex;
align-self: center;
}
}
}
}
.webhookFormPage {
#httpBody {
min-height: 200px;
}
.httpBodyActions {
@extend
.d-flex,
.justify-content-between,
.mt-1;
div:first-child {
min-width: 250px;
}
}
.previewHttpBody {
display: block;
text-align: right;
}
.urlAndHttpBodyPreview {
@extend .mt-3;
#preview {
@extend .previewStyling;
margin-top: 0 !important;
}
}
.formGroupHttpHeaders {
@extend .mb-0;
div.formRow { @extend .mb-0; }
div.formRow:last-child {
div.formGroup { @extend .mb-1; }
}
}
.deleteHeaderActionLinkContainer {
@extend
.d-flex,
.align-self-end;
}
.submitWebhookFormButton { @extend .mt-4; }
}
.webhookTestPage {
.webhookActions {
@extend .d-flex;
}
.webhookTestResponse {
@extend .mt-4;
#testHttpResponse { @extend .previewStyling; }
}
}

View File

@@ -35,7 +35,13 @@ class ApplicationController < ActionController::Base
protected protected
def configure_devise_permitted_parameters def configure_devise_permitted_parameters
additional_permitted_parameters = [:full_name, :notifications_enabled, :invitation_token] additional_permitted_parameters = [
:full_name,
:notifications_enabled,
:recap_notification_frequency,
:invitation_token,
:avatar,
]
devise_parameter_sanitizer.permit(:sign_up, keys: additional_permitted_parameters) devise_parameter_sanitizer.permit(:sign_up, keys: additional_permitted_parameters)
devise_parameter_sanitizer.permit(:account_update, keys: additional_permitted_parameters) devise_parameter_sanitizer.permit(:account_update, keys: additional_permitted_parameters)

View File

@@ -11,6 +11,7 @@ class CommentsController < ApplicationController
:is_post_update, :is_post_update,
:created_at, :created_at,
:updated_at, :updated_at,
'users.id as user_id', # required for avatar_url
'users.full_name as user_full_name', 'users.full_name as user_full_name',
'users.email as user_email', 'users.email as user_email',
'users.role as user_role', 'users.role as user_role',
@@ -18,6 +19,17 @@ class CommentsController < ApplicationController
.where(post_id: params[:post_id]) .where(post_id: params[:post_id])
.left_outer_joins(:user) .left_outer_joins(:user)
.order(created_at: :desc) .order(created_at: :desc)
.includes(user: { avatar_attachment: :blob }) # Preload avatars
comments = comments.map do |comment|
user_avatar_url = comment.user.avatar.attached? ? comment.user.avatar.blob.url : nil
attachment_urls = comment.attachments.order(:created_at).map { |attachment| attachment.blob.url }
comment.attributes.merge(
attachment_urls: attachment_urls,
user_avatar: user_avatar_url
)
end
render json: comments render json: comments
end end
@@ -26,11 +38,22 @@ class CommentsController < ApplicationController
@comment = Comment.new @comment = Comment.new
@comment.assign_attributes(comment_create_params) @comment.assign_attributes(comment_create_params)
# handle attachments
if Current.tenant.tenant_setting.allow_attachment_upload && params[:comment][:attachments].present?
@comment.attachments.attach(params[:comment][:attachments])
end
if @comment.save if @comment.save
SendNotificationForCommentWorkflow.new(comment: @comment).run SendNotificationForCommentWorkflow.new(comment: @comment).run
render json: @comment.attributes.merge( render json: @comment.attributes.merge(
{ user_full_name: current_user.full_name, user_email: current_user.email, user_role: current_user.role_before_type_cast } {
attachment_urls: @comment.attachments.order(:created_at).map { |attachment| attachment.blob.url },
user_full_name: current_user.full_name,
user_email: current_user.email,
user_avatar: current_user.avatar.attached? ? current_user.avatar.blob.url : nil,
user_role: current_user.role_before_type_cast
}
), status: :created ), status: :created
else else
render json: { render json: {
@@ -43,9 +66,29 @@ class CommentsController < ApplicationController
@comment = Comment.find(params[:id]) @comment = Comment.find(params[:id])
authorize @comment authorize @comment
if @comment.update(comment_update_params) @comment.assign_attributes(comment_update_params)
# handle attachment deletion
if params[:comment][:attachments_to_delete].present? && @comment.attachments.attached?
@comment.attachments.order(:created_at).each_with_index do |attachment, index|
attachment.purge if params[:comment][:attachments_to_delete].include?(index.to_s)
end
end
# handle attachments
if Current.tenant.tenant_setting.allow_attachment_upload && params[:comment][:attachments].present?
@comment.attachments.attach(params[:comment][:attachments])
end
if @comment.save
render json: @comment.attributes.merge( render json: @comment.attributes.merge(
{ user_full_name: @comment.user.full_name, user_email: @comment.user.email, user_role: @comment.user.role_before_type_cast } {
attachment_urls: @comment.attachments.order(:created_at).map { |attachment| attachment.blob.url },
user_full_name: @comment.user.full_name,
user_email: @comment.user.email,
user_avatar: @comment.user.avatar.attached? ? @comment.user.avatar.blob.url : nil,
user_role: @comment.user.role_before_type_cast
}
) )
else else
render json: { render json: {

View File

@@ -32,7 +32,7 @@ class InvitationsController < ApplicationController
) )
) )
InvitationMailer.invite(invitation: invitation, subject: subject, body: body_with_link).deliver_now InvitationMailer.invite(invitation: invitation, subject: subject, body: body_with_link).deliver_later
num_invitations_sent += 1 num_invitations_sent += 1
end end

View File

@@ -7,10 +7,17 @@ class LikesController < ApplicationController
.select( .select(
:id, :id,
:full_name, :full_name,
:email :email,
'users.id as user_id', # required for avatar_url
) )
.left_outer_joins(:user) .left_outer_joins(:user)
.where(post_id: params[:post_id]) .where(post_id: params[:post_id])
.includes(user: { avatar_attachment: :blob }) # Preload avatars
likes = likes.map do |like|
user_avatar_url = like.user.avatar.attached? ? like.user.avatar.blob.url : nil
like.attributes.merge(user_avatar: user_avatar_url)
end
render json: likes render json: likes
end end
@@ -23,6 +30,7 @@ class LikesController < ApplicationController
id: like.id, id: like.id,
full_name: current_user.full_name, full_name: current_user.full_name,
email: current_user.email, email: current_user.email,
user_avatar: current_user.avatar.attached? ? current_user.avatar.blob.url : nil,
}, status: :created }, status: :created
else else
render json: { render json: {

View File

@@ -0,0 +1,14 @@
class LocalFilesController < ApplicationController
def show
blob = ActiveStorage::Blob.find_by(key: params[:key])
if blob.present? && blob.service.is_a?(ActiveStorage::Service::DiskService)
send_file blob.service.path_for(blob.key),
type: blob.content_type, # Set correct MIME type
disposition: :inline, # Show in browser
filename: blob.filename.to_s # Ensure correct filename
else
head :not_found
end
end
end

View File

@@ -170,6 +170,10 @@ class OAuthsController < ApplicationController
@o_auth = OAuth.find(params[:id]) @o_auth = OAuth.find(params[:id])
authorize @o_auth authorize @o_auth
if params[:o_auth][:should_delete_logo] == "true"
@o_auth.logo.purge if @o_auth.logo.attached?
end
if @o_auth.update(o_auth_params) if @o_auth.update(o_auth_params)
render json: to_json_custom(@o_auth) render json: to_json_custom(@o_auth)
else else
@@ -198,7 +202,7 @@ class OAuthsController < ApplicationController
def to_json_custom(o_auth) def to_json_custom(o_auth)
o_auth.as_json( o_auth.as_json(
methods: [:callback_url, :default_o_auth_is_enabled], methods: [:logo_url, :callback_url, :default_o_auth_is_enabled],
except: [:client_secret] except: [:client_secret]
) )
end end
@@ -206,6 +210,10 @@ class OAuthsController < ApplicationController
def o_auth_params def o_auth_params
params params
.require(:o_auth) .require(:o_auth)
.permit(policy(@o_auth).permitted_attributes) .permit(
policy(@o_auth)
.permitted_attributes
.concat([{ additional_params: [:should_delete_logo] }])
)
end end
end end

View File

@@ -4,12 +4,19 @@ class PostStatusChangesController < ApplicationController
.select( .select(
:post_status_id, :post_status_id,
:created_at, :created_at,
'users.id as user_id', # required for avatar_url
'users.full_name as user_full_name', 'users.full_name as user_full_name',
'users.email as user_email', 'users.email as user_email',
) )
.where(post_id: params[:post_id]) .where(post_id: params[:post_id])
.left_outer_joins(:user) .left_outer_joins(:user)
.order(created_at: :asc) .order(created_at: :asc)
.includes(user: { avatar_attachment: :blob }) # Preload avatars
post_status_changes = post_status_changes.map do |post_status_change|
user_avatar_url = post_status_change.user.avatar.attached? ? post_status_change.user.avatar.blob.url : nil
post_status_change.attributes.merge(user_avatar: user_avatar_url)
end
render json: post_status_changes render json: post_status_changes
end end

View File

@@ -32,6 +32,11 @@ class PostsController < ApplicationController
# apply post status filter if present # apply post status filter if present
posts = posts.where(post_status_id: params[:post_status_ids].map { |id| id == "0" ? nil : id }) if params[:post_status_ids].present? posts = posts.where(post_status_id: params[:post_status_ids].map { |id| id == "0" ? nil : id }) if params[:post_status_ids].present?
# check if posts have attachments
posts = posts.map do |post|
post.attributes.merge(has_attachments: post.attachments.attached?)
end
render json: posts render json: posts
end end
@@ -57,6 +62,11 @@ class PostsController < ApplicationController
@post = Post.new(approval_status: approval_status) @post = Post.new(approval_status: approval_status)
@post.assign_attributes(post_create_params(is_anonymous: is_anonymous)) @post.assign_attributes(post_create_params(is_anonymous: is_anonymous))
# handle attachments
if Current.tenant.tenant_setting.allow_attachment_upload && params[:post][:attachments].present? && !is_anonymous
@post.attachments.attach(params[:post][:attachments])
end
if @post.save if @post.save
Follow.create(post_id: @post.id, user_id: current_user.id) unless is_anonymous Follow.create(post_id: @post.id, user_id: current_user.id) unless is_anonymous
@@ -87,7 +97,7 @@ class PostsController < ApplicationController
) )
.eager_load(:user) # left outer join .eager_load(:user) # left outer join
.find(params[:id]) .find(params[:id])
@post_statuses = PostStatus.select(:id, :name, :color).order(order: :asc) @post_statuses = PostStatus.select(:id, :name, :color).order(order: :asc)
@board = @post.board @board = @post.board
@@ -96,7 +106,7 @@ class PostsController < ApplicationController
respond_to do |format| respond_to do |format|
format.html format.html
format.json { render json: @post } format.json { render json: @post.as_json.merge(attachment_urls: @post.attachments.order(:created_at).map { |attachment| attachment.blob.url }) }
end end
end end
@@ -106,6 +116,18 @@ class PostsController < ApplicationController
@post.assign_attributes(post_update_params) @post.assign_attributes(post_update_params)
# handle attachment deletion
if params[:post][:attachments_to_delete].present? && @post.attachments.attached?
@post.attachments.order(:created_at).each_with_index do |attachment, index|
attachment.purge if params[:post][:attachments_to_delete].include?(index.to_s)
end
end
# handle attachments
if Current.tenant.tenant_setting.allow_attachment_upload && params[:post][:attachments].present?
@post.attachments.attach(params[:post][:attachments])
end
if @post.save if @post.save
if @post.post_status_id_previously_changed? if @post.post_status_id_previously_changed?
ExecutePostStatusChangeLogicWorkflow.new( ExecutePostStatusChangeLogicWorkflow.new(
@@ -115,7 +137,7 @@ class PostsController < ApplicationController
).run ).run
end end
render json: @post render json: @post.as_json.merge(attachment_urls: @post.attachments.order(:created_at).map { |attachment| attachment.blob.url })
else else
render json: { render json: {
error: @post.errors.full_messages error: @post.errors.full_messages
@@ -150,6 +172,7 @@ class PostsController < ApplicationController
:user_id, :user_id,
:board_id, :board_id,
:created_at, :created_at,
'users.id as user_id', # required for avatar_url
'users.email as user_email', 'users.email as user_email',
'users.full_name as user_full_name' 'users.full_name as user_full_name'
) )
@@ -157,6 +180,12 @@ class PostsController < ApplicationController
.where(approval_status: ["pending", "rejected"]) .where(approval_status: ["pending", "rejected"])
.order_by(created_at: :desc) .order_by(created_at: :desc)
.limit(100) .limit(100)
.includes(user: { avatar_attachment: :blob }) # Preload avatars
posts = posts.map do |post|
user_avatar_url = post.user.avatar.attached? ? post.user.avatar.blob.url : nil
post.attributes.merge(user_avatar: user_avatar_url)
end
render json: posts render json: posts
end end
@@ -186,6 +215,10 @@ class PostsController < ApplicationController
def post_update_params def post_update_params
params params
.require(:post) .require(:post)
.permit(policy(@post).permitted_attributes_for_update) .permit(
policy(@post)
.permitted_attributes_for_update
.concat([{ additional_params: [:attachments_to_delete] }])
)
end end
end end

View File

@@ -77,6 +77,13 @@ class RegistrationsController < Devise::RegistrationsController
respond_with_navigational(resource){ redirect_to after_sign_out_path_for(resource_name) } respond_with_navigational(resource){ redirect_to after_sign_out_path_for(resource_name) }
end end
def delete_avatar
user = User.find(current_user.id)
user.avatar.purge
render json: { success: true }, status: :ok
end
def send_set_password_instructions def send_set_password_instructions
user = User.find_by_email(params[:email]) user = User.find_by_email(params[:email])

View File

@@ -19,6 +19,9 @@ class SiteSettingsController < ApplicationController
def roadmap def roadmap
end end
def webhooks
end
def invitations def invitations
@invitations = Invitation.all.order(updated_at: :desc) @invitations = Invitation.all.order(updated_at: :desc)
end end

View File

@@ -11,7 +11,11 @@ class TenantsController < ApplicationController
end end
def show def show
render json: Current.tenant_or_raise! tenant = Current.tenant_or_raise!
tenant.attributes.merge(site_logo_url: tenant.site_logo.attached? ? tenant.site_logo.blob.url : nil)
render json: tenant
end end
def create def create
@@ -43,7 +47,8 @@ class TenantsController < ApplicationController
email: params[:user][:email], email: params[:user][:email],
password: is_o_auth_login ? Devise.friendly_token : params[:user][:password], password: is_o_auth_login ? Devise.friendly_token : params[:user][:password],
has_set_password: !is_o_auth_login, has_set_password: !is_o_auth_login,
role: "owner" role: "owner",
recap_notification_frequency: "daily"
) )
if is_o_auth_login if is_o_auth_login
@@ -91,7 +96,27 @@ class TenantsController < ApplicationController
# to avoid unique constraint violation # to avoid unique constraint violation
params[:tenant][:custom_domain] = nil if params[:tenant][:custom_domain].blank? params[:tenant][:custom_domain] = nil if params[:tenant][:custom_domain].blank?
if @tenant.update(tenant_update_params) # Handle site logo attachment
if params[:tenant][:should_delete_site_logo] == "true"
@tenant.site_logo.purge if @tenant.site_logo.attached?
elsif params[:tenant][:site_logo].present?
@tenant.site_logo.purge if @tenant.site_logo.attached?
@tenant.site_logo.attach(params[:tenant][:site_logo])
should_delete_old_site_logo = true
end
# Handle site favicon attachment
if params[:tenant][:should_delete_site_favicon] == "true"
@tenant.site_favicon.purge if @tenant.site_favicon.attached?
elsif params[:tenant][:site_favicon].present?
@tenant.site_favicon.purge if @tenant.site_favicon.attached?
@tenant.site_favicon.attach(params[:tenant][:site_favicon])
end
@tenant.assign_attributes(tenant_update_params)
@tenant.old_site_logo = nil if should_delete_old_site_logo
if @tenant.save
render json: @tenant render json: @tenant
else else
render json: { render json: {
@@ -127,7 +152,8 @@ class TenantsController < ApplicationController
policy(@tenant) policy(@tenant)
.permitted_attributes_for_update .permitted_attributes_for_update
.concat([{ .concat([{
tenant_setting_attributes: policy(@tenant.tenant_setting).permitted_attributes_for_update tenant_setting_attributes: policy(@tenant.tenant_setting).permitted_attributes_for_update,
additional_params: [:should_delete_site_logo, :should_delete_site_favicon]
}]) # in order to permit nested attributes for tenant_setting }]) # in order to permit nested attributes for tenant_setting
) )
end end

View File

@@ -8,6 +8,10 @@ class UsersController < ApplicationController
.all .all
.order(role: :desc, created_at: :desc) .order(role: :desc, created_at: :desc)
@users = @users.map do |user|
user.attributes.merge(avatar_url: user.avatar.attached? ? user.avatar.blob.url : nil)
end
render json: @users render json: @users
end end

View File

@@ -0,0 +1,97 @@
class WebhooksController < ApplicationController
def index
authorize Webhook
@webhooks = Webhook.order(trigger: :asc, created_at: :desc)
render json: @webhooks
end
def create
@webhook = Webhook.new
@webhook.assign_attributes(webhook_params)
authorize @webhook
if @webhook.save
render json: @webhook, status: :created
else
render json: {
error: @webhook.errors.full_messages
}, status: :unprocessable_entity
end
end
def update
@webhook = Webhook.find(params[:id])
authorize @webhook
if @webhook.update(webhook_params)
render json: @webhook
else
render json: {
error: @webhook.errors.full_messages
}, status: :unprocessable_entity
end
end
def destroy
@webhook = Webhook.find(params[:id])
authorize @webhook
if @webhook.destroy
render json: {
id: params[:id]
}, status: :accepted
else
render json: {
error: @webhook.errors.full_messages
}, status: :unprocessable_entity
end
end
def preview
context = CreateLiquidTemplateContextWorkflow.new(
webhook_trigger: params[:webhook][:trigger],
is_test: true,
).run
url_template = Liquid::Template.parse(params[:webhook][:url])
url_preview = url_template.render(context)
http_body_template = Liquid::Template.parse(params[:webhook][:http_body])
http_body_preview = http_body_template.render(context)
render json: {
url_preview: url_preview,
http_body_preview: http_body_preview,
}, status: :ok
rescue => e
render json: {
error: e.message
}, status: :unprocessable_entity
end
def test
response = RunWebhook.perform_now(
webhook_id: params[:id],
current_tenant_id: Current.tenant.id,
is_test: true
)
render json: {
response: response,
}, status: :ok
rescue => e
render json: {
error: e.message
}, status: :unprocessable_entity
end
private
def webhook_params
params
.require(:webhook)
.permit(policy(@webhook).permitted_attributes)
end
end

View File

@@ -39,24 +39,37 @@ module ApplicationHelper
end end
def get_url_for(url_helper, resource: nil, disallow_custom_domain: false, options: {}) def get_url_for(url_helper, resource: nil, disallow_custom_domain: false, options: {})
custom_domain = Current.tenant.custom_domain if not disallow_custom_domain and Current.tenant logger.info { "(start) Call to get_url_for with tenant: #{Current.tenant&.inspect}, url_helper = #{url_helper}, resource = #{resource}, disallow_custom_domain = #{disallow_custom_domain}, options = #{options}" }
custom_domain = Current.tenant.custom_domain if not disallow_custom_domain and Current.tenant
subdomain = ''
host = ''
if not options[:subdomain].blank?
subdomain = options[:subdomain]
end
if options[:subdomain].blank? && Rails.application.multi_tenancy? && (custom_domain.blank? || disallow_custom_domain) if options[:subdomain].blank? && Rails.application.multi_tenancy? && (custom_domain.blank? || disallow_custom_domain)
options[:subdomain] = Current.tenant.subdomain subdomain = Current.tenant.subdomain
end end
if custom_domain.blank? || disallow_custom_domain if custom_domain.blank? || disallow_custom_domain
options[:host] = Rails.application.base_url host = Rails.application.base_url
host = host.gsub(%r{\Ahttps?://|/$}, '')
host = "#{subdomain}.#{host}" if subdomain.present?
else else
options[:host] = custom_domain host = custom_domain
end end
options[:host] = host
if Rails.application.base_url.include?('https') if Rails.application.base_url.include?('https')
options[:protocol] = 'https' options[:protocol] = 'https'
else else
options[:protocol] = 'http' options[:protocol] = 'http'
end end
logger.info { "(end) Call to get_url_for with options = #{options}, resulting url = #{resource ? url_helper.call(resource, options) : url_helper.call(options)}" }
resource ? url_helper.call(resource, options) : url_helper.call(options) resource ? url_helper.call(resource, options) : url_helper.call(options)
end end

View File

@@ -3,8 +3,8 @@ import { ThunkAction } from 'redux-thunk';
import { State } from '../../reducers/rootReducer'; import { State } from '../../reducers/rootReducer';
import ICommentJSON from '../../interfaces/json/IComment'; import ICommentJSON from '../../interfaces/json/IComment';
import buildRequestHeaders from '../../helpers/buildRequestHeaders';
import HttpStatus from '../../constants/http_status'; import HttpStatus from '../../constants/http_status';
import buildFormData from '../../helpers/buildFormData';
export const COMMENT_SUBMIT_START = 'COMMENT_SUBMIT_START'; export const COMMENT_SUBMIT_START = 'COMMENT_SUBMIT_START';
interface CommentSubmitStartAction { interface CommentSubmitStartAction {
@@ -53,21 +53,24 @@ export const submitComment = (
body: string, body: string,
parentId: number, parentId: number,
isPostUpdate: boolean, isPostUpdate: boolean,
attachments: File[],
authenticityToken: string, authenticityToken: string,
): ThunkAction<void, State, null, Action<string>> => async (dispatch) => { ): ThunkAction<void, State, null, Action<string>> => async (dispatch) => {
dispatch(commentSubmitStart(parentId)); dispatch(commentSubmitStart(parentId));
try { try {
let formDataObj = {
'comment[body]': body,
'comment[parent_id]': parentId,
'comment[is_post_update]': isPostUpdate,
'comment[attachments][]': attachments,
};
const requestBody = buildFormData(formDataObj);
const res = await fetch(`/posts/${postId}/comments`, { const res = await fetch(`/posts/${postId}/comments`, {
method: 'POST', method: 'POST',
headers: buildRequestHeaders(authenticityToken), headers: { 'X-CSRF-Token': authenticityToken },
body: JSON.stringify({ body: requestBody,
comment: {
body,
parent_id: parentId,
is_post_update: isPostUpdate,
},
}),
}); });
const json = await res.json(); const json = await res.json();
@@ -76,7 +79,11 @@ export const submitComment = (
} else { } else {
dispatch(commentSubmitFailure(parentId, json.error)); dispatch(commentSubmitFailure(parentId, json.error));
} }
return Promise.resolve(res);
} catch (e) { } catch (e) {
dispatch(commentSubmitFailure(parentId, e)); dispatch(commentSubmitFailure(parentId, e));
return Promise.resolve(null);
} }
} }

View File

@@ -5,6 +5,7 @@ import HttpStatus from "../../constants/http_status";
import buildRequestHeaders from "../../helpers/buildRequestHeaders"; import buildRequestHeaders from "../../helpers/buildRequestHeaders";
import ICommentJSON from "../../interfaces/json/IComment"; import ICommentJSON from "../../interfaces/json/IComment";
import { State } from "../../reducers/rootReducer"; import { State } from "../../reducers/rootReducer";
import buildFormData from "../../helpers/buildFormData";
export const COMMENT_UPDATE_START = 'COMMENT_UPDATE_START'; export const COMMENT_UPDATE_START = 'COMMENT_UPDATE_START';
interface CommentUpdateStartAction { interface CommentUpdateStartAction {
@@ -49,20 +50,25 @@ export const updateComment = (
commentId: number, commentId: number,
body: string, body: string,
isPostUpdate: boolean, isPostUpdate: boolean,
attachmentsToDelete: number[],
attachments: File[],
authenticityToken: string, authenticityToken: string,
): ThunkAction<void, State, null, Action<string>> => async (dispatch) => { ): ThunkAction<void, State, null, Action<string>> => async (dispatch) => {
dispatch(commentUpdateStart()); dispatch(commentUpdateStart());
try { try {
let formDataObj = {
'comment[body]': body,
'comment[is_post_update]': isPostUpdate,
'comment[attachments_to_delete][]': attachmentsToDelete,
'comment[attachments][]': attachments,
};
const requestBody = buildFormData(formDataObj);
const res = await fetch(`/posts/${postId}/comments/${commentId}`, { const res = await fetch(`/posts/${postId}/comments/${commentId}`, {
method: 'PATCH', method: 'PATCH',
headers: buildRequestHeaders(authenticityToken), headers: { 'X-CSRF-Token': authenticityToken },
body: JSON.stringify({ body: requestBody,
comment: {
body,
is_post_update: isPostUpdate,
},
}),
}); });
const json = await res.json(); const json = await res.json();

View File

@@ -2,9 +2,9 @@ import { Action } from "redux";
import { ThunkAction } from "redux-thunk"; import { ThunkAction } from "redux-thunk";
import HttpStatus from "../../constants/http_status"; import HttpStatus from "../../constants/http_status";
import buildRequestHeaders from "../../helpers/buildRequestHeaders";
import { IOAuth, IOAuthJSON, oAuthJS2JSON } from "../../interfaces/IOAuth"; import { IOAuth, IOAuthJSON, oAuthJS2JSON } from "../../interfaces/IOAuth";
import { State } from "../../reducers/rootReducer"; import { State } from "../../reducers/rootReducer";
import buildFormData from "../../helpers/buildFormData";
export const OAUTH_SUBMIT_START = 'OAUTH_SUBMIT_START'; export const OAUTH_SUBMIT_START = 'OAUTH_SUBMIT_START';
interface OAuthSubmitStartAction { interface OAuthSubmitStartAction {
@@ -46,20 +46,26 @@ const oAuthSubmitFailure = (error: string): OAuthSubmitFailureAction => ({
export const submitOAuth = ( export const submitOAuth = (
oAuth: IOAuth, oAuth: IOAuth,
oAuthLogo: File = null,
authenticityToken: string, authenticityToken: string,
): ThunkAction<void, State, null, Action<string>> => async (dispatch) => { ): ThunkAction<void, State, null, Action<string>> => async (dispatch) => {
dispatch(oAuthSubmitStart()); dispatch(oAuthSubmitStart());
let formDataObj = {};
Object.entries(oAuthJS2JSON(oAuth)).forEach(([key, value]) => {
formDataObj[`o_auth[${key}]`] = value;
});
formDataObj['o_auth[logo]'] = oAuthLogo;
formDataObj['o_auth[is_enabled]'] = false;
const body = buildFormData(formDataObj);
try { try {
const res = await fetch(`/o_auths`, { const res = await fetch(`/o_auths`, {
method: 'POST', method: 'POST',
headers: buildRequestHeaders(authenticityToken), headers: { 'X-CSRF-Token': authenticityToken },
body: JSON.stringify({ body,
o_auth: {
...oAuthJS2JSON(oAuth),
is_enabled: false,
},
}),
}); });
const json = await res.json(); const json = await res.json();

View File

@@ -1,10 +1,12 @@
import { Action } from "redux"; import { Action } from "redux";
import { ThunkAction } from "redux-thunk"; import { ThunkAction } from "redux-thunk";
import { ISiteSettingsOAuthForm } from "../../components/SiteSettings/Authentication/OAuthForm"; import { ISiteSettingsOAuthForm } from "../../components/SiteSettings/Authentication/OAuthForm";
import HttpStatus from "../../constants/http_status"; import HttpStatus from "../../constants/http_status";
import buildRequestHeaders from "../../helpers/buildRequestHeaders"; import buildRequestHeaders from "../../helpers/buildRequestHeaders";
import { IOAuthJSON } from "../../interfaces/IOAuth"; import { IOAuthJSON } from "../../interfaces/IOAuth";
import { State } from "../../reducers/rootReducer"; import { State } from "../../reducers/rootReducer";
import buildFormData from "../../helpers/buildFormData";
export const OAUTH_UPDATE_START = 'OAUTH_UPDATE_START'; export const OAUTH_UPDATE_START = 'OAUTH_UPDATE_START';
interface OAuthUpdateStartAction { interface OAuthUpdateStartAction {
@@ -48,6 +50,7 @@ interface UpdateOAuthParams {
id: number; id: number;
form?: ISiteSettingsOAuthForm; form?: ISiteSettingsOAuthForm;
isEnabled?: boolean; isEnabled?: boolean;
shouldDeleteLogo?: boolean;
authenticityToken: string; authenticityToken: string;
} }
@@ -55,6 +58,7 @@ export const updateOAuth = ({
id, id,
form = null, form = null,
isEnabled = null, isEnabled = null,
shouldDeleteLogo = false,
authenticityToken, authenticityToken,
}: UpdateOAuthParams): ThunkAction<void, State, null, Action<string>> => async (dispatch) => { }: UpdateOAuthParams): ThunkAction<void, State, null, Action<string>> => async (dispatch) => {
dispatch(oAuthUpdateStart()); dispatch(oAuthUpdateStart());
@@ -62,7 +66,7 @@ export const updateOAuth = ({
const o_auth = Object.assign({}, const o_auth = Object.assign({},
form !== null ? { form !== null ? {
name: form.name, name: form.name,
logo: form.logo, logo: form.logo ? form.logo : null,
client_id: form.clientId, client_id: form.clientId,
client_secret: form.clientSecret, client_secret: form.clientSecret,
authorize_url: form.authorizeUrl, authorize_url: form.authorizeUrl,
@@ -75,11 +79,20 @@ export const updateOAuth = ({
isEnabled !== null ? {is_enabled: isEnabled} : null, isEnabled !== null ? {is_enabled: isEnabled} : null,
); );
let formDataObj = {};
Object.entries(o_auth).forEach(([key, value]) => {
formDataObj[`o_auth[${key}]`] = value;
});
formDataObj['o_auth[should_delete_logo]'] = shouldDeleteLogo.toString();
const body = buildFormData(formDataObj);
try { try {
const res = await fetch(`/o_auths/${id}`, { const res = await fetch(`/o_auths/${id}`, {
method: 'PATCH', method: 'PATCH',
headers: buildRequestHeaders(authenticityToken), headers: { 'X-CSRF-Token': authenticityToken },
body: JSON.stringify({o_auth}), body,
}); });
const json = await res.json(); const json = await res.json();

View File

@@ -5,6 +5,7 @@ import buildRequestHeaders from "../../helpers/buildRequestHeaders";
import IPostJSON from "../../interfaces/json/IPost"; import IPostJSON from "../../interfaces/json/IPost";
import { State } from "../../reducers/rootReducer"; import { State } from "../../reducers/rootReducer";
import { PostApprovalStatus } from "../../interfaces/IPost"; import { PostApprovalStatus } from "../../interfaces/IPost";
import buildFormData from "../../helpers/buildFormData";
export const POST_UPDATE_START = 'POST_UPDATE_START'; export const POST_UPDATE_START = 'POST_UPDATE_START';
interface PostUpdateStartAction { interface PostUpdateStartAction {
@@ -50,22 +51,28 @@ export const updatePost = (
description: string, description: string,
boardId: number, boardId: number,
postStatusId: number, postStatusId: number,
attachmentsToDelete: number[],
attachments: File[],
authenticityToken: string, authenticityToken: string,
): ThunkAction<void, State, null, Action<string>> => async (dispatch) => { ): ThunkAction<void, State, null, Action<string>> => async (dispatch) => {
dispatch(postUpdateStart()); dispatch(postUpdateStart());
try { try {
let formDataObj = {
'post[title]': title,
'post[description]': description,
'post[board_id]': boardId,
'post[post_status_id]': postStatusId,
'post[attachments_to_delete][]': attachmentsToDelete,
'post[attachments][]': attachments,
};
const body = buildFormData(formDataObj);
const res = await fetch(`/posts/${id}`, { const res = await fetch(`/posts/${id}`, {
method: 'PATCH', method: 'PATCH',
headers: buildRequestHeaders(authenticityToken), headers: { 'X-CSRF-Token': authenticityToken },
body: JSON.stringify({ body,
post: {
title,
description,
board_id: boardId,
post_status_id: postStatusId,
}
}),
}); });
const json = await res.json(); const json = await res.json();

View File

@@ -2,10 +2,10 @@ import { Action } from "redux";
import { ThunkAction } from "redux-thunk"; import { ThunkAction } from "redux-thunk";
import HttpStatus from "../../constants/http_status"; import HttpStatus from "../../constants/http_status";
import buildRequestHeaders from "../../helpers/buildRequestHeaders";
import ITenantSetting from "../../interfaces/ITenantSetting"; import ITenantSetting from "../../interfaces/ITenantSetting";
import ITenantJSON from "../../interfaces/json/ITenant"; import ITenantJSON from "../../interfaces/json/ITenant";
import { State } from "../../reducers/rootReducer"; import { State } from "../../reducers/rootReducer";
import buildFormData from "../../helpers/buildFormData";
export const TENANT_UPDATE_START = 'TENANT_UPDATE_START'; export const TENANT_UPDATE_START = 'TENANT_UPDATE_START';
interface TenantUpdateStartAction { interface TenantUpdateStartAction {
@@ -47,7 +47,11 @@ const tenantUpdateFailure = (error: string): TenantUpdateFailureAction => ({
interface UpdateTenantParams { interface UpdateTenantParams {
siteName?: string; siteName?: string;
siteLogo?: string; siteLogo?: File;
shouldDeleteSiteLogo?: boolean;
oldSiteLogo?: string;
siteFavicon?: File;
shouldDeleteSiteFavicon?: boolean;
tenantSetting?: ITenantSetting; tenantSetting?: ITenantSetting;
locale?: string; locale?: string;
customDomain?: string; customDomain?: string;
@@ -57,6 +61,10 @@ interface UpdateTenantParams {
export const updateTenant = ({ export const updateTenant = ({
siteName = null, siteName = null,
siteLogo = null, siteLogo = null,
shouldDeleteSiteLogo = null,
oldSiteLogo = null,
siteFavicon = null,
shouldDeleteSiteFavicon = null,
tenantSetting = null, tenantSetting = null,
locale = null, locale = null,
customDomain = null, customDomain = null,
@@ -64,24 +72,32 @@ export const updateTenant = ({
}: UpdateTenantParams): ThunkAction<void, State, null, Action<string>> => async (dispatch) => { }: UpdateTenantParams): ThunkAction<void, State, null, Action<string>> => async (dispatch) => {
dispatch(tenantUpdateStart()); dispatch(tenantUpdateStart());
const tenant = Object.assign({},
siteName !== null ? { site_name: siteName } : null,
siteLogo !== null ? { site_logo: siteLogo } : null,
locale !== null ? { locale } : null,
customDomain !== null ? { custom_domain: customDomain } : null,
);
try { try {
const body = JSON.stringify({ let formDataObj = {
tenant: { 'tenant[site_name]': siteName,
...tenant, 'tenant[site_logo]': siteLogo,
tenant_setting_attributes: tenantSetting, 'tenant[should_delete_site_logo]': shouldDeleteSiteLogo.toString(),
}, 'tenant[old_site_logo]': oldSiteLogo,
}); 'tenant[site_favicon]': siteFavicon,
'tenant[should_delete_site_favicon]': shouldDeleteSiteFavicon.toString(),
'tenant[locale]': locale,
'tenant[custom_domain]': customDomain,
}
if (tenantSetting) {
Object.entries(tenantSetting).forEach(([key, value]) => {
formDataObj[`tenant[tenant_setting_attributes][${key}]`] = value;
});
}
const body = buildFormData(formDataObj);
const res = await fetch(`/tenants/0`, { const res = await fetch(`/tenants/0`, {
method: 'PATCH', method: 'PATCH',
headers: buildRequestHeaders(authenticityToken), headers: {
'X-CSRF-Token': authenticityToken,
// do not set Content-Type header when using FormData
},
body, body,
}); });
const json = await res.json(); const json = await res.json();

View File

@@ -0,0 +1,69 @@
import { Action } from "redux";
import { ThunkAction } from "redux-thunk";
import HttpStatus from "../../constants/http_status";
import buildRequestHeaders from "../../helpers/buildRequestHeaders";
import { State } from "../../reducers/rootReducer";
export const WEBHOOK_DELETE_START = 'WEBHOOK_DELETE_START';
interface WebhookDeleteStartAction {
type: typeof WEBHOOK_DELETE_START;
}
export const WEBHOOK_DELETE_SUCCESS = 'WEBHOOK_DELETE_SUCCESS';
interface WebhookDeleteSuccessAction {
type: typeof WEBHOOK_DELETE_SUCCESS;
id: number;
}
export const WEBHOOK_DELETE_FAILURE = 'WEBHOOK_DELETE_FAILURE';
interface WebhookDeleteFailureAction {
type: typeof WEBHOOK_DELETE_FAILURE;
error: string;
}
export type WebhookDeleteActionTypes =
WebhookDeleteStartAction |
WebhookDeleteSuccessAction |
WebhookDeleteFailureAction;
const webhookDeleteStart = (): WebhookDeleteStartAction => ({
type: WEBHOOK_DELETE_START,
});
const webhookDeleteSuccess = (
id: number,
): WebhookDeleteSuccessAction => ({
type: WEBHOOK_DELETE_SUCCESS,
id,
});
const webhookDeleteFailure = (error: string): WebhookDeleteFailureAction => ({
type: WEBHOOK_DELETE_FAILURE,
error,
});
export const deleteWebhook = (
id: number,
authenticityToken: string,
): ThunkAction<void, State, null, Action<string>> => (
async (dispatch) => {
dispatch(webhookDeleteStart());
try {
const res = await fetch(`/webhooks/${id}`, {
method: 'DELETE',
headers: buildRequestHeaders(authenticityToken),
});
const json = await res.json();
if (res.status === HttpStatus.Accepted) {
dispatch(webhookDeleteSuccess(id));
} else {
dispatch(webhookDeleteFailure(json.error));
}
} catch (error) {
dispatch(webhookDeleteFailure(error.message));
}
}
);

View File

@@ -0,0 +1,59 @@
import { Action } from 'redux';
import { ThunkAction } from 'redux-thunk';
import { IWebhookJSON } from '../../interfaces/IWebhook';
import { State } from '../../reducers/rootReducer';
export const WEBHOOKS_REQUEST_START = 'WEBHOOKS_REQUEST_START';
interface WebhooksRequestStartAction {
type: typeof WEBHOOKS_REQUEST_START;
}
export const WEBHOOKS_REQUEST_SUCCESS = 'WEBHOOKS_REQUEST_SUCCESS';
interface WebhooksRequestSuccessAction {
type: typeof WEBHOOKS_REQUEST_SUCCESS;
webhooks: Array<IWebhookJSON>;
}
export const WEBHOOKS_REQUEST_FAILURE = 'WEBHOOKS_REQUEST_FAILURE';
interface WebhooksRequestFailureAction {
type: typeof WEBHOOKS_REQUEST_FAILURE;
error: string;
}
export type WebhooksRequestActionTypes =
WebhooksRequestStartAction |
WebhooksRequestSuccessAction |
WebhooksRequestFailureAction;
const webhooksRequestStart = (): WebhooksRequestActionTypes => ({
type: WEBHOOKS_REQUEST_START,
});
const webhooksRequestSuccess = (
webhooks: Array<IWebhookJSON>
): WebhooksRequestActionTypes => ({
type: WEBHOOKS_REQUEST_SUCCESS,
webhooks,
});
const webhooksRequestFailure = (error: string): WebhooksRequestActionTypes => ({
type: WEBHOOKS_REQUEST_FAILURE,
error,
});
export const requestWebhooks = (): ThunkAction<void, State, null, Action<string>> => (
async (dispatch) => {
dispatch(webhooksRequestStart());
try {
const response = await fetch('/webhooks');
const json = await response.json();
dispatch(webhooksRequestSuccess(json));
} catch (e) {
dispatch(webhooksRequestFailure(e));
}
}
)

View File

@@ -0,0 +1,78 @@
import { Action } from "redux";
import { ThunkAction } from "redux-thunk";
import { IWebhook, IWebhookJSON, webhookJS2JSON } from "../../interfaces/IWebhook";
import { State } from "react-joyride";
import buildRequestHeaders from "../../helpers/buildRequestHeaders";
import HttpStatus from "../../constants/http_status";
export const WEBHOOK_SUBMIT_START = 'WEBHOOK_SUBMIT_START';
interface WebhookSubmitStartAction {
type: typeof WEBHOOK_SUBMIT_START;
}
export const WEBHOOK_SUBMIT_SUCCESS = 'WEBHOOK_SUBMIT_SUCCESS';
interface WebhookSubmitSuccessAction {
type: typeof WEBHOOK_SUBMIT_SUCCESS;
webhook: IWebhookJSON;
}
export const WEBHOOK_SUBMIT_FAILURE = 'WEBHOOK_SUBMIT_FAILURE';
interface WebhookSubmitFailureAction {
type: typeof WEBHOOK_SUBMIT_FAILURE;
error: string;
}
export type WebhookSubmitActionTypes =
WebhookSubmitStartAction |
WebhookSubmitSuccessAction |
WebhookSubmitFailureAction;
const webhookSubmitStart = (): WebhookSubmitStartAction => ({
type: WEBHOOK_SUBMIT_START,
});
const webhookSubmitSuccess = (
webhookJSON: IWebhookJSON,
): WebhookSubmitSuccessAction => ({
type: WEBHOOK_SUBMIT_SUCCESS,
webhook: webhookJSON,
});
const webhookSubmitFailure = (error: string): WebhookSubmitFailureAction => ({
type: WEBHOOK_SUBMIT_FAILURE,
error,
});
export const submitWebhook = (
webhook: IWebhook,
authenticityToken: string,
): ThunkAction<void, State, null, Action<string>> => async (dispatch) => {
dispatch(webhookSubmitStart());
try {
const res = await fetch(`/webhooks`, {
method: 'POST',
headers: buildRequestHeaders(authenticityToken),
body: JSON.stringify({
webhook: {
...webhookJS2JSON(webhook),
is_enabled: false,
},
}),
});
const json = await res.json();
if (res.status === HttpStatus.Created) {
dispatch(webhookSubmitSuccess(json));
} else {
dispatch(webhookSubmitFailure(json.error));
}
return Promise.resolve(res);
} catch (e) {
dispatch(webhookSubmitFailure(e));
return Promise.resolve(null);
}
};

View File

@@ -0,0 +1,96 @@
import { Action } from "redux";
import { ThunkAction } from "redux-thunk";
import HttpStatus from "../../constants/http_status";
import buildRequestHeaders from "../../helpers/buildRequestHeaders";
import { State } from "../../reducers/rootReducer";
import { IWebhookJSON } from "../../interfaces/IWebhook";
import { ISiteSettingsWebhookFormUpdate } from "../../components/SiteSettings/Webhooks/WebhookForm";
export const WEBHOOK_UPDATE_START = 'WEBHOOK_UPDATE_START';
interface WebhookUpdateStartAction {
type: typeof WEBHOOK_UPDATE_START;
}
export const WEBHOOK_UPDATE_SUCCESS = 'WEBHOOK_UPDATE_SUCCESS';
interface WebhookUpdateSuccessAction {
type: typeof WEBHOOK_UPDATE_SUCCESS;
webhook: IWebhookJSON;
}
export const WEBHOOK_UPDATE_FAILURE = 'WEBHOOK_UPDATE_FAILURE';
interface WebhookUpdateFailureAction {
type: typeof WEBHOOK_UPDATE_FAILURE;
error: string;
}
export type WebhookUpdateActionTypes =
WebhookUpdateStartAction |
WebhookUpdateSuccessAction |
WebhookUpdateFailureAction;
const webhookUpdateStart = (): WebhookUpdateStartAction => ({
type: WEBHOOK_UPDATE_START,
});
const webhookUpdateSuccess = (
webhookJSON: IWebhookJSON,
): WebhookUpdateSuccessAction => ({
type: WEBHOOK_UPDATE_SUCCESS,
webhook: webhookJSON,
});
const webhookUpdateFailure = (error: string): WebhookUpdateFailureAction => ({
type: WEBHOOK_UPDATE_FAILURE,
error,
});
interface UpdateWebhookParams {
id: number;
form?: ISiteSettingsWebhookFormUpdate;
isEnabled?: boolean;
authenticityToken: string;
}
export const updateWebhook = ({
id,
form = null,
isEnabled = null,
authenticityToken,
}: UpdateWebhookParams): ThunkAction<void, State, null, Action<string>> => async (dispatch) => {
dispatch(webhookUpdateStart());
const webhook = Object.assign({},
form !== null ? {
name: form.name,
description: form.description,
trigger: form.trigger,
http_method: form.httpMethod,
url: form.url,
http_body: form.httpBody,
http_headers: form.httpHeaders,
} : null,
isEnabled !== null ? { is_enabled: isEnabled } : null,
);
try {
const res = await fetch(`/webhooks/${id}`, {
method: 'PATCH',
headers: buildRequestHeaders(authenticityToken),
body: JSON.stringify({ webhook }),
});
const json = await res.json();
if (res.status === HttpStatus.OK) {
dispatch(webhookUpdateSuccess(json));
} else {
dispatch(webhookUpdateFailure(json.error));
}
return Promise.resolve(res);
} catch (e) {
dispatch(webhookUpdateFailure(e));
return Promise.resolve(null);
}
};

View File

@@ -115,6 +115,7 @@ class BoardP extends React.Component<Props> {
<Sidebar> <Sidebar>
<NewPost <NewPost
board={board} board={board}
tenantSetting={tenantSetting}
isLoggedIn={isLoggedIn} isLoggedIn={isLoggedIn}
currentUserFullName={currentUserFullName} currentUserFullName={currentUserFullName}
isAnonymousFeedbackAllowed={tenantSetting.allow_anonymous_feedback} isAnonymousFeedbackAllowed={tenantSetting.allow_anonymous_feedback}

View File

@@ -11,12 +11,16 @@ import {
import Button from '../common/Button'; import Button from '../common/Button';
import IBoard from '../../interfaces/IBoard'; import IBoard from '../../interfaces/IBoard';
import buildRequestHeaders from '../../helpers/buildRequestHeaders';
import HttpStatus from '../../constants/http_status'; import HttpStatus from '../../constants/http_status';
import { POST_APPROVAL_STATUS_APPROVED } from '../../interfaces/IPost'; import { POST_APPROVAL_STATUS_APPROVED } from '../../interfaces/IPost';
import ActionLink from '../common/ActionLink';
import { CancelIcon } from '../common/Icons';
import buildFormData from '../../helpers/buildFormData';
import ITenantSetting from '../../interfaces/ITenantSetting';
interface Props { interface Props {
board: IBoard; board: IBoard;
tenantSetting: ITenantSetting;
isLoggedIn: boolean; isLoggedIn: boolean;
currentUserFullName: string; currentUserFullName: string;
isAnonymousFeedbackAllowed: boolean; isAnonymousFeedbackAllowed: boolean;
@@ -34,6 +38,7 @@ interface State {
title: string; title: string;
description: string; description: string;
attachments: File[];
isSubmissionAnonymous: boolean; isSubmissionAnonymous: boolean;
// Honeypot anti-spam measure // Honeypot anti-spam measure
@@ -55,6 +60,7 @@ class NewPost extends React.Component<Props, State> {
title: '', title: '',
description: '', description: '',
attachments: [],
isSubmissionAnonymous: false, isSubmissionAnonymous: false,
dnf1: '', dnf1: '',
@@ -64,6 +70,7 @@ class NewPost extends React.Component<Props, State> {
this.toggleForm = this.toggleForm.bind(this); this.toggleForm = this.toggleForm.bind(this);
this.onTitleChange = this.onTitleChange.bind(this); this.onTitleChange = this.onTitleChange.bind(this);
this.onDescriptionChange = this.onDescriptionChange.bind(this); this.onDescriptionChange = this.onDescriptionChange.bind(this);
this.onAttachmentsChange = this.onAttachmentsChange.bind(this);
this.submitForm = this.submitForm.bind(this); this.submitForm = this.submitForm.bind(this);
this.onDnf1Change = this.onDnf1Change.bind(this) this.onDnf1Change = this.onDnf1Change.bind(this)
@@ -92,6 +99,12 @@ class NewPost extends React.Component<Props, State> {
}); });
} }
onAttachmentsChange(attachments: File[]) {
this.setState({
attachments,
});
}
onDnf1Change(dnf1: string) { onDnf1Change(dnf1: string) {
this.setState({ this.setState({
dnf1, dnf1,
@@ -115,7 +128,7 @@ class NewPost extends React.Component<Props, State> {
const boardId = this.props.board.id; const boardId = this.props.board.id;
const { authenticityToken, componentRenderedAt } = this.props; const { authenticityToken, componentRenderedAt } = this.props;
const { title, description, isSubmissionAnonymous, dnf1, dnf2 } = this.state; const { title, description, attachments, isSubmissionAnonymous, dnf1, dnf2 } = this.state;
if (title === '') { if (title === '') {
this.setState({ this.setState({
@@ -126,22 +139,23 @@ class NewPost extends React.Component<Props, State> {
} }
try { try {
let formDataObj = {
'post[title]': title,
'post[description]': description,
'post[attachments][]': attachments,
'post[board_id]': boardId,
'post[is_anonymous]': isSubmissionAnonymous.toString(),
'post[dnf1]': dnf1,
'post[dnf2]': dnf2,
'post[form_rendered_at]': componentRenderedAt,
};
const body = buildFormData(formDataObj);
const res = await fetch('/posts', { const res = await fetch('/posts', {
method: 'POST', method: 'POST',
headers: buildRequestHeaders(authenticityToken), headers: { 'X-CSRF-Token': authenticityToken },
body: JSON.stringify({ body: body,
post: {
title,
description,
board_id: boardId,
is_anonymous: isSubmissionAnonymous,
dnf1,
dnf2,
form_rendered_at: componentRenderedAt,
},
}),
}); });
const json = await res.json(); const json = await res.json();
this.setState({isLoading: false}); this.setState({isLoading: false});
@@ -177,6 +191,7 @@ class NewPost extends React.Component<Props, State> {
render() { render() {
const { const {
board, board,
tenantSetting,
isLoggedIn, isLoggedIn,
currentUserFullName, currentUserFullName,
isAnonymousFeedbackAllowed isAnonymousFeedbackAllowed
@@ -190,6 +205,7 @@ class NewPost extends React.Component<Props, State> {
title, title,
description, description,
attachments,
isSubmissionAnonymous, isSubmissionAnonymous,
dnf1, dnf1,
@@ -208,31 +224,30 @@ class NewPost extends React.Component<Props, State> {
{board.description} {board.description}
</ReactMarkdown> </ReactMarkdown>
<Button {
onClick={() => { showForm ?
<ActionLink
if (showForm) { onClick={this.toggleForm}
this.toggleForm(); icon={<CancelIcon />}
return; >
} {I18n.t('common.buttons.cancel')}
</ActionLink>
if (isLoggedIn) { :
this.toggleForm(); <Button
this.setState({ isSubmissionAnonymous: false }); onClick={() => {
} else { if (isLoggedIn) {
window.location.href = '/users/sign_in'; this.toggleForm();
} this.setState({ isSubmissionAnonymous: false });
}} } else {
className="submitBtn" window.location.href = '/users/sign_in';
outline={showForm} }
> }}
{ className="submitBtn"
showForm ? outline={showForm}
I18n.t('board.new_post.cancel_button') >
: {I18n.t('board.new_post.submit_button')}
I18n.t('board.new_post.submit_button') </Button>
} }
</Button>
{ {
(isAnonymousFeedbackAllowed && !showForm) && (isAnonymousFeedbackAllowed && !showForm) &&
@@ -252,12 +267,14 @@ class NewPost extends React.Component<Props, State> {
} }
{ {
showForm ? showForm &&
<NewPostForm <NewPostForm
title={title} title={title}
description={description} description={description}
attachments={attachments}
handleTitleChange={this.onTitleChange} handleTitleChange={this.onTitleChange}
handleDescriptionChange={this.onDescriptionChange} handleDescriptionChange={this.onDescriptionChange}
handleAttachmentsChange={this.onAttachmentsChange}
handleSubmit={this.submitForm} handleSubmit={this.submitForm}
@@ -266,11 +283,10 @@ class NewPost extends React.Component<Props, State> {
handleDnf1Change={this.onDnf1Change} handleDnf1Change={this.onDnf1Change}
handleDnf2Change={this.onDnf2Change} handleDnf2Change={this.onDnf2Change}
tenantSetting={tenantSetting}
currentUserFullName={currentUserFullName} currentUserFullName={currentUserFullName}
isSubmissionAnonymous={isSubmissionAnonymous} isSubmissionAnonymous={isSubmissionAnonymous}
/> />
:
null
} }
{ isLoading ? <Spinner /> : null } { isLoading ? <Spinner /> : null }

View File

@@ -3,12 +3,17 @@ import I18n from 'i18n-js';
import Button from '../common/Button'; import Button from '../common/Button';
import { SmallMutedText } from '../common/CustomTexts'; import { SmallMutedText } from '../common/CustomTexts';
import { MarkdownIcon } from '../common/Icons';
import Dropzone from '../common/Dropzone';
import ITenantSetting from '../../interfaces/ITenantSetting';
interface Props { interface Props {
title: string; title: string;
description: string; description: string;
attachments: File[];
handleTitleChange(title: string): void; handleTitleChange(title: string): void;
handleDescriptionChange(description: string): void; handleDescriptionChange(description: string): void;
handleAttachmentsChange(attachments: File[]): void;
handleSubmit(e: object): void; handleSubmit(e: object): void;
@@ -17,6 +22,7 @@ interface Props {
handleDnf1Change(dnf1: string): void; handleDnf1Change(dnf1: string): void;
handleDnf2Change(dnf2: string): void; handleDnf2Change(dnf2: string): void;
tenantSetting: ITenantSetting;
currentUserFullName: string; currentUserFullName: string;
isSubmissionAnonymous: boolean; isSubmissionAnonymous: boolean;
} }
@@ -24,8 +30,10 @@ interface Props {
const NewPostForm = ({ const NewPostForm = ({
title, title,
description, description,
attachments,
handleTitleChange, handleTitleChange,
handleDescriptionChange, handleDescriptionChange,
handleAttachmentsChange,
handleSubmit, handleSubmit,
@@ -34,11 +42,14 @@ const NewPostForm = ({
handleDnf1Change, handleDnf1Change,
handleDnf2Change, handleDnf2Change,
tenantSetting,
currentUserFullName, currentUserFullName,
isSubmissionAnonymous, isSubmissionAnonymous,
}: Props) => ( }: Props) => (
<div className="newPostForm"> <div className="newPostForm">
<form> <form encType="multipart/form-data">
{ /* Title */ }
<div className="form-group"> <div className="form-group">
<input <input
type="text" type="text"
@@ -83,6 +94,7 @@ const NewPostForm = ({
className="form-control" className="form-control"
/> />
{ /* Description */ }
<div className="form-group"> <div className="form-group">
<textarea <textarea
value={description} value={description}
@@ -93,8 +105,24 @@ const NewPostForm = ({
className="form-control" className="form-control"
id="postDescription" id="postDescription"
></textarea> ></textarea>
<div style={{position: 'relative', width: 0, height: 0}}>
<MarkdownIcon style={{position: 'absolute', left: '6px', top: '-28px'}} />
</div>
</div> </div>
{ /* Attachments */ }
{
tenantSetting.allow_attachment_upload && !isSubmissionAnonymous &&
<div className="form-group">
<Dropzone
files={attachments}
setFiles={handleAttachmentsChange}
maxSizeKB={2048}
maxFiles={5}
/>
</div>
}
<Button onClick={e => handleSubmit(e)} className="submitBtn d-block mx-auto"> <Button onClick={e => handleSubmit(e)} className="submitBtn d-block mx-auto">
{I18n.t('board.new_post.submit_button')} {I18n.t('board.new_post.submit_button')}
</Button> </Button>

View File

@@ -63,6 +63,7 @@ const PostList = ({
showLikeButtons={showLikeButtons} showLikeButtons={showLikeButtons}
liked={post.liked} liked={post.liked}
commentsCount={post.commentsCount} commentsCount={post.commentsCount}
hasAttachments={post.hasAttachments}
isLoggedIn={isLoggedIn} isLoggedIn={isLoggedIn}
authenticityToken={authenticityToken} authenticityToken={authenticityToken}

View File

@@ -6,6 +6,7 @@ import CommentsNumber from '../common/CommentsNumber';
import PostStatusLabel from '../common/PostStatusLabel'; import PostStatusLabel from '../common/PostStatusLabel';
import IPostStatus from '../../interfaces/IPostStatus'; import IPostStatus from '../../interfaces/IPostStatus';
import { ImageIcon } from '../common/Icons';
interface Props { interface Props {
id: number; id: number;
@@ -18,6 +19,7 @@ interface Props {
showLikeButtons: boolean; showLikeButtons: boolean;
liked: number; liked: number;
commentsCount: number; commentsCount: number;
hasAttachments: boolean;
isLoggedIn: boolean; isLoggedIn: boolean;
authenticityToken: string; authenticityToken: string;
@@ -34,6 +36,7 @@ const PostListItem = ({
showLikeButtons, showLikeButtons,
liked, liked,
commentsCount, commentsCount,
hasAttachments,
isLoggedIn, isLoggedIn,
authenticityToken, authenticityToken,
@@ -58,7 +61,10 @@ const PostListItem = ({
<div className="postDetails"> <div className="postDetails">
<CommentsNumber number={commentsCount} /> <CommentsNumber number={commentsCount} />
{ postStatus ? <PostStatusLabel {...postStatus} /> : null }
{ postStatus && <PostStatusLabel {...postStatus} /> }
{ hasAttachments && <span style={{marginLeft: '4px'}}><ImageIcon /></span> }
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,6 +1,5 @@
import * as React from 'react'; import * as React from 'react';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import Gravatar from 'react-gravatar';
import I18n from 'i18n-js'; import I18n from 'i18n-js';
import NewComment from './NewComment'; import NewComment from './NewComment';
@@ -10,13 +9,18 @@ import { ReplyFormState } from '../../reducers/replyFormReducer';
import CommentEditForm from './CommentEditForm'; import CommentEditForm from './CommentEditForm';
import CommentFooter from './CommentFooter'; import CommentFooter from './CommentFooter';
import { StaffIcon } from '../common/Icons'; import { StaffIcon } from '../common/Icons';
import Avatar from '../common/Avatar';
import CommentAttachments from './CommentAttachments';
import ITenantSetting from '../../interfaces/ITenantSetting';
interface Props { interface Props {
id: number; id: number;
body: string; body: string;
isPostUpdate: boolean; isPostUpdate: boolean;
attachmentUrls?: string[];
userFullName: string; userFullName: string;
userEmail: string; userEmail: string;
userAvatar?: string;
userRole: number; userRole: number;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
@@ -25,13 +29,15 @@ interface Props {
handleToggleCommentReply(): void; handleToggleCommentReply(): void;
handleCommentReplyBodyChange(e: React.FormEvent): void; handleCommentReplyBodyChange(e: React.FormEvent): void;
handleSubmitComment(body: string, parentId: number, isPostUpdate: boolean): void; handleSubmitComment(body: string, parentId: number, isPostUpdate: boolean, attachments: File[], onSuccess: Function): void;
handleUpdateComment(commentId: number, body: string, isPostUpdate: boolean, onSuccess: Function): void; handleUpdateComment(commentId: number, body: string, isPostUpdate: boolean, attachmentsToDelete: number[], attachments: File[], onSuccess: Function): void;
handleDeleteComment(id: number): void; handleDeleteComment(id: number): void;
tenantSetting: ITenantSetting;
isLoggedIn: boolean; isLoggedIn: boolean;
isPowerUser: boolean; isPowerUser: boolean;
currentUserEmail: string; currentUserEmail: string;
currentUserAvatar?: string;
} }
interface State { interface State {
@@ -54,11 +60,13 @@ class Comment extends React.Component<Props, State> {
this.setState({editMode: !this.state.editMode}); this.setState({editMode: !this.state.editMode});
} }
_handleUpdateComment(body: string, isPostUpdate: boolean) { _handleUpdateComment(body: string, isPostUpdate: boolean, attachmentsToDelete: number[], attachments: File[]) {
this.props.handleUpdateComment( this.props.handleUpdateComment(
this.props.id, this.props.id,
body, body,
isPostUpdate, isPostUpdate,
attachmentsToDelete,
attachments,
this.toggleEditMode, this.toggleEditMode,
); );
} }
@@ -68,8 +76,10 @@ class Comment extends React.Component<Props, State> {
id, id,
body, body,
isPostUpdate, isPostUpdate,
attachmentUrls,
userFullName, userFullName,
userEmail, userEmail,
userAvatar,
userRole, userRole,
createdAt, createdAt,
updatedAt, updatedAt,
@@ -81,26 +91,26 @@ class Comment extends React.Component<Props, State> {
handleSubmitComment, handleSubmitComment,
handleDeleteComment, handleDeleteComment,
tenantSetting,
isLoggedIn, isLoggedIn,
isPowerUser, isPowerUser,
currentUserEmail, currentUserEmail,
currentUserAvatar,
} = this.props; } = this.props;
return ( return (
<div className="comment"> <div className="comment">
<div className="commentHeader"> <div className="commentHeader">
<Gravatar email={userEmail} size={28} className="gravatar" /> <Avatar avatarUrl={userAvatar} email={userEmail} size={28} />
<span className="commentAuthor">{userFullName}</span> <span className="commentAuthor">{userFullName}</span>
{ userRole > 0 && <StaffIcon /> } { userRole > 0 && <StaffIcon /> }
{ {
isPostUpdate ? isPostUpdate &&
<span className="postUpdateBadge"> <span className="postUpdateBadge">
{I18n.t('post.comments.post_update_badge')} {I18n.t('post.comments.post_update_badge')}
</span> </span>
:
null
} }
</div> </div>
@@ -110,8 +120,10 @@ class Comment extends React.Component<Props, State> {
id={id} id={id}
initialBody={body} initialBody={body}
initialIsPostUpdate={isPostUpdate} initialIsPostUpdate={isPostUpdate}
attachmentUrls={attachmentUrls}
isPowerUser={isPowerUser} isPowerUser={isPowerUser}
tenantSetting={tenantSetting}
handleUpdateComment={this._handleUpdateComment} handleUpdateComment={this._handleUpdateComment}
toggleEditMode={this.toggleEditMode} toggleEditMode={this.toggleEditMode}
@@ -126,6 +138,11 @@ class Comment extends React.Component<Props, State> {
{body} {body}
</ReactMarkdown> </ReactMarkdown>
{
attachmentUrls && attachmentUrls.length > 0 &&
<CommentAttachments attachmentUrls={attachmentUrls} />
}
<CommentFooter <CommentFooter
id={id} id={id}
createdAt={createdAt} createdAt={createdAt}
@@ -153,9 +170,11 @@ class Comment extends React.Component<Props, State> {
handlePostUpdateFlag={() => null} handlePostUpdateFlag={() => null}
handleSubmit={handleSubmitComment} handleSubmit={handleSubmitComment}
allowAttachmentUpload={tenantSetting.allow_attachment_upload}
isLoggedIn={isLoggedIn} isLoggedIn={isLoggedIn}
isPowerUser={isPowerUser} isPowerUser={isPowerUser}
userEmail={currentUserEmail} userEmail={currentUserEmail}
userAvatar={currentUserAvatar}
/> />
: :
null null

View File

@@ -0,0 +1,20 @@
import React from 'react';
interface Props {
attachmentUrls?: string[];
}
const CommentAttachments = ({ attachmentUrls = [] }: Props) => (
attachmentUrls.length > 0 &&
<div className="commentAttachments">
{
attachmentUrls.map((url, index) => (
<a key={index} href={url} target="_blank">
<img src={url} className="commentAttachment" />
</a>
))
}
</div>
);
export default CommentAttachments;

View File

@@ -3,22 +3,28 @@ import I18n from 'i18n-js';
import Button from '../common/Button'; import Button from '../common/Button';
import Switch from '../common/Switch'; import Switch from '../common/Switch';
import ActionLink from '../common/ActionLink'; import ActionLink from '../common/ActionLink';
import { CancelIcon } from '../common/Icons'; import { CancelIcon, DeleteIcon, MarkdownIcon } from '../common/Icons';
import Dropzone from '../common/Dropzone';
import ITenantSetting from '../../interfaces/ITenantSetting';
interface Props { interface Props {
id: number; id: number;
initialBody: string; initialBody: string;
initialIsPostUpdate: boolean; initialIsPostUpdate: boolean;
attachmentUrls?: string[];
isPowerUser: boolean; isPowerUser: boolean;
tenantSetting: ITenantSetting;
handleUpdateComment(body: string, isPostUpdate: boolean): void; handleUpdateComment(body: string, isPostUpdate: boolean, attachmentsToDelete: number[], attachments: File[]): void;
toggleEditMode(): void; toggleEditMode(): void;
} }
interface State { interface State {
body: string; body: string;
isPostUpdate: boolean; isPostUpdate: boolean;
attachmentsToDelete: number[];
attachments: File[];
} }
class CommentEditForm extends React.Component<Props, State> { class CommentEditForm extends React.Component<Props, State> {
@@ -28,10 +34,14 @@ class CommentEditForm extends React.Component<Props, State> {
this.state = { this.state = {
body: '', body: '',
isPostUpdate: false, isPostUpdate: false,
attachmentsToDelete: [],
attachments: [],
}; };
this.handleCommentBodyChange = this.handleCommentBodyChange.bind(this); this.handleCommentBodyChange = this.handleCommentBodyChange.bind(this);
this.handleCommentIsPostUpdateChange = this.handleCommentIsPostUpdateChange.bind(this); this.handleCommentIsPostUpdateChange = this.handleCommentIsPostUpdateChange.bind(this);
this.handleAttachmentsToDeleteChange = this.handleAttachmentsToDeleteChange.bind(this);
this.handleAttachmentsChange = this.handleAttachmentsChange.bind(this);
} }
componentDidMount() { componentDidMount() {
@@ -49,41 +59,109 @@ class CommentEditForm extends React.Component<Props, State> {
this.setState({ isPostUpdate: newIsPostUpdate }); this.setState({ isPostUpdate: newIsPostUpdate });
} }
handleAttachmentsToDeleteChange(newAttachmentsToDelete: number[]) {
this.setState({ attachmentsToDelete: newAttachmentsToDelete });
}
handleAttachmentsChange(newAttachments: File[]) {
this.setState({ attachments: newAttachments });
}
render() { render() {
const { id, isPowerUser, handleUpdateComment, toggleEditMode } = this.props; const { id, attachmentUrls, isPowerUser, tenantSetting, handleUpdateComment, toggleEditMode } = this.props;
const { body, isPostUpdate } = this.state; const { body, isPostUpdate, attachmentsToDelete, attachments } = this.state;
return ( return (
<div className="editCommentForm"> <div className="editCommentForm">
<textarea
value={body} <div className="commentFormContainer">
onChange={e => this.handleCommentBodyChange(e.target.value)} <textarea
rows={3} value={body}
autoFocus onChange={e => this.handleCommentBodyChange(e.target.value)}
className="commentForm" rows={3}
/> autoFocus
className="commentForm"
/>
<div style={{position: 'relative', width: 0, height: 0}}>
<MarkdownIcon style={{position: 'absolute', left: '6px', top: '-36px'}} />
</div>
</div>
<div> <div className="editCommentFormAttachments">
<div> { /* Attachments */ }
{ <div className="thumbnailsContainer" style={{ display: attachmentUrls && attachmentUrls.length > 0 ? 'flex' : 'none' }}>
isPowerUser && {
<Switch attachmentUrls && attachmentUrls.map((attachmentUrl, i) => (
htmlId={`isPostUpdateFlagComment${id}`} <div className="thumbnailContainer" key={i}>
onClick={e => this.handleCommentIsPostUpdateChange(!isPostUpdate)} <div className={`thumbnail${attachmentsToDelete.includes(i) ? ' thumbnailToDelete' : ''}`}>
checked={isPostUpdate || false} <div className="thumbnailInner">
label={I18n.t('post.new_comment.is_post_update')} <img
/> src={attachmentUrl}
} className="thumbnailImage"
/>
</div>
</div>
{
attachmentsToDelete.includes(i) ?
<ActionLink
onClick={() => this.handleAttachmentsToDeleteChange(attachmentsToDelete.filter(index => index !== i))}
icon={<CancelIcon />}
customClass="removeThumbnail"
>
{I18n.t('common.buttons.cancel')}
</ActionLink>
:
<ActionLink
onClick={() => this.handleAttachmentsToDeleteChange([...attachmentsToDelete, i])}
icon={<DeleteIcon />}
customClass="removeThumbnail"
>
{I18n.t('common.buttons.delete')}
</ActionLink>
}
</div>
))
}
</div> </div>
<div className="editCommentFormActions"> { /* Attachments dropzone */ }
<ActionLink onClick={toggleEditMode} icon={<CancelIcon />}> {
{I18n.t('common.buttons.cancel')} tenantSetting.allow_attachment_upload &&
</ActionLink> <div className="form-group">
&nbsp; <Dropzone
<Button onClick={() => handleUpdateComment(body, isPostUpdate)}> files={attachments}
{I18n.t('common.buttons.update')} setFiles={this.handleAttachmentsChange}
</Button> maxSizeKB={2048}
maxFiles={5}
customStyle={{ minHeight: '60px', marginTop: '16px' }}
/>
</div>
}
<div className="editCommentFormFooter">
{ /* Is post update */ }
<div className="editCommentFormPostUpdate">
{
isPowerUser &&
<Switch
htmlId={`isPostUpdateFlagComment${id}`}
onClick={e => this.handleCommentIsPostUpdateChange(!isPostUpdate)}
checked={isPostUpdate || false}
label={I18n.t('post.new_comment.is_post_update')}
/>
}
</div>
<div className="editCommentFormActions">
<ActionLink onClick={toggleEditMode} icon={<CancelIcon />}>
{I18n.t('common.buttons.cancel')}
</ActionLink>
&nbsp;
<Button onClick={() => handleUpdateComment(body, isPostUpdate, attachmentsToDelete, attachments)}>
{I18n.t('common.buttons.update')}
</Button>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -3,7 +3,7 @@ import I18n from 'i18n-js';
import Separator from '../common/Separator'; import Separator from '../common/Separator';
import { MutedText } from '../common/CustomTexts'; import { MutedText } from '../common/CustomTexts';
import friendlyDate from '../../helpers/datetime'; import friendlyDate, { fromRailsStringToJavascriptDate } from '../../helpers/datetime';
import { ReplyFormState } from '../../reducers/replyFormReducer'; import { ReplyFormState } from '../../reducers/replyFormReducer';
import ActionLink from '../common/ActionLink'; import ActionLink from '../common/ActionLink';
import { CancelIcon, DeleteIcon, EditIcon, ReplyIcon } from '../common/Icons'; import { CancelIcon, DeleteIcon, EditIcon, ReplyIcon } from '../common/Icons';
@@ -71,7 +71,7 @@ const CommentFooter = ({
</span> </span>
{ {
createdAt !== updatedAt && fromRailsStringToJavascriptDate(updatedAt).getTime() - fromRailsStringToJavascriptDate(createdAt).getTime() > 10000 &&
<> <>
<Separator /> <Separator />
<MutedText>{ I18n.t('common.edited').toLowerCase() }</MutedText> <MutedText>{ I18n.t('common.edited').toLowerCase() }</MutedText>

View File

@@ -4,6 +4,7 @@ import Comment from './Comment';
import IComment from '../../interfaces/IComment'; import IComment from '../../interfaces/IComment';
import { ReplyFormState } from '../../reducers/replyFormReducer'; import { ReplyFormState } from '../../reducers/replyFormReducer';
import ITenantSetting from '../../interfaces/ITenantSetting';
interface Props { interface Props {
comments: Array<IComment>; comments: Array<IComment>;
@@ -14,13 +15,15 @@ interface Props {
toggleCommentReply(commentId: number): void; toggleCommentReply(commentId: number): void;
setCommentReplyBody(commentId: number, body: string): void; setCommentReplyBody(commentId: number, body: string): void;
handleSubmitComment(body: string, parentId: number, isPostUpdate: boolean): void; handleSubmitComment(body: string, parentId: number, isPostUpdate: boolean, attachments: File[], onSuccess: Function): void;
handleUpdateComment(commentId: number, body: string, isPostUpdate: boolean, onSuccess: Function): void; handleUpdateComment(commentId: number, body: string, isPostUpdate: boolean, attachmentsToDelete: number[], attachments: File[], onSuccess: Function): void;
handleDeleteComment(id: number): void; handleDeleteComment(id: number): void;
tenantSetting: ITenantSetting;
isLoggedIn: boolean; isLoggedIn: boolean;
isPowerUser: boolean; isPowerUser: boolean;
userEmail: string; userEmail: string;
userAvatar?: string;
} }
const CommentList = ({ const CommentList = ({
@@ -35,9 +38,11 @@ const CommentList = ({
handleUpdateComment, handleUpdateComment,
handleDeleteComment, handleDeleteComment,
tenantSetting,
isLoggedIn, isLoggedIn,
isPowerUser, isPowerUser,
userEmail, userEmail,
userAvatar,
}: Props) => ( }: Props) => (
<> <>
{comments.map((comment, i) => { {comments.map((comment, i) => {
@@ -59,9 +64,11 @@ const CommentList = ({
{...comment} {...comment}
tenantSetting={tenantSetting}
isLoggedIn={isLoggedIn} isLoggedIn={isLoggedIn}
isPowerUser={isPowerUser} isPowerUser={isPowerUser}
currentUserEmail={userEmail} currentUserEmail={userEmail}
currentUserAvatar={userAvatar}
/> />
<CommentList <CommentList
@@ -77,9 +84,11 @@ const CommentList = ({
handleUpdateComment={handleUpdateComment} handleUpdateComment={handleUpdateComment}
handleDeleteComment={handleDeleteComment} handleDeleteComment={handleDeleteComment}
tenantSetting={tenantSetting}
isLoggedIn={isLoggedIn} isLoggedIn={isLoggedIn}
isPowerUser={isPowerUser} isPowerUser={isPowerUser}
userEmail={userEmail} userEmail={userEmail}
userAvatar={userAvatar}
/> />
</div> </div>
); );

View File

@@ -9,12 +9,15 @@ import { DangerText, MutedText } from '../common/CustomTexts';
import IComment from '../../interfaces/IComment'; import IComment from '../../interfaces/IComment';
import { ReplyFormState } from '../../reducers/replyFormReducer'; import { ReplyFormState } from '../../reducers/replyFormReducer';
import Separator from '../common/Separator'; import Separator from '../common/Separator';
import ITenantSetting from '../../interfaces/ITenantSetting';
interface Props { interface Props {
postId: number; postId: number;
tenantSetting: ITenantSetting;
isLoggedIn: boolean; isLoggedIn: boolean;
isPowerUser: boolean; isPowerUser: boolean;
userEmail: string; userEmail: string;
userAvatar?: string;
authenticityToken: string; authenticityToken: string;
comments: Array<IComment>; comments: Array<IComment>;
@@ -32,6 +35,8 @@ interface Props {
body: string, body: string,
parentId: number, parentId: number,
isPostUpdate: boolean, isPostUpdate: boolean,
attachments: File[],
onSuccess: Function,
authenticityToken: string, authenticityToken: string,
): void; ): void;
updateComment( updateComment(
@@ -39,6 +44,8 @@ interface Props {
commentId: number, commentId: number,
body: string, body: string,
isPostUpdate: boolean, isPostUpdate: boolean,
attachmentsToDelete: number[],
attachments: File[],
onSuccess: Function, onSuccess: Function,
authenticityToken: string, authenticityToken: string,
): void; ): void;
@@ -54,22 +61,26 @@ class CommentsP extends React.Component<Props> {
this.props.requestComments(this.props.postId); this.props.requestComments(this.props.postId);
} }
_handleSubmitComment = (body: string, parentId: number, isPostUpdate: boolean) => { _handleSubmitComment = (body: string, parentId: number, isPostUpdate: boolean, attachments: File[], onSuccess: Function) => {
this.props.submitComment( this.props.submitComment(
this.props.postId, this.props.postId,
body, body,
parentId, parentId,
isPostUpdate, isPostUpdate,
attachments,
onSuccess,
this.props.authenticityToken, this.props.authenticityToken,
); );
} }
_handleUpdateComment = (commentId: number, body: string, isPostUpdate: boolean, onSuccess: Function) => { _handleUpdateComment = (commentId: number, body: string, isPostUpdate: boolean, attachmentsToDelete: number[], attachments: File[], onSuccess: Function) => {
this.props.updateComment( this.props.updateComment(
this.props.postId, this.props.postId,
commentId, commentId,
body, body,
isPostUpdate, isPostUpdate,
attachmentsToDelete,
attachments,
onSuccess, onSuccess,
this.props.authenticityToken, this.props.authenticityToken,
); );
@@ -85,9 +96,11 @@ class CommentsP extends React.Component<Props> {
render() { render() {
const { const {
tenantSetting,
isLoggedIn, isLoggedIn,
isPowerUser, isPowerUser,
userEmail, userEmail,
userAvatar,
comments, comments,
replyForms, replyForms,
@@ -117,9 +130,11 @@ class CommentsP extends React.Component<Props> {
handlePostUpdateFlag={toggleCommentIsPostUpdateFlag} handlePostUpdateFlag={toggleCommentIsPostUpdateFlag}
handleSubmit={this._handleSubmitComment} handleSubmit={this._handleSubmitComment}
allowAttachmentUpload={tenantSetting.allow_attachment_upload}
isLoggedIn={isLoggedIn} isLoggedIn={isLoggedIn}
isPowerUser={isPowerUser} isPowerUser={isPowerUser}
userEmail={userEmail} userEmail={userEmail}
userAvatar={userAvatar}
/> />
<div className="commentsTitle"> <div className="commentsTitle">
@@ -142,9 +157,11 @@ class CommentsP extends React.Component<Props> {
handleDeleteComment={this._handleDeleteComment} handleDeleteComment={this._handleDeleteComment}
parentId={null} parentId={null}
level={1} level={1}
tenantSetting={tenantSetting}
isLoggedIn={isLoggedIn} isLoggedIn={isLoggedIn}
isPowerUser={isPowerUser} isPowerUser={isPowerUser}
userEmail={userEmail} userEmail={userEmail}
userAvatar={userAvatar}
/> />
</div> </div>
); );

View File

@@ -1,5 +1,4 @@
import * as React from 'react'; import * as React from 'react';
import Gravatar from 'react-gravatar';
import I18n from 'i18n-js'; import I18n from 'i18n-js';
import NewCommentUpdateSection from './NewCommentUpdateSection'; import NewCommentUpdateSection from './NewCommentUpdateSection';
@@ -7,6 +6,10 @@ import NewCommentUpdateSection from './NewCommentUpdateSection';
import Button from '../common/Button'; import Button from '../common/Button';
import Spinner from '../common/Spinner'; import Spinner from '../common/Spinner';
import { DangerText } from '../common/CustomTexts'; import { DangerText } from '../common/CustomTexts';
import { AttachIcon, CancelIcon, MarkdownIcon } from '../common/Icons';
import Avatar from '../common/Avatar';
import ActionLink from '../common/ActionLink';
import Dropzone from '../common/Dropzone';
interface Props { interface Props {
body: string; body: string;
@@ -19,12 +22,16 @@ interface Props {
handleSubmit( handleSubmit(
body: string, body: string,
parentId: number, parentId: number,
isPostUpdate: boolean isPostUpdate: boolean,
attachments: File[],
onSuccess: Function,
): void; ): void;
allowAttachmentUpload: boolean;
isLoggedIn: boolean; isLoggedIn: boolean;
isPowerUser: boolean; isPowerUser: boolean;
userEmail: string; userEmail: string;
userAvatar?: string;
} }
const NewComment = ({ const NewComment = ({
@@ -37,49 +44,106 @@ const NewComment = ({
handlePostUpdateFlag, handlePostUpdateFlag,
handleSubmit, handleSubmit,
allowAttachmentUpload,
isLoggedIn, isLoggedIn,
isPowerUser, isPowerUser,
userEmail, userEmail,
}: Props) => ( userAvatar,
<> }: Props) => {
<div className="newCommentForm">
{
isLoggedIn ?
<>
<div className="commentBodyForm">
<Gravatar email={userEmail} size={48} className="currentUserAvatar" />
<textarea
value={body}
onChange={handleChange}
autoFocus={parentId != null}
placeholder={I18n.t('post.new_comment.body_placeholder')}
className="commentForm"
/>
<Button
onClick={() => handleSubmit(body, parentId, postUpdateFlagValue)}
className="submitCommentButton">
{ isSubmitting ? <Spinner color="light" /> : I18n.t('post.new_comment.submit_button') }
</Button>
</div>
{
isPowerUser && parentId == null ?
<NewCommentUpdateSection
postUpdateFlagValue={postUpdateFlagValue}
handlePostUpdateFlag={handlePostUpdateFlag}
/>
:
null
}
</>
:
<a href="/users/sign_in" className="loginInfo">
{I18n.t('post.new_comment.not_logged_in')}
</a>
}
</div>
{ error ? <DangerText>{error}</DangerText> : null } const [isAttachingFiles, setIsAttachingFiles] = React.useState(false);
</> const [attachments, setAttachments] = React.useState<File[]>([]);
);
return (
<>
<div className="newCommentForm">
{
isLoggedIn ?
<>
<div className="newCommentBodyForm">
<Avatar avatarUrl={userAvatar} email={userEmail} size={48} customClass="currentUserAvatar" />
<div style={{width: '100%', marginRight: '8px'}}>
<textarea
value={body}
onChange={handleChange}
autoFocus={parentId != null}
placeholder={I18n.t('post.new_comment.body_placeholder')}
className="commentForm"
/>
<div style={{position: 'relative', width: 0, height: 0}}>
<MarkdownIcon style={{position: 'absolute', left: '6px', top: '-28px'}} />
</div>
</div>
<Button
onClick={() => {
handleSubmit(
body,
parentId,
postUpdateFlagValue,
attachments,
() => { setIsAttachingFiles(false); setAttachments([]); }
);
}}
className="submitCommentButton">
{ isSubmitting ? <Spinner color="light" /> : I18n.t('post.new_comment.submit_button') }
</Button>
</div>
<div className="newCommentFooter">
{ /* Attach files */ }
<div className={`attachFilesSection${allowAttachmentUpload ? '' : ' attachFilesSectionHidden'}`}>
{
isAttachingFiles ?
<ActionLink
icon={<CancelIcon />}
onClick={() => { setIsAttachingFiles(false); setAttachments([]); }}
customClass='cancelAttachFilesNewComment'
>
{I18n.t('common.buttons.cancel')}
</ActionLink>
:
<ActionLink
icon={<AttachIcon />}
onClick={() => setIsAttachingFiles(true)}
customClass='showAttachFilesNewComment'
>
{I18n.t('common.buttons.attach')}
</ActionLink>
}
{
isAttachingFiles &&
<Dropzone
files={attachments}
setFiles={setAttachments}
maxSizeKB={2048}
maxFiles={5}
customStyle={{ minHeight: '60px', marginTop: '16px' }}
/>
}
</div>
{ /* Post update flag */ }
{
isPowerUser && parentId == null &&
<NewCommentUpdateSection
postUpdateFlagValue={postUpdateFlagValue}
handlePostUpdateFlag={handlePostUpdateFlag}
allowAttachmentUpload={allowAttachmentUpload}
/>
}
</div>
</>
:
<a href="/users/sign_in" className="loginInfo">
{I18n.t('post.new_comment.not_logged_in')}
</a>
}
</div>
{ error ? <DangerText>{error}</DangerText> : null }
</>
);
};
export default NewComment; export default NewComment;

View File

@@ -7,13 +7,15 @@ import Switch from '../common/Switch';
interface Props { interface Props {
postUpdateFlagValue: boolean; postUpdateFlagValue: boolean;
handlePostUpdateFlag(): void; handlePostUpdateFlag(): void;
allowAttachmentUpload?: boolean;
} }
const NewCommentUpdateSection = ({ const NewCommentUpdateSection = ({
postUpdateFlagValue, postUpdateFlagValue,
handlePostUpdateFlag, handlePostUpdateFlag,
allowAttachmentUpload = true,
}: Props) => ( }: Props) => (
<div className="commentIsUpdateForm"> <div className={`commentIsUpdateForm${allowAttachmentUpload ? ' commentIsUpdateFormWithAttachment' : ' commentIsUpdateFormWithoutAttachment'}`}>
<Switch <Switch
htmlId="isPostUpdateFlag" htmlId="isPostUpdateFlag"
onClick={handlePostUpdateFlag} onClick={handlePostUpdateFlag}

View File

@@ -1,11 +1,11 @@
import React from 'react'; import React from 'react';
import I18n from 'i18n-js'; import I18n from 'i18n-js';
import Gravatar from 'react-gravatar';
import IPost, { PostApprovalStatus } from '../../../interfaces/IPost'; import IPost, { PostApprovalStatus } from '../../../interfaces/IPost';
import { AnonymousIcon, ApproveIcon, RejectIcon } from '../../common/Icons'; import { AnonymousIcon, ApproveIcon, RejectIcon } from '../../common/Icons';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import ActionLink from '../../common/ActionLink'; import ActionLink from '../../common/ActionLink';
import Avatar from '../../common/Avatar';
interface Props { interface Props {
post: IPost; post: IPost;
@@ -25,7 +25,7 @@ const FeedbackListItem = ({ post, onUpdatePostApprovalStatus, hideRejectButton }
<div className="feedbackListItemIcon"> <div className="feedbackListItemIcon">
{ {
post.userId ? post.userId ?
<Gravatar email={post.userEmail} size={42} title={post.userEmail} className="gravatar userGravatar" /> <Avatar avatarUrl={post.userAvatar} email={post.userEmail} size={42} customClass="userAvatar" />
: :
<AnonymousIcon size={42} /> <AnonymousIcon size={42} />
} }

View File

@@ -1,5 +1,4 @@
import * as React from "react"; import * as React from "react";
import Gravatar from 'react-gravatar';
import I18n from 'i18n-js'; import I18n from 'i18n-js';
import IUser, { UserRoles, USER_ROLE_ADMIN, USER_ROLE_MODERATOR, USER_ROLE_OWNER, USER_ROLE_USER, USER_STATUS_ACTIVE, USER_STATUS_BLOCKED, USER_STATUS_DELETED } from "../../../interfaces/IUser"; import IUser, { UserRoles, USER_ROLE_ADMIN, USER_ROLE_MODERATOR, USER_ROLE_OWNER, USER_ROLE_USER, USER_STATUS_ACTIVE, USER_STATUS_BLOCKED, USER_STATUS_DELETED } from "../../../interfaces/IUser";
@@ -8,6 +7,7 @@ import UserForm from "./UserForm";
import { MutedText } from "../../common/CustomTexts"; import { MutedText } from "../../common/CustomTexts";
import { BlockIcon, CancelIcon, EditIcon, UnblockIcon } from "../../common/Icons"; import { BlockIcon, CancelIcon, EditIcon, UnblockIcon } from "../../common/Icons";
import ActionLink from "../../common/ActionLink"; import ActionLink from "../../common/ActionLink";
import Avatar from "../../common/Avatar";
interface Props { interface Props {
user: IUser; user: IUser;
@@ -94,7 +94,7 @@ class UserEditable extends React.Component<Props, State> {
editMode === false ? editMode === false ?
<> <>
<div className="userInfo"> <div className="userInfo">
<Gravatar email={user.email} size={42} className="gravatar userGravatar" /> <Avatar avatarUrl={user.avatarUrl} email={user.email} size={42} customClass="userAvatar" />
<div className="userFullNameRoleStatus"> <div className="userFullNameRoleStatus">
<span className="userFullName">{ user.fullName }</span> <span className="userFullName">{ user.fullName }</span>

View File

@@ -1,10 +1,10 @@
import * as React from 'react'; import * as React from 'react';
import Gravatar from 'react-gravatar';
import I18n from 'i18n-js'; import I18n from 'i18n-js';
import Button from '../../common/Button'; import Button from '../../common/Button';
import IUser, { UserRoles, USER_ROLE_ADMIN, USER_ROLE_MODERATOR, USER_ROLE_USER } from '../../../interfaces/IUser'; import IUser, { UserRoles, USER_ROLE_ADMIN, USER_ROLE_MODERATOR, USER_ROLE_USER } from '../../../interfaces/IUser';
import { getLabel } from '../../../helpers/formUtils'; import { getLabel } from '../../../helpers/formUtils';
import Avatar from '../../common/Avatar';
interface Props { interface Props {
user: IUser; user: IUser;
@@ -44,7 +44,7 @@ class UserForm extends React.Component<Props, State> {
return ( return (
<div className="userForm"> <div className="userForm">
<Gravatar email={user.email} size={42} className="gravatar userGravatar" /> <Avatar avatarUrl={user.avatarUrl} email={user.email} size={42} customClass="userAvatar" />
<div className="userFullNameRoleForm"> <div className="userFullNameRoleForm">
<span className="userFullName">{ user.fullName }</span> <span className="userFullName">{ user.fullName }</span>

View File

@@ -1,6 +1,5 @@
import * as React from 'react'; import * as React from 'react';
import I18n from 'i18n-js'; import I18n from 'i18n-js';
import Gravatar from 'react-gravatar';
import ILike from '../../interfaces/ILike'; import ILike from '../../interfaces/ILike';
import Spinner from '../common/Spinner'; import Spinner from '../common/Spinner';
@@ -9,6 +8,7 @@ import {
DangerText, DangerText,
CenteredMutedText CenteredMutedText
} from '../common/CustomTexts'; } from '../common/CustomTexts';
import Avatar from '../common/Avatar';
interface Props { interface Props {
likes: Array<ILike>; likes: Array<ILike>;
@@ -26,7 +26,8 @@ const LikeList = ({ likes, areLoading, error}: Props) => (
{ {
likes.map((like, i) => ( likes.map((like, i) => (
<div className="likeListItem" key={i}> <div className="likeListItem" key={i}>
<Gravatar email={like.email} size={32} className="gravatar" /> <Avatar avatarUrl={like.userAvatar} email={like.email} size={32} />
<div className="likeListItemUserInfo"> <div className="likeListItemUserInfo">
<span className="likeListItemName" title={like.fullName}>{like.fullName}</span> <span className="likeListItemName" title={like.fullName}>{like.fullName}</span>
<span className="likeListItemEmail" title={like.email}>{like.email}</span> <span className="likeListItemEmail" title={like.email}>{like.email}</span>

View File

@@ -0,0 +1,20 @@
import React from 'react';
interface Props {
attachmentUrls?: string[];
}
const PostAttachments = ({ attachmentUrls = [] }: Props) => (
attachmentUrls.length > 0 &&
<div className="postAttachments">
{
attachmentUrls.map((url, index) => (
<a key={index} href={url} target="_blank">
<img src={url} className="postAttachment" />
</a>
))
}
</div>
);
export default PostAttachments;

View File

@@ -9,13 +9,17 @@ import IBoard from '../../interfaces/IBoard';
import Button from '../common/Button'; import Button from '../common/Button';
import Spinner from '../common/Spinner'; import Spinner from '../common/Spinner';
import ActionLink from '../common/ActionLink'; import ActionLink from '../common/ActionLink';
import { CancelIcon } from '../common/Icons'; import { CancelIcon, DeleteIcon } from '../common/Icons';
import ITenantSetting from '../../interfaces/ITenantSetting';
import Dropzone from '../common/Dropzone';
import { DangerText } from '../common/CustomTexts';
interface Props { interface Props {
title: string; title: string;
description?: string; description?: string;
boardId: number; boardId: number;
postStatusId?: number; postStatusId?: number;
attachmentUrls?: string[];
isUpdating: boolean; isUpdating: boolean;
error: string; error: string;
@@ -25,6 +29,7 @@ interface Props {
handleChangeBoard(boardId: number): void; handleChangeBoard(boardId: number): void;
handleChangePostStatus(postStatusId: number): void; handleChangePostStatus(postStatusId: number): void;
tenantSetting: ITenantSetting;
isPowerUser: boolean; isPowerUser: boolean;
boards: Array<IBoard>; boards: Array<IBoard>;
postStatuses: Array<IPostStatus>; postStatuses: Array<IPostStatus>;
@@ -35,6 +40,8 @@ interface Props {
description: string, description: string,
boardId: number, boardId: number,
postStatusId: number, postStatusId: number,
attachmentsToDelete: number[],
attachments: File[],
): void; ): void;
} }
@@ -43,6 +50,7 @@ const PostEditForm = ({
description, description,
boardId, boardId,
postStatusId, postStatusId,
attachmentUrls,
isUpdating, isUpdating,
error, error,
@@ -52,59 +60,122 @@ const PostEditForm = ({
handleChangeBoard, handleChangeBoard,
handleChangePostStatus, handleChangePostStatus,
tenantSetting,
isPowerUser, isPowerUser,
boards, boards,
postStatuses, postStatuses,
toggleEditMode, toggleEditMode,
handleUpdatePost, handleUpdatePost,
}: Props) => ( }: Props) => {
<div className="postEditForm"> const [attachmentsToDelete, setAttachmentsToDelete] = React.useState<number[]>([]);
<div className="postHeader"> const [attachments, setAttachments] = React.useState<File[]>([]);
<input
type="text" React.useEffect(() => {
value={title} setAttachmentsToDelete([]);
onChange={e => handleChangeTitle(e.target.value)} }, [attachmentUrls]);
autoFocus
className="postTitle form-control" return (
<div className="postEditForm">
<div className="postHeader">
<input
type="text"
value={title}
onChange={e => handleChangeTitle(e.target.value)}
autoFocus
className="postTitle form-control"
/>
</div>
{
isPowerUser ?
<div className="postSettings">
<PostBoardSelect
boards={boards}
selectedBoardId={boardId}
handleChange={newBoardId => handleChangeBoard(newBoardId)}
/>
<PostStatusSelect
postStatuses={postStatuses}
selectedPostStatusId={postStatusId}
handleChange={newPostStatusId => handleChangePostStatus(newPostStatusId)}
/>
</div>
:
null
}
<textarea
value={description}
onChange={e => handleChangeDescription(e.target.value)}
rows={5}
className="postDescription form-control"
/> />
{ /* Attachments */ }
<div className="thumbnailsContainer" style={{ display: attachmentUrls && attachmentUrls.length > 0 ? 'flex' : 'none' }}>
{
attachmentUrls && attachmentUrls.map((attachmentUrl, i) => (
<div className="thumbnailContainer" key={i}>
<div className={`thumbnail${attachmentsToDelete.includes(i) ? ' thumbnailToDelete' : ''}`}>
<div className="thumbnailInner">
<img
src={attachmentUrl}
className="thumbnailImage"
/>
</div>
</div>
{
attachmentsToDelete.includes(i) ?
<ActionLink
onClick={() => setAttachmentsToDelete(attachmentsToDelete.filter(index => index !== i))}
icon={<CancelIcon />}
customClass="removeThumbnail"
>
{I18n.t('common.buttons.cancel')}
</ActionLink>
:
<ActionLink
onClick={() => setAttachmentsToDelete([...attachmentsToDelete, i])}
icon={<DeleteIcon />}
customClass="removeThumbnail"
>
{I18n.t('common.buttons.delete')}
</ActionLink>
}
</div>
))
}
</div>
{ /* Attachments dropzone */ }
{
tenantSetting.allow_attachment_upload &&
<div className="form-group">
<Dropzone
files={attachments}
setFiles={setAttachments}
maxSizeKB={2048}
maxFiles={5}
customStyle={{ minHeight: '60px', marginTop: '16px' }}
/>
</div>
}
<div className="postEditFormButtons">
<ActionLink onClick={toggleEditMode} icon={<CancelIcon />}>
{I18n.t('common.buttons.cancel')}
</ActionLink>
&nbsp;
<Button onClick={() => handleUpdatePost(title, description, boardId, postStatusId, attachmentsToDelete, attachments)}>
{ isUpdating ? <Spinner /> : I18n.t('common.buttons.update') }
</Button>
</div>
{ error && <DangerText>{error}</DangerText> }
</div> </div>
);
{ };
isPowerUser ?
<div className="postSettings">
<PostBoardSelect
boards={boards}
selectedBoardId={boardId}
handleChange={newBoardId => handleChangeBoard(newBoardId)}
/>
<PostStatusSelect
postStatuses={postStatuses}
selectedPostStatusId={postStatusId}
handleChange={newPostStatusId => handleChangePostStatus(newPostStatusId)}
/>
</div>
:
null
}
<textarea
value={description}
onChange={e => handleChangeDescription(e.target.value)}
rows={5}
className="postDescription form-control"
/>
<div className="postEditFormButtons">
<ActionLink onClick={toggleEditMode} icon={<CancelIcon />}>
{I18n.t('common.buttons.cancel')}
</ActionLink>
&nbsp;
<Button onClick={() => handleUpdatePost(title, description, boardId, postStatusId)}>
{ isUpdating ? <Spinner /> : I18n.t('common.buttons.update') }
</Button>
</div>
</div>
);
export default PostEditForm; export default PostEditForm;

View File

@@ -1,12 +1,11 @@
import * as React from 'react'; import * as React from 'react';
import Gravatar from 'react-gravatar';
import I18n from 'i18n-js'; import I18n from 'i18n-js';
import { MutedText } from '../common/CustomTexts';
import friendlyDate from '../../helpers/datetime'; import friendlyDate from '../../helpers/datetime';
import Separator from '../common/Separator'; import Separator from '../common/Separator';
import ActionLink from '../common/ActionLink'; import ActionLink from '../common/ActionLink';
import { DeleteIcon, EditIcon } from '../common/Icons'; import { DeleteIcon, EditIcon } from '../common/Icons';
import Avatar from '../common/Avatar';
interface Props { interface Props {
createdAt: string; createdAt: string;
@@ -15,6 +14,7 @@ interface Props {
isPowerUser: boolean; isPowerUser: boolean;
authorEmail: string; authorEmail: string;
authorFullName: string; authorFullName: string;
authorAvatar?: string;
currentUserEmail: string; currentUserEmail: string;
} }
@@ -25,6 +25,7 @@ const PostFooter = ({
isPowerUser, isPowerUser,
authorEmail, authorEmail,
authorFullName, authorFullName,
authorAvatar,
currentUserEmail, currentUserEmail,
}: Props) => ( }: Props) => (
<div className="postFooter"> <div className="postFooter">
@@ -33,7 +34,7 @@ const PostFooter = ({
authorEmail ? authorEmail ?
<> <>
<span>{I18n.t('post.published_by').toLowerCase()} &nbsp;</span> <span>{I18n.t('post.published_by').toLowerCase()} &nbsp;</span>
<Gravatar email={authorEmail} size={24} className="postAuthorAvatar" /> &nbsp; <Avatar avatarUrl={authorAvatar} email={authorEmail} size={24} customClass="postAuthorAvatar" /> &nbsp;
<span>{authorFullName}</span> <span>{authorFullName}</span>
</> </>
: :

View File

@@ -9,6 +9,7 @@ import ITenantSetting from '../../interfaces/ITenantSetting';
import PostUpdateList from './PostUpdateList'; import PostUpdateList from './PostUpdateList';
import PostEditForm from './PostEditForm'; import PostEditForm from './PostEditForm';
import PostAttachments from './PostAttachments';
import PostFooter from './PostFooter'; import PostFooter from './PostFooter';
import LikeList from './LikeList'; import LikeList from './LikeList';
import ActionBox from './ActionBox'; import ActionBox from './ActionBox';
@@ -47,6 +48,7 @@ interface Props {
isPowerUser: boolean; isPowerUser: boolean;
currentUserFullName: string; currentUserFullName: string;
currentUserEmail: string; currentUserEmail: string;
currentUserAvatar?: string;
tenantSetting: ITenantSetting; tenantSetting: ITenantSetting;
authenticityToken: string; authenticityToken: string;
@@ -57,6 +59,8 @@ interface Props {
description: string, description: string,
boardId: number, boardId: number,
postStatusId: number, postStatusId: number,
attachmentsToDelete: number[],
attachments: File[],
authenticityToken: string, authenticityToken: string,
): Promise<any>; ): Promise<any>;
@@ -112,7 +116,14 @@ class PostP extends React.Component<Props, State> {
this.props.requestPostStatusChanges(postId); this.props.requestPostStatusChanges(postId);
} }
_handleUpdatePost(title: string, description: string, boardId: number, postStatusId: number) { _handleUpdatePost(
title: string,
description: string,
boardId: number,
postStatusId: number,
attachmentsToDelete: number[],
attachments: File[],
) {
const { const {
postId, postId,
post, post,
@@ -132,6 +143,8 @@ class PostP extends React.Component<Props, State> {
description, description,
boardId, boardId,
postStatusId, postStatusId,
attachmentsToDelete,
attachments,
authenticityToken, authenticityToken,
).then(res => { ).then(res => {
if (res?.status !== HttpStatus.OK) return; if (res?.status !== HttpStatus.OK) return;
@@ -171,6 +184,7 @@ class PostP extends React.Component<Props, State> {
isLoggedIn, isLoggedIn,
isPowerUser, isPowerUser,
currentUserEmail, currentUserEmail,
currentUserAvatar,
tenantSetting, tenantSetting,
authenticityToken, authenticityToken,
@@ -232,12 +246,14 @@ class PostP extends React.Component<Props, State> {
editMode ? editMode ?
<PostEditForm <PostEditForm
{...editForm} {...editForm}
attachmentUrls={postToShow.attachmentUrls}
handleChangeTitle={handleChangeEditFormTitle} handleChangeTitle={handleChangeEditFormTitle}
handleChangeDescription={handleChangeEditFormDescription} handleChangeDescription={handleChangeEditFormDescription}
handleChangeBoard={handleChangeEditFormBoard} handleChangeBoard={handleChangeEditFormBoard}
handleChangePostStatus={handleChangeEditFormPostStatus} handleChangePostStatus={handleChangeEditFormPostStatus}
tenantSetting={tenantSetting}
isPowerUser={isPowerUser} isPowerUser={isPowerUser}
boards={boards} boards={boards}
postStatuses={postStatuses} postStatuses={postStatuses}
@@ -292,6 +308,10 @@ class PostP extends React.Component<Props, State> {
{postToShow.description} {postToShow.description}
</ReactMarkdown> </ReactMarkdown>
<PostAttachments
attachmentUrls={postToShow?.attachmentUrls}
/>
<PostFooter <PostFooter
createdAt={postToShow.createdAt} createdAt={postToShow.createdAt}
handleDeletePost={this._handleDeletePost} handleDeletePost={this._handleDeletePost}
@@ -300,6 +320,7 @@ class PostP extends React.Component<Props, State> {
isPowerUser={isPowerUser} isPowerUser={isPowerUser}
authorEmail={postToShow.userEmail} authorEmail={postToShow.userEmail}
authorFullName={postToShow.userFullName} authorFullName={postToShow.userFullName}
authorAvatar={originPost.authorAvatar}
currentUserEmail={currentUserEmail} currentUserEmail={currentUserEmail}
/> />
</> </>
@@ -307,9 +328,11 @@ class PostP extends React.Component<Props, State> {
<Comments <Comments
postId={this.props.postId} postId={this.props.postId}
tenantSetting={tenantSetting}
isLoggedIn={isLoggedIn} isLoggedIn={isLoggedIn}
isPowerUser={isPowerUser} isPowerUser={isPowerUser}
userEmail={currentUserEmail} userEmail={currentUserEmail}
userAvatar={currentUserAvatar}
authenticityToken={authenticityToken} authenticityToken={authenticityToken}
/> />
</div> </div>

View File

@@ -1,9 +1,8 @@
import * as React from 'react'; import * as React from 'react';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import Gravatar from 'react-gravatar';
import I18n from 'i18n-js'; import I18n from 'i18n-js';
import { DangerText, CenteredMutedText, MutedText } from '../common/CustomTexts'; import { DangerText, CenteredMutedText } from '../common/CustomTexts';
import Spinner from '../common/Spinner'; import Spinner from '../common/Spinner';
import IComment from '../../interfaces/IComment'; import IComment from '../../interfaces/IComment';
@@ -13,6 +12,7 @@ import IPostStatus from '../../interfaces/IPostStatus';
import friendlyDate from '../../helpers/datetime'; import friendlyDate from '../../helpers/datetime';
import PostStatusLabel from '../common/PostStatusLabel'; import PostStatusLabel from '../common/PostStatusLabel';
import SidebarBox from '../common/SidebarBox'; import SidebarBox from '../common/SidebarBox';
import Avatar from '../common/Avatar';
interface Props { interface Props {
postUpdates: Array<IComment | IPostStatusChange>; postUpdates: Array<IComment | IPostStatusChange>;
@@ -42,7 +42,8 @@ const PostUpdateList = ({
postUpdates.map((postUpdate, i) => ( postUpdates.map((postUpdate, i) => (
<div className="postUpdateListItem" key={i}> <div className="postUpdateListItem" key={i}>
<div className="postUpdateListItemHeader"> <div className="postUpdateListItemHeader">
<Gravatar email={postUpdate.userEmail} size={28} className="gravatar" /> <Avatar avatarUrl={postUpdate.userAvatar} email={postUpdate.userEmail} size={28} />
<span>{postUpdate.userFullName}</span> <span>{postUpdate.userFullName}</span>
</div> </div>

View File

@@ -21,6 +21,7 @@ interface Props {
isPowerUser: boolean; isPowerUser: boolean;
currentUserFullName: string; currentUserFullName: string;
currentUserEmail: string; currentUserEmail: string;
currentUserAvatar?: string;
tenantSetting: ITenantSetting; tenantSetting: ITenantSetting;
authenticityToken: string; authenticityToken: string;
} }
@@ -44,6 +45,7 @@ class PostRoot extends React.Component<Props> {
isPowerUser, isPowerUser,
currentUserFullName, currentUserFullName,
currentUserEmail, currentUserEmail,
currentUserAvatar,
tenantSetting, tenantSetting,
authenticityToken authenticityToken
} = this.props; } = this.props;
@@ -60,6 +62,7 @@ class PostRoot extends React.Component<Props> {
isPowerUser={isPowerUser} isPowerUser={isPowerUser}
currentUserFullName={currentUserFullName} currentUserFullName={currentUserFullName}
currentUserEmail={currentUserEmail} currentUserEmail={currentUserEmail}
currentUserAvatar={currentUserAvatar}
tenantSetting={tenantSetting} tenantSetting={tenantSetting}
authenticityToken={authenticityToken} authenticityToken={authenticityToken}
/> />

View File

@@ -1,5 +1,4 @@
import * as React from 'react'; import * as React from 'react';
import I18n from 'i18n-js';
import Box from '../../common/Box'; import Box from '../../common/Box';
import { AuthenticationPages } from './AuthenticationSiteSettingsP'; import { AuthenticationPages } from './AuthenticationSiteSettingsP';
@@ -9,8 +8,8 @@ import { DangerText } from '../../common/CustomTexts';
import { IOAuth } from '../../../interfaces/IOAuth'; import { IOAuth } from '../../../interfaces/IOAuth';
interface Props { interface Props {
handleSubmitOAuth(oAuth: IOAuth): void; handleSubmitOAuth(oAuth: IOAuth, oAuthLogo: File): void;
handleUpdateOAuth(id: number, form: ISiteSettingsOAuthForm): void; handleUpdateOAuth(id: number, form: ISiteSettingsOAuthForm, shouldDeleteLogo: boolean): void;
isSubmitting: boolean; isSubmitting: boolean;
submitError: string; submitError: string;
selectedOAuth: IOAuth; selectedOAuth: IOAuth;

View File

@@ -79,7 +79,7 @@ const AuthenticationIndexPage = ({
<h2>{ I18n.t('site_settings.authentication.title') }</h2> <h2>{ I18n.t('site_settings.authentication.title') }</h2>
<div className="emailRegistrationPolicy"> <div className="emailRegistrationPolicy">
<h3>{ I18n.t('site_settings.authentication.email_registration_subtitle') }</h3> <h4>{ I18n.t('site_settings.authentication.email_registration_subtitle') }</h4>
<form onSubmit={handleSubmit(onSubmit)} onChange={handleSubmit(onSubmit)}> <form onSubmit={handleSubmit(onSubmit)} onChange={handleSubmit(onSubmit)}>
<div className="formGroup"> <div className="formGroup">

View File

@@ -16,8 +16,8 @@ interface Props {
oAuths: OAuthsState; oAuths: OAuthsState;
requestOAuths(): void; requestOAuths(): void;
onSubmitOAuth(oAuth: IOAuth, authenticityToken: string): Promise<any>; onSubmitOAuth(oAuth: IOAuth, oAuthLogo: File, authenticityToken: string): Promise<any>;
onUpdateOAuth(id: number, form: ISiteSettingsOAuthForm, authenticityToken: string): Promise<any>; onUpdateOAuth(id: number, form: ISiteSettingsOAuthForm, shouldDeleteLogo: boolean, authenticityToken: string): Promise<any>;
onToggleEnabledOAuth(id: number, isEnabled: boolean, authenticityToken: string): void; onToggleEnabledOAuth(id: number, isEnabled: boolean, authenticityToken: string): void;
onToggleEnabledDefaultOAuth(id: number, isEnabled: boolean, authenticityToken: string): void; onToggleEnabledDefaultOAuth(id: number, isEnabled: boolean, authenticityToken: string): void;
onDeleteOAuth(id: number, authenticityToken: string): void; onDeleteOAuth(id: number, authenticityToken: string): void;
@@ -50,14 +50,14 @@ const AuthenticationSiteSettingsP = ({
useEffect(requestOAuths, []); useEffect(requestOAuths, []);
const handleSubmitOAuth = (oAuth: IOAuth) => { const handleSubmitOAuth = (oAuth: IOAuth, oAuthLogo: File) => {
onSubmitOAuth(oAuth, authenticityToken).then(res => { onSubmitOAuth(oAuth, oAuthLogo, authenticityToken).then(res => {
if (res?.status === HttpStatus.Created) setPage('index'); if (res?.status === HttpStatus.Created) setPage('index');
}); });
}; };
const handleUpdateOAuth = (id: number, form: ISiteSettingsOAuthForm) => { const handleUpdateOAuth = (id: number, form: ISiteSettingsOAuthForm, shouldDeleteLogo: boolean) => {
onUpdateOAuth(id, form, authenticityToken).then(res => { onUpdateOAuth(id, form, shouldDeleteLogo, authenticityToken).then(res => {
if (res?.status === HttpStatus.OK) setPage('index'); if (res?.status === HttpStatus.OK) setPage('index');
}); });
}; };

View File

@@ -1,6 +1,7 @@
import * as React from 'react'; import * as React from 'react';
import I18n from 'i18n-js'; import I18n from 'i18n-js';
import { SubmitHandler, useForm } from 'react-hook-form'; import { Controller, SubmitHandler, useForm } from 'react-hook-form';
import { DangerText } from '../../common/CustomTexts'; import { DangerText } from '../../common/CustomTexts';
import { getLabel, getValidationMessage } from '../../../helpers/formUtils'; import { getLabel, getValidationMessage } from '../../../helpers/formUtils';
import Button from '../../common/Button'; import Button from '../../common/Button';
@@ -10,20 +11,22 @@ import { AuthenticationPages } from './AuthenticationSiteSettingsP';
import { useState } from 'react'; import { useState } from 'react';
import Separator from '../../common/Separator'; import Separator from '../../common/Separator';
import ActionLink from '../../common/ActionLink'; import ActionLink from '../../common/ActionLink';
import { BackIcon } from '../../common/Icons'; import { BackIcon, CancelIcon, DeleteIcon, EditIcon } from '../../common/Icons';
import Dropzone from '../../common/Dropzone';
interface Props { interface Props {
selectedOAuth: IOAuth; selectedOAuth: IOAuth;
page: AuthenticationPages; page: AuthenticationPages;
setPage: React.Dispatch<React.SetStateAction<AuthenticationPages>>; setPage: React.Dispatch<React.SetStateAction<AuthenticationPages>>;
handleSubmitOAuth(oAuth: IOAuth): void; handleSubmitOAuth(oAuth: IOAuth, oAuthLogo: File): void;
handleUpdateOAuth(id: number, form: ISiteSettingsOAuthForm): void; handleUpdateOAuth(id: number, form: ISiteSettingsOAuthForm, shouldDeleteLogo: boolean): void;
} }
export interface ISiteSettingsOAuthForm { export interface ISiteSettingsOAuthForm {
name: string; name: string;
logo: string; logo?: File;
shouldDeleteLogo: boolean;
clientId: string; clientId: string;
clientSecret: string; clientSecret: string;
authorizeUrl: string; authorizeUrl: string;
@@ -47,11 +50,14 @@ const OAuthForm = ({
const { const {
register, register,
handleSubmit, handleSubmit,
formState: { errors, isDirty } formState: { errors, isDirty },
control,
watch,
} = useForm<ISiteSettingsOAuthForm>({ } = useForm<ISiteSettingsOAuthForm>({
defaultValues: page === 'new' ? { defaultValues: page === 'new' ? {
name: '', name: '',
logo: '', logo: null,
shouldDeleteLogo: false,
clientId: '', clientId: '',
clientSecret: '', clientSecret: '',
authorizeUrl: '', authorizeUrl: '',
@@ -62,7 +68,8 @@ const OAuthForm = ({
jsonUserNamePath: '', jsonUserNamePath: '',
} : { } : {
name: selectedOAuth.name, name: selectedOAuth.name,
logo: selectedOAuth.logo, logo: null,
shouldDeleteLogo: false,
clientId: selectedOAuth.clientId, clientId: selectedOAuth.clientId,
clientSecret: selectedOAuth.clientSecret, clientSecret: selectedOAuth.clientSecret,
authorizeUrl: selectedOAuth.authorizeUrl, authorizeUrl: selectedOAuth.authorizeUrl,
@@ -78,16 +85,26 @@ const OAuthForm = ({
const oAuth = { ...data, isEnabled: false }; const oAuth = { ...data, isEnabled: false };
if (page === 'new') { if (page === 'new') {
handleSubmitOAuth(oAuth); handleSubmitOAuth(
oAuth,
data.logo ? data.logo : null
);
} else if (page === 'edit') { } else if (page === 'edit') {
if (editClientSecret === false) { if (editClientSecret === false) {
delete oAuth.clientSecret; delete oAuth.clientSecret;
} }
handleUpdateOAuth(selectedOAuth.id, oAuth as ISiteSettingsOAuthForm); handleUpdateOAuth(
selectedOAuth.id,
oAuth as ISiteSettingsOAuthForm,
data.shouldDeleteLogo
);
} }
}; };
const shouldDeleteLogo = watch('shouldDeleteLogo');
const [showOAuthLogoDropzone, setShowOAuthLogoDropzone] = React.useState([null, undefined, ''].includes(selectedOAuth?.logoUrl));
return ( return (
<> <>
<ActionLink <ActionLink
@@ -102,6 +119,7 @@ const OAuthForm = ({
> >
{I18n.t('common.buttons.back')} {I18n.t('common.buttons.back')}
</ActionLink> </ActionLink>
<h2>{ I18n.t(`site_settings.authentication.form.title_${page}`) }</h2> <h2>{ I18n.t(`site_settings.authentication.form.title_${page}`) }</h2>
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={handleSubmit(onSubmit)}>
<div className="formRow"> <div className="formRow">
@@ -117,13 +135,81 @@ const OAuthForm = ({
<div className="formGroup col-6"> <div className="formGroup col-6">
<label htmlFor="logo">{ getLabel('o_auth', 'logo') }</label> <label htmlFor="logo">{ getLabel('o_auth', 'logo') }</label>
<input
{...register('logo')} {
placeholder='https://example.com/logo.png' selectedOAuth && selectedOAuth.logoUrl &&
id="logo" <div className={`oAuthLogoPreview${shouldDeleteLogo ? ' oAuthLogoPreviewShouldDelete' : ''}`}>
className="formControl" <img src={selectedOAuth.logoUrl} alt={`${selectedOAuth.name} OAuth logo`} className="oAuthLogoPreviewImg" />
/> </div>
</div> }
<div className="oAuthLogoActions">
{
(selectedOAuth && selectedOAuth.logoUrl && !shouldDeleteLogo) &&
(showOAuthLogoDropzone ?
<ActionLink
onClick={() => setShowOAuthLogoDropzone(false)}
icon={<CancelIcon />}
>
{I18n.t('common.buttons.cancel')}
</ActionLink>
:
<ActionLink
onClick={() => setShowOAuthLogoDropzone(true)}
icon={<EditIcon />}
>
{I18n.t('common.buttons.edit')}
</ActionLink>)
}
{
(selectedOAuth && selectedOAuth.logoUrl && !showOAuthLogoDropzone) &&
(shouldDeleteLogo ?
<Controller
name="shouldDeleteLogo"
control={control}
render={({ field }) => (
<ActionLink
onClick={() => field.onChange(false)}
icon={<CancelIcon />}
>
{I18n.t('common.buttons.cancel')}
</ActionLink>
)}
/>
:
<Controller
name="shouldDeleteLogo"
control={control}
render={({ field }) => (
<ActionLink
onClick={() => field.onChange(true)}
icon={<DeleteIcon />}
>
{I18n.t('common.buttons.delete')}
</ActionLink>
)}
/>
)
}
</div>
{
showOAuthLogoDropzone &&
<Controller
name="logo"
control={control}
render={({ field }) => (
<Dropzone
files={field.value ? [field.value] : []}
setFiles={files => files.length > 0 ? field.onChange(files[0]) : field.onChange(null)}
maxSizeKB={64}
maxFiles={1}
/>
)}
/>
}
</div>
</div> </div>
<h5>{ I18n.t('site_settings.authentication.form.subtitle_oauth_config') }</h5> <h5>{ I18n.t('site_settings.authentication.form.subtitle_oauth_config') }</h5>

View File

@@ -29,8 +29,8 @@ const OAuthProviderItem = ({
<li className="oAuthListItem"> <li className="oAuthListItem">
<div className="oAuthInfo"> <div className="oAuthInfo">
{ {
oAuth.logo && oAuth.logo.length > 0 ? oAuth.logoUrl && oAuth.logoUrl.length > 0 ?
<img src={oAuth.logo} className="oAuthLogo" width={42} height={42} /> <img src={oAuth.logoUrl} className="oAuthLogo" width={42} height={42} />
: :
<div className="oauthLogo" style={{width: 42, height: 42}}></div> <div className="oauthLogo" style={{width: 42, height: 42}}></div>
} }

View File

@@ -27,15 +27,15 @@ const OAuthProvidersList = ({
}: Props) => ( }: Props) => (
<> <>
<div className="oauthProvidersTitle"> <div className="oauthProvidersTitle">
<h3>{ I18n.t('site_settings.authentication.oauth_subtitle') }</h3> <h4>{ I18n.t('site_settings.authentication.oauth_subtitle') }</h4>
<Button onClick={() => setPage('new')}> <Button onClick={() => { setSelectedOAuth(null); setPage('new'); }}>
{ I18n.t('common.buttons.new') } { I18n.t('common.buttons.new') }
</Button> </Button>
</div> </div>
<p style={{textAlign: 'left'}}> <p style={{textAlign: 'left'}}>
<ActionLink <ActionLink
onClick={() => window.open('https://docs.astuto.io/category/oauth-configuration/', '_blank')} onClick={() => window.open('https://docs.astuto.io/oauth/oauth-configuration-basics', '_blank')}
icon={<LearnMoreIcon />} icon={<LearnMoreIcon />}
> >
{I18n.t('site_settings.authentication.learn_more')} {I18n.t('site_settings.authentication.learn_more')}

View File

@@ -4,6 +4,7 @@ import I18n from 'i18n-js';
import Button from '../../common/Button'; import Button from '../../common/Button';
import { DangerText } from '../../common/CustomTexts'; import { DangerText } from '../../common/CustomTexts';
import { MarkdownIcon } from '../../common/Icons';
interface Props { interface Props {
mode: 'create' | 'update'; mode: 'create' | 'update';
@@ -95,11 +96,17 @@ const BoardForm = ({
</Button> </Button>
</div> </div>
<textarea <div>
{...register('description')} <textarea
placeholder={I18n.t('site_settings.boards.form.description')} {...register('description')}
className="boardDescriptionTextArea formControl" placeholder={I18n.t('site_settings.boards.form.description')}
/> className="boardDescriptionTextArea formControl"
/>
<div style={{position: 'relative', width: 0, height: 0}}>
<MarkdownIcon style={{position: 'absolute', left: '6px', top: '-28px'}} />
</div>
</div>
{mode === 'update' && ( {mode === 'update' && (
<> <>

View File

@@ -1,5 +1,5 @@
import * as React from 'react'; import * as React from 'react';
import { useForm, SubmitHandler } from 'react-hook-form'; import { useForm, SubmitHandler, Controller } from 'react-hook-form';
import I18n from 'i18n-js'; import I18n from 'i18n-js';
import Box from '../../common/Box'; import Box from '../../common/Box';
@@ -21,11 +21,16 @@ import { DangerText, SmallMutedText } from '../../common/CustomTexts';
import { getLabel, getValidationMessage } from '../../../helpers/formUtils'; import { getLabel, getValidationMessage } from '../../../helpers/formUtils';
import IBoardJSON from '../../../interfaces/json/IBoard'; import IBoardJSON from '../../../interfaces/json/IBoard';
import ActionLink from '../../common/ActionLink'; import ActionLink from '../../common/ActionLink';
import { LearnMoreIcon } from '../../common/Icons'; import { CancelIcon, DeleteIcon, EditIcon, LearnMoreIcon } from '../../common/Icons';
import Dropzone from '../../common/Dropzone';
export interface ISiteSettingsGeneralForm { export interface ISiteSettingsGeneralForm {
siteName: string; siteName: string;
siteLogo: string; siteLogo?: File;
shouldDeleteSiteLogo: boolean;
oldSiteLogo: string;
siteFavicon?: File;
shouldDeleteSiteFavicon: boolean;
brandDisplaySetting: string; brandDisplaySetting: string;
locale: string; locale: string;
useBrowserLocale: boolean; useBrowserLocale: boolean;
@@ -34,6 +39,7 @@ export interface ISiteSettingsGeneralForm {
isPrivate: boolean; isPrivate: boolean;
allowAnonymousFeedback: boolean; allowAnonymousFeedback: boolean;
feedbackApprovalPolicy: string; feedbackApprovalPolicy: string;
allowAttachmentUpload: boolean;
logoLinksTo: string; logoLinksTo: string;
logoCustomUrl?: string; logoCustomUrl?: string;
showRoadmapInHeader: boolean; showRoadmapInHeader: boolean;
@@ -46,6 +52,8 @@ export interface ISiteSettingsGeneralForm {
interface Props { interface Props {
originForm: ISiteSettingsGeneralForm; originForm: ISiteSettingsGeneralForm;
siteLogoUrl?: string;
siteFaviconUrl?: string;
boards: IBoardJSON[]; boards: IBoardJSON[];
isMultiTenant: boolean; isMultiTenant: boolean;
authenticityToken: string; authenticityToken: string;
@@ -55,7 +63,11 @@ interface Props {
updateTenant( updateTenant(
siteName: string, siteName: string,
siteLogo: string, siteLogo: File,
shouldDeleteSiteLogo: boolean,
oldSiteLogo: string,
siteFavicon: File,
shouldDeleteSiteFavicon: boolean,
brandDisplaySetting: string, brandDisplaySetting: string,
locale: string, locale: string,
useBrowserLocale: boolean, useBrowserLocale: boolean,
@@ -64,6 +76,7 @@ interface Props {
isPrivate: boolean, isPrivate: boolean,
allowAnonymousFeedback: boolean, allowAnonymousFeedback: boolean,
feedbackApprovalPolicy: string, feedbackApprovalPolicy: string,
allowAttachmentUpload: boolean,
logoLinksTo: string, logoLinksTo: string,
logoCustomUrl: string, logoCustomUrl: string,
showRoadmapInHeader: boolean, showRoadmapInHeader: boolean,
@@ -78,6 +91,8 @@ interface Props {
const GeneralSiteSettingsP = ({ const GeneralSiteSettingsP = ({
originForm, originForm,
siteLogoUrl,
siteFaviconUrl,
boards, boards,
isMultiTenant, isMultiTenant,
authenticityToken, authenticityToken,
@@ -91,10 +106,15 @@ const GeneralSiteSettingsP = ({
handleSubmit, handleSubmit,
formState: { isDirty, isSubmitSuccessful, errors }, formState: { isDirty, isSubmitSuccessful, errors },
watch, watch,
control,
} = useForm<ISiteSettingsGeneralForm>({ } = useForm<ISiteSettingsGeneralForm>({
defaultValues: { defaultValues: {
siteName: originForm.siteName, siteName: originForm.siteName,
siteLogo: originForm.siteLogo, siteLogo: null,
shouldDeleteSiteLogo: false,
oldSiteLogo: originForm.oldSiteLogo,
siteFavicon: null,
shouldDeleteSiteFavicon: false,
brandDisplaySetting: originForm.brandDisplaySetting, brandDisplaySetting: originForm.brandDisplaySetting,
locale: originForm.locale, locale: originForm.locale,
useBrowserLocale: originForm.useBrowserLocale, useBrowserLocale: originForm.useBrowserLocale,
@@ -103,6 +123,7 @@ const GeneralSiteSettingsP = ({
isPrivate: originForm.isPrivate, isPrivate: originForm.isPrivate,
allowAnonymousFeedback: originForm.allowAnonymousFeedback, allowAnonymousFeedback: originForm.allowAnonymousFeedback,
feedbackApprovalPolicy: originForm.feedbackApprovalPolicy, feedbackApprovalPolicy: originForm.feedbackApprovalPolicy,
allowAttachmentUpload: originForm.allowAttachmentUpload,
logoLinksTo: originForm.logoLinksTo, logoLinksTo: originForm.logoLinksTo,
logoCustomUrl: originForm.logoCustomUrl, logoCustomUrl: originForm.logoCustomUrl,
showRoadmapInHeader: originForm.showRoadmapInHeader, showRoadmapInHeader: originForm.showRoadmapInHeader,
@@ -117,7 +138,11 @@ const GeneralSiteSettingsP = ({
const onSubmit: SubmitHandler<ISiteSettingsGeneralForm> = data => { const onSubmit: SubmitHandler<ISiteSettingsGeneralForm> = data => {
updateTenant( updateTenant(
data.siteName, data.siteName,
data.siteLogo, data.siteLogo ? data.siteLogo : null,
data.shouldDeleteSiteLogo,
data.oldSiteLogo,
data.siteFavicon ? data.siteFavicon : null,
data.shouldDeleteSiteFavicon,
data.brandDisplaySetting, data.brandDisplaySetting,
data.locale, data.locale,
data.useBrowserLocale, data.useBrowserLocale,
@@ -126,6 +151,7 @@ const GeneralSiteSettingsP = ({
data.isPrivate, data.isPrivate,
data.allowAnonymousFeedback, data.allowAnonymousFeedback,
data.feedbackApprovalPolicy, data.feedbackApprovalPolicy,
data.allowAttachmentUpload,
data.logoLinksTo, data.logoLinksTo,
data.logoCustomUrl, data.logoCustomUrl,
data.showRoadmapInHeader, data.showRoadmapInHeader,
@@ -144,8 +170,6 @@ const GeneralSiteSettingsP = ({
}); });
}; };
const customDomain = watch('customDomain');
React.useEffect(() => { React.useEffect(() => {
if (window.location.hash) { if (window.location.hash) {
const anchor = window.location.hash.substring(1); const anchor = window.location.hash.substring(1);
@@ -163,6 +187,13 @@ const GeneralSiteSettingsP = ({
} }
}, []); }, []);
const customDomain = watch('customDomain');
const shouldDeleteSiteLogo = watch('shouldDeleteSiteLogo');
const shouldDeleteSiteFavicon = watch('shouldDeleteSiteFavicon');
const [showSiteLogoDropzone, setShowSiteLogoDropzone] = React.useState([null, undefined, ''].includes(siteLogoUrl));
const [showSiteFaviconDropzone, setShowSiteFaviconDropzone] = React.useState([null, undefined, ''].includes(siteFaviconUrl));
return ( return (
<> <>
<Box customClass="generalSiteSettingsContainer"> <Box customClass="generalSiteSettingsContainer">
@@ -170,7 +201,7 @@ const GeneralSiteSettingsP = ({
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={handleSubmit(onSubmit)}>
<div className="formRow"> <div className="formRow">
<div className="formGroup col-4"> <div className="formGroup col-6">
<label htmlFor="siteName">{ getLabel('tenant', 'site_name') }</label> <label htmlFor="siteName">{ getLabel('tenant', 'site_name') }</label>
<input <input
{...register('siteName', { required: true })} {...register('siteName', { required: true })}
@@ -180,17 +211,7 @@ const GeneralSiteSettingsP = ({
<DangerText>{errors.siteName && getValidationMessage(errors.siteName.type, 'tenant', 'site_name')}</DangerText> <DangerText>{errors.siteName && getValidationMessage(errors.siteName.type, 'tenant', 'site_name')}</DangerText>
</div> </div>
<div className="formGroup col-4"> <div className="formGroup col-6">
<label htmlFor="siteLogo">{ getLabel('tenant', 'site_logo') }</label>
<input
{...register('siteLogo')}
placeholder='https://example.com/logo.png'
id="siteLogo"
className="formControl"
/>
</div>
<div className="formGroup col-4">
<label htmlFor="brandSetting">{ getLabel('tenant_setting', 'brand_display') }</label> <label htmlFor="brandSetting">{ getLabel('tenant_setting', 'brand_display') }</label>
<select <select
{...register('brandDisplaySetting')} {...register('brandDisplaySetting')}
@@ -211,6 +232,177 @@ const GeneralSiteSettingsP = ({
</option> </option>
</select> </select>
</div> </div>
{/* Hidden oldSiteLogo field for backwards compatibility */}
<div className="formGroup d-none">
<label htmlFor="oldSiteLogo">{ getLabel('tenant', 'site_logo') }</label>
<input
{...register('oldSiteLogo')}
placeholder='https://example.com/logo.png'
id="oldSiteLogo"
className="formControl"
/>
</div>
</div>
<div className="formRow">
<div className="formGroup col-6">
<label htmlFor="siteLogo">{ getLabel('tenant', 'site_logo') }</label>
{
siteLogoUrl &&
<div className={`siteLogoPreview${shouldDeleteSiteLogo ? ' siteLogoPreviewShouldDelete' : ''}`}>
<img src={siteLogoUrl} alt={`${originForm.siteName} logo`} className="siteLogoPreviewImg" />
</div>
}
<div className="siteLogoActions">
{
(siteLogoUrl && !shouldDeleteSiteLogo) &&
(showSiteLogoDropzone ?
<ActionLink
onClick={() => setShowSiteLogoDropzone(false)}
icon={<CancelIcon />}
>
{I18n.t('common.buttons.cancel')}
</ActionLink>
:
<ActionLink
onClick={() => setShowSiteLogoDropzone(true)}
icon={<EditIcon />}
>
{I18n.t('common.buttons.edit')}
</ActionLink>)
}
{
(siteLogoUrl && !showSiteLogoDropzone) &&
(shouldDeleteSiteLogo ?
<Controller
name="shouldDeleteSiteLogo"
control={control}
render={({ field }) => (
<ActionLink
onClick={() => field.onChange(false)}
icon={<CancelIcon />}
>
{I18n.t('common.buttons.cancel')}
</ActionLink>
)}
/>
:
<Controller
name="shouldDeleteSiteLogo"
control={control}
render={({ field }) => (
<ActionLink
onClick={() => field.onChange(true)}
icon={<DeleteIcon />}
>
{I18n.t('common.buttons.delete')}
</ActionLink>
)}
/>
)
}
</div>
{
showSiteLogoDropzone &&
<Controller
name="siteLogo"
control={control}
render={({ field }) => (
<Dropzone
files={field.value ? [field.value] : []}
setFiles={files => files.length > 0 ? field.onChange(files[0]) : field.onChange(null)}
maxSizeKB={256}
maxFiles={1}
/>
)}
/>
}
</div>
<div className="formGroup col-6">
<label htmlFor="siteFavicon">{ getLabel('tenant', 'site_favicon') }</label>
{
siteFaviconUrl &&
<div className={`siteFaviconPreview${shouldDeleteSiteFavicon ? ' siteFaviconPreviewShouldDelete' : ''}`}>
<img src={siteFaviconUrl} alt={`${originForm.siteName} favicon`} className="siteFaviconPreviewImg" />
</div>
}
<div className="siteFaviconActions">
{
(siteFaviconUrl && !shouldDeleteSiteFavicon) &&
(showSiteFaviconDropzone ?
<ActionLink
onClick={() => setShowSiteFaviconDropzone(false)}
icon={<CancelIcon />}
>
{I18n.t('common.buttons.cancel')}
</ActionLink>
:
<ActionLink
onClick={() => setShowSiteFaviconDropzone(true)}
icon={<EditIcon />}
>
{I18n.t('common.buttons.edit')}
</ActionLink>)
}
{
(siteFaviconUrl && !showSiteFaviconDropzone) &&
(shouldDeleteSiteFavicon ?
<Controller
name="shouldDeleteSiteFavicon"
control={control}
render={({ field }) => (
<ActionLink
onClick={() => field.onChange(false)}
icon={<CancelIcon />}
>
{I18n.t('common.buttons.cancel')}
</ActionLink>
)}
/>
:
<Controller
name="shouldDeleteSiteFavicon"
control={control}
render={({ field }) => (
<ActionLink
onClick={() => field.onChange(true)}
icon={<DeleteIcon />}
>
{I18n.t('common.buttons.delete')}
</ActionLink>
)}
/>
)
}
</div>
{
showSiteFaviconDropzone &&
<Controller
name="siteFavicon"
control={control}
render={({ field }) => (
<Dropzone
files={field.value ? [field.value] : []}
setFiles={files => files.length > 0 ? field.onChange(files[0]) : field.onChange(null)}
maxSizeKB={64}
maxFiles={1}
accept={['image/x-icon', 'image/icon', 'image/png', 'image/jpeg', 'image/jpg']}
/>
)}
/>
}
</div>
</div> </div>
<div className="formGroup"> <div className="formGroup">
@@ -335,6 +527,16 @@ const GeneralSiteSettingsP = ({
{ I18n.t('site_settings.general.feedback_approval_policy_help') } { I18n.t('site_settings.general.feedback_approval_policy_help') }
</SmallMutedText> </SmallMutedText>
</div> </div>
<div className="formGroup">
<div className="checkboxSwitch">
<input {...register('allowAttachmentUpload')} type="checkbox" id="allow_attachment_upload" />
<label htmlFor="allow_attachment_upload">{ getLabel('tenant_setting', 'allow_attachment_upload') }</label>
<SmallMutedText>
{ I18n.t('site_settings.general.allow_attachment_upload_help') }
</SmallMutedText>
</div>
</div>
</div> </div>
<div id="header" className="settingsGroup"> <div id="header" className="settingsGroup">

View File

@@ -10,6 +10,8 @@ import { ISiteSettingsGeneralForm } from './GeneralSiteSettingsP';
interface Props { interface Props {
originForm: ISiteSettingsGeneralForm; originForm: ISiteSettingsGeneralForm;
siteLogoUrl?: string;
siteFaviconUrl?: string;
boards: IBoardJSON[]; boards: IBoardJSON[];
isMultiTenant: boolean; isMultiTenant: boolean;
authenticityToken: string; authenticityToken: string;
@@ -29,6 +31,8 @@ class GeneralSiteSettingsRoot extends React.Component<Props> {
<Provider store={this.store}> <Provider store={this.store}>
<GeneralSiteSettings <GeneralSiteSettings
originForm={this.props.originForm} originForm={this.props.originForm}
siteLogoUrl={this.props.siteLogoUrl}
siteFaviconUrl={this.props.siteFaviconUrl}
boards={this.props.boards} boards={this.props.boards}
isMultiTenant={this.props.isMultiTenant} isMultiTenant={this.props.isMultiTenant}
authenticityToken={this.props.authenticityToken} authenticityToken={this.props.authenticityToken}

View File

@@ -1,5 +1,4 @@
import * as React from 'react'; import * as React from 'react';
import Gravatar from 'react-gravatar';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import I18n from 'i18n-js'; import I18n from 'i18n-js';
@@ -13,6 +12,7 @@ import IInvitation from '../../../interfaces/IInvitation';
import friendlyDate, { fromRailsStringToJavascriptDate, nMonthsAgo } from '../../../helpers/datetime'; import friendlyDate, { fromRailsStringToJavascriptDate, nMonthsAgo } from '../../../helpers/datetime';
import ActionLink from '../../common/ActionLink'; import ActionLink from '../../common/ActionLink';
import { TestIcon } from '../../common/Icons'; import { TestIcon } from '../../common/Icons';
import Avatar from '../../common/Avatar';
interface Props { interface Props {
siteName: string; siteName: string;
@@ -218,7 +218,8 @@ const Invitations = ({ siteName, invitations, currentUserEmail, authenticityToke
invitationsToDisplay.map((invitation, i) => ( invitationsToDisplay.map((invitation, i) => (
<li key={i} className="invitationListItem"> <li key={i} className="invitationListItem">
<div className="invitationUserInfo"> <div className="invitationUserInfo">
<Gravatar email={invitation.email} size={42} className="gravatar userGravatar" /> <Avatar email={invitation.email} size={42} customClass="userAvatar" />
<span className="invitationEmail">{ invitation.email }</span> <span className="invitationEmail">{ invitation.email }</span>
</div> </div>

View File

@@ -50,6 +50,7 @@ const RoadmapEmbedding: React.FC<Props> = ({ embeddedRoadmapUrl }) => {
onChange={event => setEmbedCode(event.target.value)} onChange={event => setEmbedCode(event.target.value)}
rows={5} rows={5}
id="roadmapEmbedCode" id="roadmapEmbedCode"
className="formControl"
> >
</textarea> </textarea>

View File

@@ -0,0 +1,241 @@
import React, { useState } from 'react';
import I18n from 'i18n-js';
import Select from 'react-select';
// To keep in sync with app/workflows/create_liquid_template_context_workflow.rb
const tenantOptions = [
{ value: '{{ tenant.site_name }}', label: 'Feedback space name' },
{ value: '{{ tenant.subdomain }}', label: 'Feedback space subdomain' },
{ value: '{{ tenant.custom_domain }}', label: 'Feedback space custom domain' },
];
const boardOptions = [
{ value: '{{ board.id }}', label: 'Board ID' },
{ value: '{{ board.name }}', label: 'Board name' },
{ value: '{{ board.description }}', label: 'Board description' },
{ value: '{{ board.slug }}', label: 'Board slug' },
{ value: '{{ board.created_at }}', label: 'Board created at datetime' },
{ value: '{{ board.updated_at }}', label: 'Board updated at datetime' },
];
const postStatusOptions = [
{ value: '{{ post_status.id }}', label: 'Post status ID' },
{ value: '{{ post_status.name }}', label: 'Post status name' },
{ value: '{{ post_status.color }}', label: 'Post status color' },
{ value: '{{ post_status.show_in_roadmap }}', label: 'Post status show in roadmap flag' },
{ value: '{{ post_status.created_at }}', label: 'Post status created at datetime' },
{ value: '{{ post_status.updated_at }}', label: 'Post status updated at datetime' },
];
const userOptions = (userKeyValue: string, userKeyLabel: string) => [
{ value: `{{ ${userKeyValue}.id }}`, label: `${userKeyLabel} ID` },
{ value: `{{ ${userKeyValue}.email }}`, label: `${userKeyLabel} email` },
{ value: `{{ ${userKeyValue}.full_name }}`, label: `${userKeyLabel} full name` },
{ value: `{{ ${userKeyValue}.role }}`, label: `${userKeyLabel} role` },
{ value: `{{ ${userKeyValue}.status }}`, label: `${userKeyLabel} status` },
{ value: `{{ ${userKeyValue}.created_at }}`, label: `${userKeyLabel} created at datetime` },
{ value: `{{ ${userKeyValue}.updated_at }}`, label: `${userKeyLabel} updated at datetime` },
];
const postOptions = [
{ value: '{{ post.id }}', label: 'Post ID' },
{ value: '{{ post.title }}', label: 'Post title' },
{ value: '{{ post.description }}', label: 'Post description' },
{ value: '{{ post.slug }}', label: 'Post slug' },
{ value: '{{ post.created_at }}', label: 'Post created at datetime' },
{ value: '{{ post.updated_at }}', label: 'Post updated at datetime' },
{ value: '{{ post.url }}', label: 'Post URL' },
];
const commentOptions = [
{ value: '{{ comment.id }}', label: 'Comment ID' },
{ value: '{{ comment.body }}', label: 'Comment body' },
{ value: '{{ comment.created_at }}', label: 'Comment created at datetime' },
{ value: '{{ comment.updated_at }}', label: 'Comment updated at datetime' },
];
const optionsByWebhookTrigger = {
'new_post': [
...postOptions,
...userOptions('post_author', 'Post author'),
...boardOptions,
...tenantOptions,
],
'new_post_pending_approval': [
...postOptions,
...userOptions('post_author', 'Post author'),
...boardOptions,
...tenantOptions,
],
'delete_post': [
// only post.id is available on delete_post
postOptions.find(option => option.value === '{{ post.id }}'),
...tenantOptions,
],
'post_status_change': [
...postOptions,
...userOptions('post_author', 'Post author'),
...boardOptions,
...postStatusOptions,
...tenantOptions,
],
'new_comment': [
...commentOptions,
...userOptions('comment_author', 'Comment author'),
...postOptions,
...userOptions('post_author', 'Post author'),
...boardOptions,
...tenantOptions,
],
'new_vote': [
...userOptions('vote_author', 'Vote author'),
...postOptions,
...userOptions('post_author', 'Post author'),
...boardOptions,
...tenantOptions,
],
'new_user': [
...userOptions('user', 'User'),
...tenantOptions,
],
};
// Non-exhaustive list of Liquid tags
const liquidTagsOptions = {
label: 'Liquid tags',
options: [
{ value: '{% if <condition> %}\n\n{% endif %}', label: 'If condition' },
{ value: '{% for <item> in <collection> %}\n\n{% endfor %}', label: 'For loop' },
{ value: '{% tablerow <item> in <collection> %}\n\n{% endtablerow %}', label: 'Tablerow loop' },
{ value: '{% assign <variable> = <value> %}', label: 'Assign variable' },
]
};
// Non-exhaustive list of Liquid filters
const liquidFiltersOptions = {
label: 'Liquid filters',
options: [
{ value: ' | abs', label: 'Absolute value' },
{ value: ' | append: <value>', label: 'Append' },
{ value: ' | capitalize', label: 'Capitalize' },
{ value: ' | ceil', label: 'Ceil' },
{ value: ' | compact', label: 'Compact' },
{ value: ' | concat: <array>', label: 'Concat' },
{ value: ' | date: <format>', label: 'Date' },
{ value: ' | default: <value>', label: 'Default' },
{ value: ' | divided_by: <value>', label: 'Divided by' },
{ value: ' | downcase', label: 'Downcase' },
{ value: ' | escape', label: 'Escape' },
{ value: ' | escape_once', label: 'Escape once' },
{ value: ' | first', label: 'First' },
{ value: ' | floor', label: 'Floor' },
{ value: ' | join: <value>', label: 'Join' },
{ value: ' | last', label: 'Last' },
{ value: ' | lstrip', label: 'Lstrip' },
{ value: ' | map: <value>', label: 'Map' },
{ value: ' | minus: <value>', label: 'Minus' },
{ value: ' | modulo: <value>', label: 'Modulo' },
{ value: ' | newline_to_br', label: 'Newline to br' },
{ value: ' | plus: <value>', label: 'Plus' },
{ value: ' | prepend: <value>', label: 'Prepend' },
{ value: ' | remove: <value>', label: 'Remove' },
{ value: ' | remove_first: <value>', label: 'Remove first' },
{ value: ' | replace: <value>, <new_value>', label: 'Replace' },
{ value: ' | replace_first: <value>, <new_value>', label: 'Replace first' },
{ value: ' | replace_last: <value>, <new_value>', label: 'Replace last' },
{ value: ' | reverse', label: 'Reverse' },
{ value: ' | round', label: 'Round' },
{ value: ' | rstrip', label: 'Rstrip' },
{ value: ' | size', label: 'Size' },
{ value: ' | slice: <value>', label: 'Slice' },
{ value: ' | sort', label: 'Sort' },
{ value: ' | sort_natural', label: 'Sort natural' },
{ value: ' | split: <value>', label: 'Split' },
{ value: ' | strip', label: 'Strip' },
{ value: ' | strip_html', label: 'Strip html' },
{ value: ' | strip_newlines', label: 'Strip newlines' },
{ value: ' | times: <value>', label: 'Times' },
{ value: ' | truncate: <value>', label: 'Truncate' },
{ value: ' | truncatewords: <value>', label: 'Truncate words' },
{ value: ' | uniq', label: 'Uniq' },
{ value: ' | upcase', label: 'Upcase' },
{ value: ' | url_decode', label: 'Url decode' },
{ value: ' | url_encode', label: 'Url encode' },
{ value: ' | where: <value>', label: 'Where' },
]
};
// Custom Liquid filters
const customLiquidFiltersOptions = {
label: 'Custom Liquid filters',
options: [
{ value: ' | escape_json', label: 'Escape JSON' },
]
};
interface Props {
webhookTrigger: string;
onChange: (option: any) => void;
}
const TemplateVariablesSelector = ({
webhookTrigger,
onChange,
}: Props) => {
const options = [
{
label: 'Astuto variables',
options: optionsByWebhookTrigger[webhookTrigger] || [],
},
{
label: 'Liquid tags',
options: liquidTagsOptions.options,
},
{
label: 'Liquid filters',
options: liquidFiltersOptions.options,
},
{
label: 'Custom Liquid filters',
options: customLiquidFiltersOptions.options,
},
];
const [selectedOption, setSelectedOption] = useState(null);
const handleChange = (option) => {
onChange(option.value);
// Reset the selection
setSelectedOption(null);
};
return (
<Select
options={options}
value={selectedOption}
onChange={handleChange}
isClearable={false}
isSearchable
placeholder={I18n.t('site_settings.webhooks.form.template_variables_selector_placeholder')}
styles={{
control: (provided, state) => ({
...provided,
boxShadow: 'none',
borderColor: state.isFocused ? '#333333' : '#cdcdcd',
'&:hover': {
boxShadow: 'none',
borderColor: '#333333',
},
}),
option: (provided, state) => ({
...provided,
color: 'inherit',
backgroundColor: state.isFocused ? '#f2f2f2' : 'white',
}),
}}
/>
);
};
export default TemplateVariablesSelector;

View File

@@ -0,0 +1,415 @@
import * as React from 'react';
import I18n from 'i18n-js';
import { SubmitHandler, useFieldArray, useForm } from 'react-hook-form';
import { IWebhook, WEBHOOK_HTTP_METHOD_DELETE, WEBHOOK_HTTP_METHOD_PATCH, WEBHOOK_HTTP_METHOD_POST, WEBHOOK_HTTP_METHOD_PUT, WEBHOOK_TRIGGER_DELETED_POST, WEBHOOK_TRIGGER_NEW_COMMENT, WEBHOOK_TRIGGER_NEW_POST, WEBHOOK_TRIGGER_NEW_POST_PENDING_APPROVAL, WEBHOOK_TRIGGER_NEW_USER, WEBHOOK_TRIGGER_NEW_VOTE, WEBHOOK_TRIGGER_POST_STATUS_CHANGE, WebhookHttpMethod, WebhookTrigger } from '../../../interfaces/IWebhook';
import { WebhookPages } from './WebhooksSiteSettingsP';
import ActionLink from '../../common/ActionLink';
import { AddIcon, BackIcon, DeleteIcon, LiquidIcon, PreviewIcon } from '../../common/Icons';
import { getLabel, getValidationMessage } from '../../../helpers/formUtils';
import { DangerText } from '../../common/CustomTexts';
import Button from '../../common/Button';
import { URL_REGEX_WHITESPACE_ALLOWED } from '../../../constants/regex';
import Spinner from '../../common/Spinner';
import buildRequestHeaders from '../../../helpers/buildRequestHeaders';
import HttpStatus from '../../../constants/http_status';
import { useRef, useState } from 'react';
import TemplateVariablesSelector from './TemplateVariablesSelector';
interface Props {
isSubmitting: boolean;
submitError: string;
selectedWebhook: IWebhook;
page: WebhookPages;
setPage: React.Dispatch<React.SetStateAction<WebhookPages>>;
handleSubmitWebhook(webhook: IWebhook): void;
handleUpdateWebhook(id: number, form: ISiteSettingsWebhookFormUpdate): void;
authenticityToken: string;
}
interface ISiteSettingsWebhookFormBase {
name: string;
description: string;
trigger: string;
url: string;
httpBody: string;
httpMethod: string;
}
interface ISiteSettingsWebhookForm extends ISiteSettingsWebhookFormBase {
httpHeaders: Array<{ key: string, value: string }>;
}
export interface ISiteSettingsWebhookFormUpdate extends ISiteSettingsWebhookFormBase {
httpHeaders: string;
}
// This method tries to parse httpHeaders JSON, otherwise returns [{ key: '', value: '' }]
const parseHttpHeaders = (httpHeaders: string) => {
try {
return JSON.parse(httpHeaders);
} catch (e) {
return [{ key: '', value: '' }];
}
}
const WebhookFormPage = ({
isSubmitting,
submitError,
selectedWebhook,
page,
setPage,
handleSubmitWebhook,
handleUpdateWebhook,
authenticityToken,
}: Props) => {
const {
register,
handleSubmit,
control,
formState: { errors, isDirty },
watch,
getValues,
setValue,
} = useForm<ISiteSettingsWebhookForm>({
defaultValues: page === 'new' ? {
name: '',
description: '',
trigger: WEBHOOK_TRIGGER_NEW_POST,
url: '',
httpBody: '',
httpMethod: WEBHOOK_HTTP_METHOD_POST,
httpHeaders: [{ key: '', value: '' }],
} : {
name: selectedWebhook.name,
description: selectedWebhook.description,
trigger: selectedWebhook.trigger,
url: selectedWebhook.url,
httpBody: selectedWebhook.httpBody,
httpMethod: selectedWebhook.httpMethod,
httpHeaders: parseHttpHeaders(selectedWebhook.httpHeaders),
}
});
const { fields, append, remove } = useFieldArray({
control,
name: 'httpHeaders', // The name of the httpHeaders field
});
const onSubmit: SubmitHandler<ISiteSettingsWebhookForm> = data => {
// Remove empty headers
let httpHeaders = data.httpHeaders.filter(header => header.key !== '' && header.value !== '');
const webhook = {
isEnabled: false,
name: data.name,
description: data.description,
trigger: data.trigger as WebhookTrigger,
url: data.url.replace(/\s/g, ''),
httpBody: data.httpBody,
httpMethod: data.httpMethod as WebhookHttpMethod,
httpHeaders: JSON.stringify(httpHeaders),
};
if (page === 'new') {
handleSubmitWebhook(webhook);
} else if (page === 'edit') {
handleUpdateWebhook(selectedWebhook.id, webhook);
}
};
const trigger = watch('trigger');
const url = watch('url');
const httpBody = watch('httpBody');
const httpBodyTextAreaRef = useRef(null);
const [cursorPosition, setCursorPosition] = React.useState(0);
const handleCursorPosition = e => {
setCursorPosition(e.target.selectionStart);
};
// Insert custom string at the last cursor position
const insertString = (stringToInsert: string) => {
const currentValue = getValues('httpBody'); // Get the current textarea value
const start = currentValue.slice(0, cursorPosition);
const end = currentValue.slice(cursorPosition);
const newValue = start + stringToInsert + end;
// Update textarea value with react-hook-form
setValue('httpBody', newValue, { shouldDirty: true });
setIsPreviewOutdated(true);
// Update cursor position after the custom string
const newCursorPosition = cursorPosition + stringToInsert.length;
setCursorPosition(newCursorPosition);
// Update the DOM to reflect the cursor position
if (httpBodyTextAreaRef.current) {
setTimeout(() => {
httpBodyTextAreaRef.current.setSelectionRange(newCursorPosition, newCursorPosition);
httpBodyTextAreaRef.current.focus();
}, 0);
}
};
// State for URL and body preview
const [isPreviewVisible, setIsPreviewVisible] = useState(false);
const [previewContent, setPreviewContent] = useState('');
const [isPreviewOutdated, setIsPreviewOutdated] = useState(true);
return (
<>
<ActionLink
onClick={() => {
let confirmation = true;
if (isDirty)
confirmation = confirm(I18n.t('common.unsaved_changes') + ' ' + I18n.t('common.confirmation'));
if (confirmation) setPage('index');
}}
icon={<BackIcon />}
customClass="backButton"
>
{I18n.t('common.buttons.back')}
</ActionLink>
<h2>{ I18n.t(`site_settings.webhooks.form.title_${page}`) }</h2>
<form onSubmit={handleSubmit(onSubmit)}>
<div className="formRow">
<div className="formGroup col-6">
<label htmlFor="name">{ getLabel('webhook', 'name') }</label>
<input
{...register('name', { required: true, maxLength: 255 })}
id="name"
className="formControl"
/>
<DangerText>{errors.name?.type === 'required' && getValidationMessage(errors.name.type, 'webhook', 'name')}</DangerText>
<DangerText>{errors.name?.type === 'maxLength' && (getLabel('webhook', 'name') + ' ' + I18n.t('activerecord.errors.messages.too_long', { count: 255 }))}</DangerText>
</div>
<div className="formGroup col-6">
<label htmlFor="description">{ getLabel('webhook', 'description') }</label>
<input
{...register('description', { maxLength: 255 })}
id="description"
className="formControl"
/>
<DangerText>{errors.description?.type === 'maxLength' && (getLabel('webhook', 'description') + ' ' + I18n.t('activerecord.errors.messages.too_long', { count: 255 }))}</DangerText>
</div>
</div>
<div className="formGroup">
<label htmlFor="trigger">{ getLabel('webhook', 'trigger') }</label>
<select
{...register('trigger')}
id="trigger"
className="selectPicker"
>
<option value={WEBHOOK_TRIGGER_NEW_POST}>
{I18n.t('site_settings.webhooks.triggers.new_post')}
</option>
<option value={WEBHOOK_TRIGGER_NEW_POST_PENDING_APPROVAL}>
{I18n.t('site_settings.webhooks.triggers.new_post_pending_approval')}
</option>
<option value={WEBHOOK_TRIGGER_DELETED_POST}>
{I18n.t('site_settings.webhooks.triggers.delete_post')}
</option>
<option value={WEBHOOK_TRIGGER_POST_STATUS_CHANGE}>
{I18n.t('site_settings.webhooks.triggers.post_status_change')}
</option>
<option value={WEBHOOK_TRIGGER_NEW_COMMENT}>
{I18n.t('site_settings.webhooks.triggers.new_comment')}
</option>
<option value={WEBHOOK_TRIGGER_NEW_VOTE}>
{I18n.t('site_settings.webhooks.triggers.new_vote')}
</option>
<option value={WEBHOOK_TRIGGER_NEW_USER}>
{I18n.t('site_settings.webhooks.triggers.new_user')}
</option>
</select>
<DangerText>{errors.trigger && getValidationMessage(errors.trigger.type, 'webhook', 'trigger')}</DangerText>
</div>
<div className="formRow">
<div className="formGroup col-3">
<label htmlFor="httpMethod">{ getLabel('webhook', 'http_method') }</label>
<select
{...register('httpMethod')}
id="httpMethod"
className="selectPicker"
>
<option value={WEBHOOK_HTTP_METHOD_POST}>
{I18n.t('site_settings.webhooks.http_methods.post')}
</option>
<option value={WEBHOOK_HTTP_METHOD_PUT}>
{I18n.t('site_settings.webhooks.http_methods.put')}
</option>
<option value={WEBHOOK_HTTP_METHOD_PATCH}>
{I18n.t('site_settings.webhooks.http_methods.patch')}
</option>
<option value={WEBHOOK_HTTP_METHOD_DELETE}>
{I18n.t('site_settings.webhooks.http_methods.delete')}
</option>
</select>
</div>
<div className="formGroup col-9">
<label htmlFor="url">
{ getLabel('webhook', 'url') }
&nbsp;
{ <LiquidIcon /> }
</label>
<input
{...register('url', {
required: true,
pattern: URL_REGEX_WHITESPACE_ALLOWED,
onChange: () => setIsPreviewOutdated(true),
})}
autoComplete="off"
id="url"
className="formControl"
/>
<DangerText>{errors.url?.type === 'required' && getValidationMessage(errors.url.type, 'webhook', 'url')}</DangerText>
<DangerText>{errors.url?.type === 'pattern' && I18n.t('common.validations.url')}</DangerText>
</div>
</div>
<div className="formGroup">
<label htmlFor="httpBody">
{ getLabel('webhook', 'http_body') }
&nbsp;
{ <LiquidIcon /> }
</label>
<textarea
{...register('httpBody', {
onChange: () => setIsPreviewOutdated(true)
})}
ref={(e) => {
register('httpBody').ref(e); // Combine react-hook-form's ref with custom ref
httpBodyTextAreaRef.current = e; // Store a local reference
}}
onClick={handleCursorPosition}
onKeyUp={handleCursorPosition}
id="httpBody"
className="formControl"
/>
<div className="httpBodyActions">
<TemplateVariablesSelector webhookTrigger={trigger} onChange={insertString} />
<ActionLink
icon={<PreviewIcon />}
onClick={async () => {
if ((url === '' && httpBody === '') || !isPreviewOutdated) return;
const res = await fetch(`/webhooks_preview`, {
method: 'PUT',
headers: buildRequestHeaders(authenticityToken),
body: JSON.stringify({
webhook: {
trigger: trigger,
url: url,
http_body: httpBody,
}
}),
});
const json = await res.json();
if (res.status === HttpStatus.OK) {
setPreviewContent(
getLabel('webhook', 'url') + ":\n" +
json.url_preview + "\n\n" +
getLabel('webhook', 'http_body') + ":\n" +
json.http_body_preview
);
} else {
setPreviewContent(
I18n.t('site_settings.webhooks.form.preview_error') + "\n" +
json.error
)
}
setIsPreviewOutdated(false);
setIsPreviewVisible(true);
}}
disabled={(url === '' && httpBody === '') || !isPreviewOutdated}
customClass="previewHttpBody"
>
{I18n.t('common.buttons.preview')}
</ActionLink>
</div>
{
isPreviewVisible &&
<div className="urlAndHttpBodyPreview">
<label>{ I18n.t('common.buttons.preview') }</label>
<pre id="preview">{previewContent}</pre>
</div>
}
</div>
<div className="formGroup formGroupHttpHeaders">
{
fields.map((field, index) => (
<div className="formRow" key={field.id}>
<div className="formGroup col-5">
<label htmlFor={`httpHeaders${index+1}Key`}>{ I18n.t('site_settings.webhooks.form.header_n_key', { n: index+1 }) }</label>
<input
{...register(`httpHeaders.${index}.key`, { required: (index!==0) })}
id={`httpHeaders${index+1}Key`}
className="formControl"
/>
<DangerText>
{errors.httpHeaders && errors.httpHeaders[index]?.key?.type === 'required' && getValidationMessage(errors.httpHeaders[index]?.key?.type, 'webhook', 'http_headers')}
</DangerText>
</div>
<div className="formGroup col-5">
<label htmlFor={`httpHeaders${index+1}Value`}>{ I18n.t('site_settings.webhooks.form.header_n_value', { n: index+1 }) }</label>
<input
{...register(`httpHeaders.${index}.value`, { required: (index!==0) })}
autoComplete="off"
id={`httpHeaders${index+1}Value`}
className="formControl"
/>
<DangerText>
{errors.httpHeaders && errors.httpHeaders[index]?.value?.type === 'required' && getValidationMessage(errors.httpHeaders[index]?.value?.type, 'webhook', 'http_headers')}
</DangerText>
</div>
<div className="formGroup col-2 deleteHeaderActionLinkContainer">
<ActionLink icon={<DeleteIcon />} onClick={() => remove(index)}>
{I18n.t('common.buttons.delete')}
</ActionLink>
</div>
</div>
))
}
</div>
<ActionLink icon={<AddIcon />} onClick={() => append({ key: "", value: "" })}>
{I18n.t('site_settings.webhooks.form.add_header')}
</ActionLink>
<Button onClick={() => null} type="submit" className="submitWebhookFormButton">
{
isSubmitting ?
<Spinner color="light" />
:
page === 'new' ?
I18n.t('common.buttons.create')
:
I18n.t('common.buttons.update')
}
</Button>
</form>
{ submitError && <p style={{marginTop: '1rem', marginBottom: '0'}}><DangerText>{submitError}</DangerText></p> }
</>
);
}
export default WebhookFormPage;

View File

@@ -0,0 +1,48 @@
import * as React from 'react';
import { WebhookPages } from './WebhooksSiteSettingsP';
import Box from '../../common/Box';
import { IWebhook } from '../../../interfaces/IWebhook';
import WebhookForm, { ISiteSettingsWebhookFormUpdate } from './WebhookForm';
interface Props {
isSubmitting: boolean;
submitError: string;
handleSubmitWebhook(webhook: IWebhook): void;
handleUpdateWebhook(id: number, form: ISiteSettingsWebhookFormUpdate): void;
selectedWebhook: IWebhook;
page: WebhookPages;
setPage: React.Dispatch<React.SetStateAction<WebhookPages>>;
authenticityToken: string;
}
const WebhookFormPage = ({
isSubmitting,
submitError,
handleSubmitWebhook,
handleUpdateWebhook,
selectedWebhook,
page,
setPage,
authenticityToken,
}: Props) => (
<>
<Box customClass="webhookFormPage">
<WebhookForm
isSubmitting={isSubmitting}
submitError={submitError}
handleSubmitWebhook={handleSubmitWebhook}
handleUpdateWebhook={handleUpdateWebhook}
selectedWebhook={selectedWebhook}
page={page}
setPage={setPage}
authenticityToken={authenticityToken}
/>
</Box>
</>
);
export default WebhookFormPage;

View File

@@ -0,0 +1,88 @@
import * as React from 'react';
import I18n from 'i18n-js';
import { IWebhook } from '../../../interfaces/IWebhook';
import { WebhookPages } from './WebhooksSiteSettingsP';
import Switch from '../../common/Switch';
import ActionLink from '../../common/ActionLink';
import { DeleteIcon, EditIcon, TestIcon } from '../../common/Icons';
import buildRequestHeaders from '../../../helpers/buildRequestHeaders';
const WEBHOOK_DESCRIPTION_MAX_LENGTH = 100;
interface Props {
webhook: IWebhook;
handleToggleEnabledWebhook: (id: number, enabled: boolean) => void;
handleDeleteWebhook: (id: number) => void;
handleTestWebhook: (id: number) => void;
setSelectedWebhook: React.Dispatch<React.SetStateAction<number>>;
setPage: React.Dispatch<React.SetStateAction<WebhookPages>>;
}
const WebhookListItem = ({
webhook,
handleToggleEnabledWebhook,
handleDeleteWebhook,
handleTestWebhook,
setSelectedWebhook,
setPage,
}: Props) => (
<li className="webhookListItem">
<div className="webhookInfo">
<div className="webhookNameAndEnabled">
<div className="webhookName">{webhook.name}</div>
{ webhook.description &&
<p className="webhookDescription">
{
webhook.description.length > WEBHOOK_DESCRIPTION_MAX_LENGTH ?
`${webhook.description.slice(0, WEBHOOK_DESCRIPTION_MAX_LENGTH)}...`
:
webhook.description
}
</p>
}
<Switch
label={I18n.t(`common.${webhook.isEnabled ? 'enabled' : 'disabled'}`)}
onClick={() => handleToggleEnabledWebhook(webhook.id, !webhook.isEnabled)}
checked={webhook.isEnabled}
htmlId={`webhook${webhook.name}EnabledSwitch`}
/>
</div>
</div>
<div className="webhookActions">
<ActionLink
onClick={() => handleTestWebhook(webhook.id)}
icon={<TestIcon />}
customClass='testAction'
>
{I18n.t('common.buttons.test')}
</ActionLink>
<ActionLink
onClick={() => {
setSelectedWebhook(webhook.id);
setPage('edit');
}}
icon={<EditIcon />}
customClass="editAction"
>
{I18n.t('common.buttons.edit')}
</ActionLink>
<ActionLink
onClick={() => confirm(I18n.t('common.confirmation')) && handleDeleteWebhook(webhook.id)}
icon={<DeleteIcon />}
customClass="deleteAction"
>
{I18n.t('common.buttons.delete')}
</ActionLink>
</div>
</li>
);
export default WebhookListItem;

View File

@@ -0,0 +1,84 @@
import * as React from 'react';
import I18n from 'i18n-js';
import Box from '../../common/Box';
import { IWebhook } from '../../../interfaces/IWebhook';
import { WebhookPages } from './WebhooksSiteSettingsP';
import ActionLink from '../../common/ActionLink';
import { BackIcon, EditIcon, TestIcon } from '../../common/Icons';
import buildRequestHeaders from '../../../helpers/buildRequestHeaders';
import Badge, { BADGE_TYPE_DANGER, BADGE_TYPE_SUCCESS } from '../../common/Badge';
interface Props {
selectedWebhook: IWebhook;
testHttpCode: number;
testHttpResponse: string;
setSelectedWebhook: React.Dispatch<React.SetStateAction<number>>;
setPage: React.Dispatch<React.SetStateAction<WebhookPages>>;
handleTestWebhook: (id: number) => void;
}
const WebhookTestPage = ({
selectedWebhook,
testHttpCode,
testHttpResponse,
setSelectedWebhook,
setPage,
handleTestWebhook,
}: Props) => (
<Box customClass="webhookTestPage">
<ActionLink
onClick={() => setPage('index') }
icon={<BackIcon />}
customClass="backButton"
>
{I18n.t('common.buttons.back')}
</ActionLink>
<div className="webhookTestTitle">
<h2>{I18n.t('site_settings.webhooks.test_page.title')}</h2>
</div>
<div className="webhookTestContent">
<div className="webhookTestInfo">
<p>
<b>{I18n.t('activerecord.models.webhook', { count: 1 })}</b>:&nbsp;
<span>{selectedWebhook.name}</span>
</p>
<div className="webhookActions">
<ActionLink
onClick={() => handleTestWebhook(selectedWebhook.id)}
icon={<TestIcon />}
customClass='testAction'
>
{I18n.t('common.buttons.test')}
</ActionLink>
<ActionLink
onClick={() => {
setSelectedWebhook(selectedWebhook.id);
setPage('edit');
}}
icon={<EditIcon />}
customClass="editAction"
>
{I18n.t('common.buttons.edit') + ' ' + I18n.t('activerecord.models.webhook', { count: 1 })}
</ActionLink>
</div>
</div>
<div className="webhookTestResponse">
<Badge type={Array.from({length: 100}, (_, i) => i + 200).includes(testHttpCode) ? BADGE_TYPE_SUCCESS : BADGE_TYPE_DANGER}>
{testHttpCode.toString()}
</Badge>
<pre id="testHttpResponse">{testHttpResponse}</pre>
</div>
</div>
</Box>
);
export default WebhookTestPage;

View File

@@ -0,0 +1,77 @@
import * as React from 'react';
import I18n from 'i18n-js';
import Box from '../../common/Box';
import { WebhooksState } from '../../../reducers/webhooksReducer';
import { WebhookPages } from './WebhooksSiteSettingsP';
import Button from '../../common/Button';
import WebhooksList from './WebhooksList';
import SiteSettingsInfoBox from '../../common/SiteSettingsInfoBox';
import ActionLink from '../../common/ActionLink';
import { LearnMoreIcon } from '../../common/Icons';
interface Props {
webhooks: WebhooksState;
isSubmitting: boolean;
isTesting: boolean;
submitError: string;
handleToggleEnabledWebhook: (id: number, enabled: boolean) => void;
handleDeleteWebhook: (id: number) => void;
handleTestWebhook: (id: number) => void;
setSelectedWebhook: React.Dispatch<React.SetStateAction<number>>;
setPage: React.Dispatch<React.SetStateAction<WebhookPages>>;
}
const WebhooksIndexPage = ({
webhooks,
isSubmitting,
isTesting,
submitError,
handleToggleEnabledWebhook,
handleDeleteWebhook,
handleTestWebhook,
setSelectedWebhook,
setPage,
}: Props) => {
return (
<>
<Box customClass="webhooksIndexPage">
<div className="webhooksTitle">
<h2>{I18n.t('site_settings.webhooks.title')}</h2>
<Button onClick={() => setPage('new')}>
{ I18n.t('common.buttons.new') }
</Button>
</div>
<p style={{textAlign: 'left'}}>
<ActionLink
onClick={() => window.open('https://docs.astuto.io/webhooks/webhooks-introduction/', '_blank')}
icon={<LearnMoreIcon />}
>
{I18n.t('site_settings.webhooks.learn_more')}
</ActionLink>
</p>
<WebhooksList
webhooks={webhooks.items}
webhooksAreLoading={webhooks.areLoading}
handleToggleEnabledWebhook={handleToggleEnabledWebhook}
handleDeleteWebhook={handleDeleteWebhook}
handleTestWebhook={handleTestWebhook}
setSelectedWebhook={setSelectedWebhook}
setPage={setPage}
/>
</Box>
<SiteSettingsInfoBox
areUpdating={webhooks.areLoading || isSubmitting || isTesting}
error={webhooks.error || submitError}
/>
</>
);
};
export default WebhooksIndexPage;

View File

@@ -0,0 +1,67 @@
import * as React from 'react';
import I18n from 'i18n-js';
import { WebhookPages } from './WebhooksSiteSettingsP';
import { IWebhook } from '../../../interfaces/IWebhook';
import WebhookListItem from './WebhookListItem';
import { CenteredMutedText } from '../../common/CustomTexts';
import Spinner from '../../common/Spinner';
interface Props {
webhooks: Array<IWebhook>;
webhooksAreLoading: boolean;
handleToggleEnabledWebhook: (id: number, enabled: boolean) => void;
handleDeleteWebhook: (id: number) => void;
handleTestWebhook: (id: number) => void;
setSelectedWebhook: React.Dispatch<React.SetStateAction<number>>;
setPage: React.Dispatch<React.SetStateAction<WebhookPages>>;
}
const WebhooksList = ({
webhooks,
webhooksAreLoading,
handleToggleEnabledWebhook,
handleDeleteWebhook,
handleTestWebhook,
setSelectedWebhook,
setPage,
}: Props) => {
// from webhooks, get a unique list of triggers
const triggers = Array.from(new Set(webhooks.map(webhook => webhook.trigger)));
if (webhooksAreLoading) return <Spinner />;
return (
<div className="webhooksList">
{
(webhooks && webhooks.length > 0) ?
triggers.map((trigger, i) => (
<div key={i}>
<h4>{I18n.t(`site_settings.webhooks.triggers.${trigger}`)}</h4>
<ul>
{
webhooks.filter(webhook => webhook.trigger === trigger).map((webhook, j) => (
<WebhookListItem
webhook={webhook}
handleToggleEnabledWebhook={handleToggleEnabledWebhook}
handleDeleteWebhook={handleDeleteWebhook}
handleTestWebhook={handleTestWebhook}
setSelectedWebhook={setSelectedWebhook}
setPage={setPage}
key={j}
/>
))
}
</ul>
</div>
))
:
<CenteredMutedText>{I18n.t('site_settings.webhooks.empty')}</CenteredMutedText>
}
</div>
);
};
export default WebhooksList;

View File

@@ -0,0 +1,124 @@
import * as React from 'react';
import { useState, useEffect } from 'react';
import { WebhooksState } from '../../../reducers/webhooksReducer';
import WebhooksIndexPage from './WebhooksIndexPage';
import WebhookFormPage from './WebhookFormPage';
import { IWebhook } from '../../../interfaces/IWebhook';
import HttpStatus from '../../../constants/http_status';
import { ISiteSettingsWebhookFormUpdate } from './WebhookForm';
import WebhookTestPage from './WebhookTestPage';
import buildRequestHeaders from '../../../helpers/buildRequestHeaders';
interface Props {
webhooks: WebhooksState;
isSubmitting: boolean;
submitError: string;
requestWebhooks(): void;
onSubmitWebhook(webhook: IWebhook, authenticityToken: string): Promise<any>;
onUpdateWebhook(id: number, form: ISiteSettingsWebhookFormUpdate, authenticityToken: string): Promise<any>;
onToggleEnabledWebhook(id: number, isEnabled: boolean, authenticityToken: string): Promise<any>;
onDeleteWebhook(id: number, authenticityToken: string): void;
authenticityToken: string;
}
export type WebhookPages = 'index' | 'new' | 'edit' | 'test';
const WebhooksSiteSettingsP = ({
webhooks,
isSubmitting,
submitError,
requestWebhooks,
onSubmitWebhook,
onUpdateWebhook,
onToggleEnabledWebhook,
onDeleteWebhook,
authenticityToken,
}: Props) => {
const [page, setPage] = useState<WebhookPages>('index');
const [selectedWebhook, setSelectedWebhook] = useState<number>(null);
const [isTesting, setIsTesting] = useState<boolean>(false);
const [testHttpCode, setTestHttpCode] = useState<number>(null);
const [testHttpResponse, setTestHttpResponse] = useState<string>(null);
useEffect(requestWebhooks, []);
const handleSubmitWebhook = (webhook: IWebhook) => {
onSubmitWebhook(webhook, authenticityToken).then(res => {
if (res?.status === HttpStatus.Created) window.location.reload();
});
};
const handleUpdateWebhook = (id: number, form: ISiteSettingsWebhookFormUpdate) => {
onUpdateWebhook(id, form, authenticityToken).then(res => {
if (res?.status === HttpStatus.OK) window.location.reload();
});
};
const handleToggleEnabledWebhook = (id: number, enabled: boolean) => {
onToggleEnabledWebhook(id, enabled, authenticityToken).then(res => {
if (res?.status === HttpStatus.OK) window.location.reload();
});
};
const handleDeleteWebhook = (id: number) => {
onDeleteWebhook(id, authenticityToken);
};
const handleTestWebhook = async (id: number) => {
setIsTesting(true);
const res = await fetch(`/webhooks/${id}/test`, {
method: 'PUT',
headers: buildRequestHeaders(authenticityToken),
});
const json = await res.json();
setTestHttpCode(res.status);
setTestHttpResponse(JSON.stringify(json, null, 2));
setSelectedWebhook(id);
setPage('test');
setIsTesting(false);
};
return (
page === 'index' ?
<WebhooksIndexPage
webhooks={webhooks}
isSubmitting={isSubmitting}
isTesting={isTesting}
submitError={submitError}
handleToggleEnabledWebhook={handleToggleEnabledWebhook}
handleDeleteWebhook={handleDeleteWebhook}
handleTestWebhook={handleTestWebhook}
setSelectedWebhook={setSelectedWebhook}
setPage={setPage}
/>
:
(page === 'new' || page === 'edit') ?
<WebhookFormPage
isSubmitting={isSubmitting}
submitError={submitError}
handleSubmitWebhook={handleSubmitWebhook}
handleUpdateWebhook={handleUpdateWebhook}
selectedWebhook={webhooks.items.find(webhook => webhook.id === selectedWebhook)}
page={page}
setPage={setPage}
authenticityToken={authenticityToken}
/>
:
<WebhookTestPage
selectedWebhook={webhooks.items.find(webhook => webhook.id === selectedWebhook)}
testHttpCode={testHttpCode}
testHttpResponse={testHttpResponse}
setSelectedWebhook={setSelectedWebhook}
setPage={setPage}
handleTestWebhook={handleTestWebhook}
/>
);
};
export default WebhooksSiteSettingsP;

View File

@@ -0,0 +1,33 @@
import * as React from 'react';
import { Provider } from 'react-redux';
import { Store } from 'redux';
import createStoreHelper from '../../../helpers/createStore';
import { State } from '../../../reducers/rootReducer';
import WebhooksSiteSettings from '../../../containers/WebhooksSiteSettings';
interface Props {
authenticityToken: string;
}
class WebhooksSiteSettingsRoot extends React.Component<Props> {
store: Store<State, any>;
constructor(props: Props) {
super(props);
this.store = createStoreHelper();
}
render() {
return (
<Provider store={this.store}>
<WebhooksSiteSettings
authenticityToken={this.props.authenticityToken}
/>
</Provider>
);
}
}
export default WebhooksSiteSettingsRoot;

View File

@@ -78,7 +78,6 @@ const UserSignUpForm = ({
<OAuthProviderLink <OAuthProviderLink
oAuthId={oAuth.id} oAuthId={oAuth.id}
oAuthName={oAuth.name} oAuthName={oAuth.name}
oAuthLogo={oAuth.logo}
oAuthReason='tenantsignup' oAuthReason='tenantsignup'
isSignUp isSignUp
key={i} key={i}

View File

@@ -0,0 +1,55 @@
import * as React from 'react';
import I18n from 'i18n-js';
import ActionLink from '../common/ActionLink';
import { DeleteIcon } from '../common/Icons';
import buildRequestHeaders from '../../helpers/buildRequestHeaders';
import Spinner from '../common/Spinner';
interface Props {
deleteAvatarEndpoint: string;
userProfileUrl: string;
authenticityToken: string;
}
const DeleteAvatarButton = ({ deleteAvatarEndpoint, userProfileUrl, authenticityToken }: Props) => {
const [isDeleting, setIsDeleting] = React.useState(false);
const [error, setError] = React.useState('');
return (
<>
<ActionLink
icon={<DeleteIcon />}
onClick={async () => {
setIsDeleting(true);
try {
const response = await fetch(deleteAvatarEndpoint, {
method: 'DELETE',
headers: buildRequestHeaders(authenticityToken),
});
if (response.ok) {
window.location.href = userProfileUrl;
} else {
throw new Error();
}
} catch {
setError(I18n.t('common.errors.unknown'));
}
setIsDeleting(false);
if (error) {
alert(error);
}
}
}>
{ I18n.t('common.buttons.delete') }
</ActionLink>
{ isDeleting && <Spinner /> }
</>
);
}
export default DeleteAvatarButton;

View File

@@ -0,0 +1,21 @@
import * as React from 'react';
import Gravatar from 'react-gravatar';
interface Props {
avatarUrl?: string;
email: string;
customClass?: string;
size?: number;
}
const Avatar = ({avatarUrl, email, customClass, size = 28}: Props) => {
const customClassName = `avatar${customClass ? ' '+customClass : ''}`;
if (avatarUrl) {
return <img src={avatarUrl} alt={`${email} avatar`} width={size} height={size} className={customClassName} />;
} else {
return <Gravatar email={email} size={size} className={customClassName} />;
}
}
export default Avatar;

View File

@@ -3,11 +3,13 @@ import * as React from 'react';
export const BADGE_TYPE_LIGHT = 'badgeLight'; export const BADGE_TYPE_LIGHT = 'badgeLight';
export const BADGE_TYPE_WARNING = 'badgeWarning'; export const BADGE_TYPE_WARNING = 'badgeWarning';
export const BADGE_TYPE_DANGER = 'badgeDanger'; export const BADGE_TYPE_DANGER = 'badgeDanger';
export const BADGE_TYPE_SUCCESS = 'badgeSuccess';
export type BadgeTypes = export type BadgeTypes =
typeof BADGE_TYPE_LIGHT | typeof BADGE_TYPE_LIGHT |
typeof BADGE_TYPE_WARNING | typeof BADGE_TYPE_WARNING |
typeof BADGE_TYPE_DANGER; typeof BADGE_TYPE_DANGER |
typeof BADGE_TYPE_SUCCESS;
interface Props { interface Props {
type: BadgeTypes; type: BadgeTypes;

View File

@@ -0,0 +1,96 @@
import React, {useEffect} from 'react';
import {useDropzone} from 'react-dropzone';
import I18n from 'i18n-js';
import { SmallMutedText } from './CustomTexts';
import ActionLink from './ActionLink';
import { DeleteIcon } from './Icons';
interface Props {
files: any[];
setFiles: React.Dispatch<React.SetStateAction<any[]>>;
maxSizeKB?: number;
maxFiles?: number;
accept?: string[];
customStyle?: { [key: string]: string };
}
const Dropzone = ({
files,
setFiles,
maxSizeKB = 256,
maxFiles = 1,
accept = ['image/png', 'image/jpeg', 'image/jpg', 'image/x-icon', 'image/icon', 'image/svg+xml', 'image/svg', 'image/webp'],
customStyle = {}
}: Props) => {
const acceptDict = accept.reduce((acc, type) => {
acc[type] = [];
return acc;
}, {});
const {
getRootProps,
getInputProps,
isDragAccept,
isDragReject
} = useDropzone({
accept: acceptDict,
maxSize: maxSizeKB * 1024,
maxFiles: maxFiles,
disabled: files.length >= maxFiles,
onDrop: (acceptedFiles, fileRejections) => {
if (fileRejections.length > 0) {
alert(I18n.t('common.errors.invalid_file', { errors: fileRejections.map(rejection => rejection.errors[0].message).join(', ') }));
}
// If number of files already uploaded is lower than maxFiles, add the new files
const filesAfterDrop = (files.length + acceptedFiles.length <= maxFiles) ? files.concat(acceptedFiles) : acceptedFiles;
setFiles(filesAfterDrop.map(file => Object.assign(file, {
preview: URL.createObjectURL(file)
})));
},
});
const thumbnails = files.map(file => (
<div className="thumbnailContainer" key={file.name}>
<div className="thumbnail">
<div className="thumbnailInner">
<img
src={file.preview}
className="thumbnailImage"
// Revoke data uri after image is loaded
onLoad={() => { URL.revokeObjectURL(file.preview) }}
/>
</div>
</div>
<ActionLink
onClick={() => setFiles(files.filter(f => f !== file))}
icon={<DeleteIcon />}
customClass="removeThumbnail"
>
{I18n.t('common.buttons.delete')}
</ActionLink>
</div>
));
useEffect(() => {
// Make sure to revoke the data uris to avoid memory leaks, will run on unmount
return () => files.forEach(file => URL.revokeObjectURL(file.preview));
}, [files]);
return (
<section>
<div {...getRootProps({className: 'dropzone' + (isDragAccept ? ' dropzone-accept' : isDragReject ? ' dropzone-reject' : '') + (files.length >= maxFiles ? ' dropzone-disabled' : '')})} style={customStyle}>
<input {...getInputProps()} />
<SmallMutedText>{I18n.t('common.drag_and_drop', { maxCount: maxFiles, maxSize: maxSizeKB })}</SmallMutedText>
</div>
<aside className="thumbnailsContainer">
{thumbnails}
</aside>
</section>
);
}
export default Dropzone;

View File

@@ -1,11 +1,12 @@
import * as React from 'react'; import * as React from 'react';
import I18n from 'i18n-js'; import I18n from 'i18n-js';
import { Tooltip } from 'react-tooltip'
import { BsReply } from 'react-icons/bs'; import { BsReply } from 'react-icons/bs';
import { FiEdit, FiDelete, FiSettings } from 'react-icons/fi'; import { FiEdit, FiDelete, FiSettings } from 'react-icons/fi';
import { ImCancelCircle } from 'react-icons/im'; import { ImCancelCircle } from 'react-icons/im';
import { TbLock, TbLockOpen } from 'react-icons/tb'; import { TbLock, TbLockOpen } from 'react-icons/tb';
import { GrTest, GrClearOption } from 'react-icons/gr'; import { GrTest, GrClearOption, GrOverview } from 'react-icons/gr';
import { BiLike, BiSolidLike } from "react-icons/bi"; import { BiLike, BiSolidLike } from "react-icons/bi";
import { import {
MdContentCopy, MdContentCopy,
@@ -15,8 +16,11 @@ import {
MdVerified, MdVerified,
MdCheck, MdCheck,
MdClear, MdClear,
MdAdd,
MdAttachFile,
} from 'react-icons/md'; } from 'react-icons/md';
import { FaUserNinja } from "react-icons/fa"; import { FaUserNinja, FaMarkdown, FaRegImage } from "react-icons/fa";
import { FaDroplet } from "react-icons/fa6";
export const EditIcon = () => <FiEdit />; export const EditIcon = () => <FiEdit />;
@@ -41,9 +45,12 @@ export const ReplyIcon = () => <BsReply />;
export const LearnMoreIcon = () => <MdOutlineLibraryBooks />; export const LearnMoreIcon = () => <MdOutlineLibraryBooks />;
export const StaffIcon = () => ( export const StaffIcon = () => (
<span title={I18n.t('common.user_staff')} className="staffIcon"> <>
<span data-tooltip-id="staff-tooltip" data-tooltip-content={I18n.t('common.user_staff')} className="staffIcon">
<MdVerified /> <MdVerified />
</span> </span>
<Tooltip id="staff-tooltip" />
</>
); );
export const ClearIcon = () => <GrClearOption />; export const ClearIcon = () => <GrClearOption />;
@@ -54,8 +61,54 @@ export const SolidLikeIcon = ({size = 32}) => <BiSolidLike size={size} />;
export const SettingsIcon = () => <FiSettings />; export const SettingsIcon = () => <FiSettings />;
export const AnonymousIcon = ({size = 32, title=I18n.t('defaults.user_full_name')}) => <FaUserNinja size={size} title={title} />; export const AnonymousIcon = ({size = 32}) => (
<>
<span data-tooltip-id="anonymous-tooltip" data-tooltip-content={I18n.t('defaults.user_full_name')} className="anonymousIcon">
<FaUserNinja size={size} />
</span>
<Tooltip id="anonymous-tooltip" />
</>
);
export const ApproveIcon = () => <MdCheck />; export const ApproveIcon = () => <MdCheck />;
export const RejectIcon = () => <MdClear />; export const RejectIcon = () => <MdClear />;
export const AddIcon = () => <MdAdd />;
export const PreviewIcon = ({size = 24}) => <GrOverview size={size} />;
export const LiquidIcon = ({size = 18}) => (
<>
<a href="https://shopify.github.io/liquid/" target="_blank" rel="noreferrer" className="link">
<span
data-tooltip-id="liquid-tooltip"
data-tooltip-content={I18n.t('common.language_supported', { language: 'Liquid' })}
className="liquidIcon"
>
<FaDroplet size={size} />
</span>
</a>
<Tooltip id="liquid-tooltip" />
</>
);
export const MarkdownIcon = ({size = 24, style = {}}) => (
<>
<a href="https://www.markdownguide.org/basic-syntax/" target="_blank" rel="noreferrer" className="link">
<span
data-tooltip-id="markdown-tooltip"
data-tooltip-content={I18n.t('common.language_supported', { language: 'Markdown' })}
style={{...style, ...{opacity: 0.75}}}
className="markdownIcon"
>
<FaMarkdown size={size} />
</span>
</a>
<Tooltip id="markdown-tooltip" />
</>
);
export const AttachIcon = ({size = 24}) => <MdAttachFile size={size} />;
export const ImageIcon = ({size = 24}) => <FaRegImage size={size} />;

View File

@@ -1,2 +1,3 @@
export const EMAIL_REGEX = /(.+)@(.+){2,}\.(.+){2,}/; export const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export const URL_REGEX = /^(ftp|http|https):\/\/[^ "]+$/; export const URL_REGEX = /^(ftp|http|https):\/\/[^ "]+$/;
export const URL_REGEX_WHITESPACE_ALLOWED = /^(ftp|http|https):\/\/[^"]+$/;

View File

@@ -24,12 +24,12 @@ const mapDispatchToProps = (dispatch: any) => ({
dispatch(requestOAuths()); dispatch(requestOAuths());
}, },
onSubmitOAuth(oAuth: IOAuth, authenticityToken: string): Promise<any> { onSubmitOAuth(oAuth: IOAuth, oAuthLogo: File = null, authenticityToken: string): Promise<any> {
return dispatch(submitOAuth(oAuth, authenticityToken)); return dispatch(submitOAuth(oAuth, oAuthLogo, authenticityToken));
}, },
onUpdateOAuth(id: number, form: ISiteSettingsOAuthForm, authenticityToken: string): Promise<any> { onUpdateOAuth(id: number, form: ISiteSettingsOAuthForm, shouldDeleteLogo: boolean, authenticityToken: string): Promise<any> {
return dispatch(updateOAuth({id, form, authenticityToken})); return dispatch(updateOAuth({id, form, isEnabled: undefined, shouldDeleteLogo, authenticityToken}));
}, },
onToggleEnabledOAuth(id: number, isEnabled: boolean, authenticityToken: string) { onToggleEnabledOAuth(id: number, isEnabled: boolean, authenticityToken: string) {

View File

@@ -44,9 +44,13 @@ const mapDispatchToProps = (dispatch) => ({
body: string, body: string,
parentId: number, parentId: number,
isPostUpdate: boolean, isPostUpdate: boolean,
attachments: File[],
onSuccess: Function,
authenticityToken: string, authenticityToken: string,
) { ) {
dispatch(submitComment(postId, body, parentId, isPostUpdate, authenticityToken)); dispatch(submitComment(postId, body, parentId, isPostUpdate, attachments, authenticityToken)).then(res => {
if (res && res.status === HttpStatus.Created) onSuccess();
});
}, },
updateComment( updateComment(
@@ -54,10 +58,12 @@ const mapDispatchToProps = (dispatch) => ({
commentId: number, commentId: number,
body: string, body: string,
isPostUpdate: boolean, isPostUpdate: boolean,
attachmentsToDelete: number[],
attachments: File[],
onSuccess: Function, onSuccess: Function,
authenticityToken: string, authenticityToken: string,
) { ) {
dispatch(updateComment(postId, commentId, body, isPostUpdate, authenticityToken)).then(res => { dispatch(updateComment(postId, commentId, body, isPostUpdate, attachmentsToDelete, attachments, authenticityToken)).then(res => {
if (res && res.status === HttpStatus.OK) onSuccess(); if (res && res.status === HttpStatus.OK) onSuccess();
}); });
}, },

View File

@@ -18,7 +18,11 @@ const mapStateToProps = (state: State) => ({
const mapDispatchToProps = (dispatch: any) => ({ const mapDispatchToProps = (dispatch: any) => ({
updateTenant( updateTenant(
siteName: string, siteName: string,
siteLogo: string, siteLogo: File,
shouldDeleteSiteLogo: boolean,
oldSiteLogo: string,
siteFavicon: File,
shouldDeleteSiteFavicon: boolean,
brandDisplaySetting: TenantSettingBrandDisplay, brandDisplaySetting: TenantSettingBrandDisplay,
locale: string, locale: string,
useBrowserLocale: boolean, useBrowserLocale: boolean,
@@ -27,6 +31,7 @@ const mapDispatchToProps = (dispatch: any) => ({
isPrivate: boolean, isPrivate: boolean,
allowAnonymousFeedback: boolean, allowAnonymousFeedback: boolean,
feedbackApprovalPolicy: TenantSettingFeedbackApprovalPolicy, feedbackApprovalPolicy: TenantSettingFeedbackApprovalPolicy,
allowAttachmentUpload: boolean,
logoLinksTo: TenantSettingLogoLinksTo, logoLinksTo: TenantSettingLogoLinksTo,
logoCustomUrl: string, logoCustomUrl: string,
showRoadmapInHeader: boolean, showRoadmapInHeader: boolean,
@@ -40,6 +45,10 @@ const mapDispatchToProps = (dispatch: any) => ({
return dispatch(updateTenant({ return dispatch(updateTenant({
siteName, siteName,
siteLogo, siteLogo,
shouldDeleteSiteLogo,
oldSiteLogo,
siteFavicon,
shouldDeleteSiteFavicon,
tenantSetting: { tenantSetting: {
brand_display: brandDisplaySetting, brand_display: brandDisplaySetting,
use_browser_locale: useBrowserLocale, use_browser_locale: useBrowserLocale,
@@ -47,6 +56,7 @@ const mapDispatchToProps = (dispatch: any) => ({
is_private: isPrivate, is_private: isPrivate,
allow_anonymous_feedback: allowAnonymousFeedback, allow_anonymous_feedback: allowAnonymousFeedback,
feedback_approval_policy: feedbackApprovalPolicy, feedback_approval_policy: feedbackApprovalPolicy,
allow_attachment_upload: allowAttachmentUpload,
logo_links_to: logoLinksTo, logo_links_to: logoLinksTo,
logo_custom_url: logoCustomUrl, logo_custom_url: logoCustomUrl,
show_roadmap_in_header: showRoadmapInHeader, show_roadmap_in_header: showRoadmapInHeader,

View File

@@ -44,9 +44,11 @@ const mapDispatchToProps = (dispatch) => ({
description: string, description: string,
boardId: number, boardId: number,
postStatusId: number, postStatusId: number,
attachmentsToDelete: number[],
attachments: File[],
authenticityToken: string, authenticityToken: string,
) { ) {
return dispatch(updatePost(postId, title, description, boardId, postStatusId, authenticityToken)); return dispatch(updatePost(postId, title, description, boardId, postStatusId, attachmentsToDelete, attachments, authenticityToken));
}, },
toggleEditMode() { toggleEditMode() {

View File

@@ -0,0 +1,44 @@
import { connect } from "react-redux";
import WebhooksSiteSettingsP from "../components/SiteSettings/Webhooks/WebhooksSiteSettingsP";
import { State } from "../reducers/rootReducer";
import { requestWebhooks } from "../actions/Webhook/requestWebhooks";
import { IWebhook } from "../interfaces/IWebhook";
import { submitWebhook } from "../actions/Webhook/submitWebhook";
import { deleteWebhook } from "../actions/Webhook/deleteWebhook";
import { ISiteSettingsWebhookFormUpdate } from "../components/SiteSettings/Webhooks/WebhookForm";
import { updateWebhook } from "../actions/Webhook/updateWebhook";
const mapStateToProps = (state: State) => ({
webhooks: state.webhooks,
isSubmitting: state.siteSettings.webhooks.isSubmitting,
submitError: state.siteSettings.webhooks.error,
});
const mapDispatchToProps = (dispatch: any) => ({
requestWebhooks() {
dispatch(requestWebhooks());
},
onSubmitWebhook(webhook: IWebhook, authenticityToken: string): Promise<any> {
return dispatch(submitWebhook(webhook, authenticityToken));
},
onUpdateWebhook(id: number, form: ISiteSettingsWebhookFormUpdate, authenticityToken: string): Promise<any> {
return dispatch(updateWebhook({id, form, authenticityToken}));
},
onToggleEnabledWebhook(id: number, isEnabled: boolean, authenticityToken: string): Promise<any> {
return dispatch(updateWebhook({id, isEnabled, authenticityToken}));
},
onDeleteWebhook(id: number, authenticityToken: string) {
dispatch(deleteWebhook(id, authenticityToken));
}
});
export default connect(
mapStateToProps,
mapDispatchToProps,
)(WebhooksSiteSettingsP);

View File

@@ -0,0 +1,20 @@
const buildFormData = (data: { [key: string]: any }) => {
const formData = new FormData();
for (const [key, value] of Object.entries(data)) {
if (value !== null && value !== undefined) {
if (Array.isArray(value)) {
// If the value is an array, append each array item to the form data separately
value.forEach((item) => {
formData.append(key, item);
});
} else {
formData.append(key, value);
}
}
}
return formData;
}
export default buildFormData;

View File

@@ -1,6 +1,6 @@
const buildRequestHeaders = (authenticityToken: string) => ({ const buildRequestHeaders = (authenticityToken: string, contentType: string = 'application/json') => ({
Accept: 'application/json', Accept: 'application/json',
'Content-Type': 'application/json', 'Content-Type': contentType,
'X-CSRF-Token': authenticityToken, 'X-CSRF-Token': authenticityToken,
}); });

View File

@@ -3,8 +3,10 @@ interface IComment {
body: string; body: string;
parentId: number; parentId: number;
isPostUpdate: boolean; isPostUpdate: boolean;
attachmentUrls?: string[];
userFullName: string; userFullName: string;
userEmail: string; userEmail: string;
userAvatar?: string;
userRole: number; userRole: number;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;

View File

@@ -4,6 +4,7 @@ interface ILike {
id: number; id: number;
fullName: string; fullName: string;
email: string; email: string;
userAvatar?: string;
} }
export default ILike; export default ILike;
@@ -12,4 +13,5 @@ export const likeJSON2JS = (likeJSON: ILikeJSON): ILike => ({
id: likeJSON.id, id: likeJSON.id,
fullName: likeJSON.full_name, fullName: likeJSON.full_name,
email: likeJSON.email, email: likeJSON.email,
userAvatar: likeJSON.user_avatar,
}); });

View File

@@ -1,7 +1,7 @@
export interface IOAuth { export interface IOAuth {
id?: number; id?: number;
name: string; name: string;
logo?: string; logoUrl?: string;
isEnabled: boolean; isEnabled: boolean;
clientId: string; clientId: string;
clientSecret?: string; clientSecret?: string;
@@ -20,7 +20,7 @@ export interface IOAuth {
export interface IOAuthJSON { export interface IOAuthJSON {
id: string; id: string;
name: string; name: string;
logo?: string; logo_url?: string;
is_enabled: boolean; is_enabled: boolean;
client_id: string; client_id: string;
client_secret?: string; client_secret?: string;
@@ -39,7 +39,7 @@ export interface IOAuthJSON {
export const oAuthJSON2JS = (oAuthJSON: IOAuthJSON): IOAuth => ({ export const oAuthJSON2JS = (oAuthJSON: IOAuthJSON): IOAuth => ({
id: parseInt(oAuthJSON.id), id: parseInt(oAuthJSON.id),
name: oAuthJSON.name, name: oAuthJSON.name,
logo: oAuthJSON.logo, logoUrl: oAuthJSON.logo_url,
isEnabled: oAuthJSON.is_enabled, isEnabled: oAuthJSON.is_enabled,
clientId: oAuthJSON.client_id, clientId: oAuthJSON.client_id,
clientSecret: oAuthJSON.client_secret, clientSecret: oAuthJSON.client_secret,
@@ -58,7 +58,7 @@ export const oAuthJSON2JS = (oAuthJSON: IOAuthJSON): IOAuth => ({
export const oAuthJS2JSON = (oAuth: IOAuth) => ({ export const oAuthJS2JSON = (oAuth: IOAuth) => ({
id: oAuth.id?.toString(), id: oAuth.id?.toString(),
name: oAuth.name, name: oAuth.name,
logo: oAuth.logo, logo_url: oAuth.logoUrl,
is_enabled: oAuth.isEnabled, is_enabled: oAuth.isEnabled,
client_id: oAuth.clientId, client_id: oAuth.clientId,
client_secret: oAuth.clientSecret, client_secret: oAuth.clientSecret,

View File

@@ -15,6 +15,8 @@ interface IPost {
title: string; title: string;
slug?: string; slug?: string;
description?: string; description?: string;
attachmentUrls?: string[];
hasAttachments?: boolean;
approvalStatus: PostApprovalStatus; approvalStatus: PostApprovalStatus;
boardId: number; boardId: number;
postStatusId?: number; postStatusId?: number;
@@ -25,6 +27,7 @@ interface IPost {
userId: number; userId: number;
userEmail: string; userEmail: string;
userFullName: string; userFullName: string;
userAvatar?: string;
createdAt: string; createdAt: string;
} }
@@ -35,6 +38,8 @@ export const postJSON2JS = (postJSON: IPostJSON): IPost => ({
title: postJSON.title, title: postJSON.title,
slug: postJSON.slug, slug: postJSON.slug,
description: postJSON.description, description: postJSON.description,
attachmentUrls: postJSON.attachment_urls,
hasAttachments: postJSON.has_attachments,
approvalStatus: postJSON.approval_status, approvalStatus: postJSON.approval_status,
boardId: postJSON.board_id, boardId: postJSON.board_id,
postStatusId: postJSON.post_status_id, postStatusId: postJSON.post_status_id,
@@ -45,5 +50,6 @@ export const postJSON2JS = (postJSON: IPostJSON): IPost => ({
userId: postJSON.user_id, userId: postJSON.user_id,
userEmail: postJSON.user_email, userEmail: postJSON.user_email,
userFullName: postJSON.user_full_name, userFullName: postJSON.user_full_name,
userAvatar: postJSON.user_avatar,
createdAt: postJSON.created_at, createdAt: postJSON.created_at,
}); });

View File

@@ -2,6 +2,7 @@ interface IPostStatusChange {
postStatusId: number; postStatusId: number;
userFullName: string; userFullName: string;
userEmail: string; userEmail: string;
userAvatar?: string;
createdAt: string; createdAt: string;
} }

Some files were not shown because too many files have changed in this diff Show More