mirror of
https://github.com/astuto/astuto.git
synced 2025-12-19 04:59:34 +01:00
Compare commits
65 Commits
l10n_main
...
add-image-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b9df2163e | ||
|
|
b87d5214de | ||
|
|
864d785cd6 | ||
|
|
c8413685d8 | ||
|
|
71cf5b2e1c | ||
|
|
bf69381e1a | ||
|
|
ced5a5b094 | ||
|
|
76110c04a0 | ||
|
|
03f94e3608 | ||
|
|
3d6f8631bc | ||
|
|
851b657f72 | ||
|
|
4c0bf7b8d4 | ||
|
|
83d4b2e84b | ||
|
|
edc1a853aa | ||
|
|
60819ce357 | ||
|
|
064f7a59f3 | ||
|
|
cbd1ebebd1 | ||
|
|
e4ddf0e5f3 | ||
|
|
8de94b4e12 | ||
|
|
ec0e59be2d | ||
|
|
acd9d598e2 | ||
|
|
9bc7a0257e | ||
|
|
7d7d29ab5f | ||
|
|
a853ac44df | ||
|
|
7eca5e3d4b | ||
|
|
14caaeb7be | ||
|
|
2e89e3ed67 | ||
|
|
1e798c3874 | ||
|
|
0e8c187d39 | ||
|
|
4dd897061a | ||
|
|
61948f40fe | ||
|
|
a712c97882 | ||
|
|
86e0657532 | ||
|
|
640785181c | ||
|
|
5c9253ebe3 | ||
|
|
8748c792db | ||
|
|
4d6b847ad7 | ||
|
|
f40ff47c81 | ||
|
|
f594a9e211 | ||
|
|
2ebce8aa31 | ||
|
|
230161c0fd | ||
|
|
7eb04a1370 | ||
|
|
7e7ec53385 | ||
|
|
1bbcbac0b0 | ||
|
|
366c83d056 | ||
|
|
2561801bad | ||
|
|
bb7d3e8218 | ||
|
|
370f050b36 | ||
|
|
a12a95eccc | ||
|
|
2290cff507 | ||
|
|
17c3e621b9 | ||
|
|
87b267998b | ||
|
|
9b57df60a4 | ||
|
|
c0d70186f6 | ||
|
|
ace50e1089 | ||
|
|
fb441564b8 | ||
|
|
8dd5ca4e2a | ||
|
|
b180886ce0 | ||
|
|
721e6a3a43 | ||
|
|
054633404c | ||
|
|
30dc40e58d | ||
|
|
697f1ac6c4 | ||
|
|
31999a2af6 | ||
|
|
5ad04adb10 | ||
|
|
20f93736f5 |
1
.github/FUNDING.yml
vendored
Normal file
1
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
github: riggraz
|
||||||
2
.github/workflows/run-tests.yml
vendored
2
.github/workflows/run-tests.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Build Docker production image
|
- name: Build Docker production image
|
||||||
run: docker compose -f docker-compose.yml -f docker-compose-prod.yml build --build-arg ENVIRONMENT=production
|
run: docker compose -f docker-compose.yml -f docker-compose-prod.yml build --build-arg ENVIRONMENT=production
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -41,3 +41,6 @@ yarn-debug.log*
|
|||||||
|
|
||||||
/app/assets/builds/*
|
/app/assets/builds/*
|
||||||
!/app/assets/builds/.keep
|
!/app/assets/builds/.keep
|
||||||
|
|
||||||
|
# Ignore Swagger spec file
|
||||||
|
/swagger/*
|
||||||
@@ -37,9 +37,12 @@ RUN yarn install --check-files
|
|||||||
# Copy all files
|
# Copy all files
|
||||||
COPY . ${APP_ROOT}/
|
COPY . ${APP_ROOT}/
|
||||||
|
|
||||||
|
# Build Swagger API documentation
|
||||||
|
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 RAILS_ENV=development ./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 ###
|
||||||
@@ -91,6 +94,9 @@ COPY --from=builder ${APP_ROOT}/Rakefile ${APP_ROOT}/
|
|||||||
COPY --from=builder ${APP_ROOT}/lib/tasks/ ${APP_ROOT}/lib/tasks/
|
COPY --from=builder ${APP_ROOT}/lib/tasks/ ${APP_ROOT}/lib/tasks/
|
||||||
COPY --from=builder /usr/local/bundle/config /usr/local/bundle/config
|
COPY --from=builder /usr/local/bundle/config /usr/local/bundle/config
|
||||||
|
|
||||||
|
# Copy Swagger API documentation
|
||||||
|
COPY --from=builder ${APP_ROOT}/swagger/ ${APP_ROOT}/swagger/
|
||||||
|
|
||||||
ENTRYPOINT ["./docker-entrypoint-prod.sh"]
|
ENTRYPOINT ["./docker-entrypoint-prod.sh"]
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
40
Gemfile
40
Gemfile
@@ -3,9 +3,9 @@ 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.8'
|
gem 'rails', '6.1.7.9'
|
||||||
|
|
||||||
gem 'pg', '1.3.5'
|
gem 'pg', '1.3.5'
|
||||||
|
|
||||||
@@ -50,10 +50,35 @@ gem 'friendly_id', '5.5.1'
|
|||||||
# Billing
|
# Billing
|
||||||
gem 'stripe', '11.2.0'
|
gem 'stripe', '11.2.0'
|
||||||
|
|
||||||
|
# Serve swagger docs
|
||||||
|
gem 'rswag-api', '2.15.0'
|
||||||
|
|
||||||
|
# We need those gems here, so we can Swaggerize in production
|
||||||
|
gem 'rswag-specs', '2.15.0'
|
||||||
|
gem 'rspec-rails', '4.0.2'
|
||||||
|
gem 'capybara', '3.40.0'
|
||||||
|
|
||||||
|
# CORS policy
|
||||||
|
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]
|
||||||
|
|
||||||
gem 'rspec-rails', '4.0.2'
|
|
||||||
gem 'factory_bot_rails', '5.0.2'
|
gem 'factory_bot_rails', '5.0.2'
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -64,11 +89,10 @@ group :development do
|
|||||||
end
|
end
|
||||||
|
|
||||||
group :test do
|
group :test do
|
||||||
# Adds support for Capybara system testing and selenium driver
|
gem 'selenium-webdriver', '4.17.0'
|
||||||
gem 'capybara', '3.36.0'
|
|
||||||
gem 'selenium-webdriver', '4.1.0'
|
# Retry flaky Capybara tests
|
||||||
# Easy installation and use of web drivers to run system tests with browsers
|
gem 'rspec-retry', '0.6.2'
|
||||||
gem 'webdrivers', '5.3.1'
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# If not bundled, webpack compilation in production fails
|
# If not bundled, webpack compilation in production fails
|
||||||
|
|||||||
289
Gemfile.lock
289
Gemfile.lock
@@ -1,124 +1,157 @@
|
|||||||
GEM
|
GEM
|
||||||
remote: https://rubygems.org/
|
remote: https://rubygems.org/
|
||||||
specs:
|
specs:
|
||||||
actioncable (6.1.7.8)
|
actioncable (6.1.7.9)
|
||||||
actionpack (= 6.1.7.8)
|
actionpack (= 6.1.7.9)
|
||||||
activesupport (= 6.1.7.8)
|
activesupport (= 6.1.7.9)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
websocket-driver (>= 0.6.1)
|
websocket-driver (>= 0.6.1)
|
||||||
actionmailbox (6.1.7.8)
|
actionmailbox (6.1.7.9)
|
||||||
actionpack (= 6.1.7.8)
|
actionpack (= 6.1.7.9)
|
||||||
activejob (= 6.1.7.8)
|
activejob (= 6.1.7.9)
|
||||||
activerecord (= 6.1.7.8)
|
activerecord (= 6.1.7.9)
|
||||||
activestorage (= 6.1.7.8)
|
activestorage (= 6.1.7.9)
|
||||||
activesupport (= 6.1.7.8)
|
activesupport (= 6.1.7.9)
|
||||||
mail (>= 2.7.1)
|
mail (>= 2.7.1)
|
||||||
actionmailer (6.1.7.8)
|
actionmailer (6.1.7.9)
|
||||||
actionpack (= 6.1.7.8)
|
actionpack (= 6.1.7.9)
|
||||||
actionview (= 6.1.7.8)
|
actionview (= 6.1.7.9)
|
||||||
activejob (= 6.1.7.8)
|
activejob (= 6.1.7.9)
|
||||||
activesupport (= 6.1.7.8)
|
activesupport (= 6.1.7.9)
|
||||||
mail (~> 2.5, >= 2.5.4)
|
mail (~> 2.5, >= 2.5.4)
|
||||||
rails-dom-testing (~> 2.0)
|
rails-dom-testing (~> 2.0)
|
||||||
actionpack (6.1.7.8)
|
actionpack (6.1.7.9)
|
||||||
actionview (= 6.1.7.8)
|
actionview (= 6.1.7.9)
|
||||||
activesupport (= 6.1.7.8)
|
activesupport (= 6.1.7.9)
|
||||||
rack (~> 2.0, >= 2.0.9)
|
rack (~> 2.0, >= 2.0.9)
|
||||||
rack-test (>= 0.6.3)
|
rack-test (>= 0.6.3)
|
||||||
rails-dom-testing (~> 2.0)
|
rails-dom-testing (~> 2.0)
|
||||||
rails-html-sanitizer (~> 1.0, >= 1.2.0)
|
rails-html-sanitizer (~> 1.0, >= 1.2.0)
|
||||||
actiontext (6.1.7.8)
|
actiontext (6.1.7.9)
|
||||||
actionpack (= 6.1.7.8)
|
actionpack (= 6.1.7.9)
|
||||||
activerecord (= 6.1.7.8)
|
activerecord (= 6.1.7.9)
|
||||||
activestorage (= 6.1.7.8)
|
activestorage (= 6.1.7.9)
|
||||||
activesupport (= 6.1.7.8)
|
activesupport (= 6.1.7.9)
|
||||||
nokogiri (>= 1.8.5)
|
nokogiri (>= 1.8.5)
|
||||||
actionview (6.1.7.8)
|
actionview (6.1.7.9)
|
||||||
activesupport (= 6.1.7.8)
|
activesupport (= 6.1.7.9)
|
||||||
builder (~> 3.1)
|
builder (~> 3.1)
|
||||||
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)
|
||||||
activejob (6.1.7.8)
|
active_storage_validations (1.4.0)
|
||||||
activesupport (= 6.1.7.8)
|
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)
|
||||||
|
activesupport (= 6.1.7.9)
|
||||||
globalid (>= 0.3.6)
|
globalid (>= 0.3.6)
|
||||||
activemodel (6.1.7.8)
|
activemodel (6.1.7.9)
|
||||||
activesupport (= 6.1.7.8)
|
activesupport (= 6.1.7.9)
|
||||||
activerecord (6.1.7.8)
|
activerecord (6.1.7.9)
|
||||||
activemodel (= 6.1.7.8)
|
activemodel (= 6.1.7.9)
|
||||||
activesupport (= 6.1.7.8)
|
activesupport (= 6.1.7.9)
|
||||||
activestorage (6.1.7.8)
|
activestorage (6.1.7.9)
|
||||||
actionpack (= 6.1.7.8)
|
actionpack (= 6.1.7.9)
|
||||||
activejob (= 6.1.7.8)
|
activejob (= 6.1.7.9)
|
||||||
activerecord (= 6.1.7.8)
|
activerecord (= 6.1.7.9)
|
||||||
activesupport (= 6.1.7.8)
|
activesupport (= 6.1.7.9)
|
||||||
marcel (~> 1.0)
|
marcel (~> 1.0)
|
||||||
mini_mime (>= 1.1.0)
|
mini_mime (>= 1.1.0)
|
||||||
activesupport (6.1.7.8)
|
activesupport (6.1.7.9)
|
||||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||||
i18n (>= 1.6, < 2)
|
i18n (>= 1.6, < 2)
|
||||||
minitest (>= 5.1)
|
minitest (>= 5.1)
|
||||||
tzinfo (~> 2.0)
|
tzinfo (~> 2.0)
|
||||||
zeitwerk (~> 2.3)
|
zeitwerk (~> 2.3)
|
||||||
addressable (2.8.0)
|
addressable (2.8.7)
|
||||||
public_suffix (>= 2.0.2, < 5.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)
|
||||||
bcrypt (3.1.18)
|
base64 (0.2.0)
|
||||||
|
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)
|
||||||
builder (3.3.0)
|
builder (3.3.0)
|
||||||
byebug (11.1.3)
|
byebug (11.1.3)
|
||||||
capybara (3.36.0)
|
capybara (3.40.0)
|
||||||
addressable
|
addressable
|
||||||
matrix
|
matrix
|
||||||
mini_mime (>= 0.1.3)
|
mini_mime (>= 0.1.3)
|
||||||
nokogiri (~> 1.8)
|
nokogiri (~> 1.11)
|
||||||
rack (>= 1.6.0)
|
rack (>= 1.6.0)
|
||||||
rack-test (>= 0.6.3)
|
rack-test (>= 0.6.3)
|
||||||
regexp_parser (>= 1.5, < 3.0)
|
regexp_parser (>= 1.5, < 3.0)
|
||||||
xpath (~> 3.2)
|
xpath (~> 3.2)
|
||||||
childprocess (4.1.0)
|
concurrent-ruby (1.3.4)
|
||||||
concurrent-ruby (1.3.3)
|
connection_pool (2.4.1)
|
||||||
connection_pool (2.2.5)
|
|
||||||
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.3.4)
|
date (3.4.0)
|
||||||
devise (4.7.3)
|
devise (4.7.3)
|
||||||
bcrypt (~> 3.0)
|
bcrypt (~> 3.0)
|
||||||
orm_adapter (~> 0.1)
|
orm_adapter (~> 0.1)
|
||||||
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)
|
||||||
mini_mime (>= 1.0.0)
|
mini_mime (>= 1.0.0)
|
||||||
multi_xml (>= 0.5.2)
|
multi_xml (>= 0.5.2)
|
||||||
i18n (1.14.5)
|
i18n (1.14.6)
|
||||||
concurrent-ruby (~> 1.0)
|
concurrent-ruby (~> 1.0)
|
||||||
i18n-js (3.9.2)
|
i18n-js (3.9.2)
|
||||||
i18n (>= 0.6.6)
|
i18n (>= 0.6.6)
|
||||||
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)
|
||||||
|
addressable (~> 2.8)
|
||||||
kaminari (1.2.2)
|
kaminari (1.2.2)
|
||||||
activesupport (>= 4.1.0)
|
activesupport (>= 4.1.0)
|
||||||
kaminari-actionview (= 1.2.2)
|
kaminari-actionview (= 1.2.2)
|
||||||
@@ -131,10 +164,12 @@ 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)
|
||||||
loofah (2.22.0)
|
logger (1.6.1)
|
||||||
|
loofah (2.23.1)
|
||||||
crass (~> 1.0.2)
|
crass (~> 1.0.2)
|
||||||
nokogiri (>= 1.12.0)
|
nokogiri (>= 1.12.0)
|
||||||
mail (2.8.1)
|
mail (2.8.1)
|
||||||
@@ -146,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.24.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.14)
|
net-imap (0.4.18)
|
||||||
date
|
date
|
||||||
net-protocol
|
net-protocol
|
||||||
net-pop (0.1.2)
|
net-pop (0.1.2)
|
||||||
@@ -159,54 +194,57 @@ GEM
|
|||||||
timeout
|
timeout
|
||||||
net-smtp (0.5.0)
|
net-smtp (0.5.0)
|
||||||
net-protocol
|
net-protocol
|
||||||
nio4r (2.7.3)
|
nio4r (2.7.4)
|
||||||
nokogiri (1.16.6)
|
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)
|
||||||
pg (1.3.5)
|
pg (1.3.5)
|
||||||
public_suffix (4.0.7)
|
public_suffix (6.0.1)
|
||||||
puma (5.6.9)
|
puma (5.6.9)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
pundit (2.2.0)
|
pundit (2.2.0)
|
||||||
activesupport (>= 3.0.0)
|
activesupport (>= 3.0.0)
|
||||||
racc (1.8.0)
|
raabro (1.4.0)
|
||||||
rack (2.2.9)
|
racc (1.8.1)
|
||||||
|
rack (2.2.10)
|
||||||
rack-attack (6.7.0)
|
rack-attack (6.7.0)
|
||||||
rack (>= 1.0, < 4)
|
rack (>= 1.0, < 4)
|
||||||
|
rack-cors (2.0.2)
|
||||||
|
rack (>= 2.0.0)
|
||||||
rack-test (2.1.0)
|
rack-test (2.1.0)
|
||||||
rack (>= 1.3)
|
rack (>= 1.3)
|
||||||
rails (6.1.7.8)
|
rails (6.1.7.9)
|
||||||
actioncable (= 6.1.7.8)
|
actioncable (= 6.1.7.9)
|
||||||
actionmailbox (= 6.1.7.8)
|
actionmailbox (= 6.1.7.9)
|
||||||
actionmailer (= 6.1.7.8)
|
actionmailer (= 6.1.7.9)
|
||||||
actionpack (= 6.1.7.8)
|
actionpack (= 6.1.7.9)
|
||||||
actiontext (= 6.1.7.8)
|
actiontext (= 6.1.7.9)
|
||||||
actionview (= 6.1.7.8)
|
actionview (= 6.1.7.9)
|
||||||
activejob (= 6.1.7.8)
|
activejob (= 6.1.7.9)
|
||||||
activemodel (= 6.1.7.8)
|
activemodel (= 6.1.7.9)
|
||||||
activerecord (= 6.1.7.8)
|
activerecord (= 6.1.7.9)
|
||||||
activestorage (= 6.1.7.8)
|
activestorage (= 6.1.7.9)
|
||||||
activesupport (= 6.1.7.8)
|
activesupport (= 6.1.7.9)
|
||||||
bundler (>= 1.15.0)
|
bundler (>= 1.15.0)
|
||||||
railties (= 6.1.7.8)
|
railties (= 6.1.7.9)
|
||||||
sprockets-rails (>= 2.0.0)
|
sprockets-rails (>= 2.0.0)
|
||||||
rails-dom-testing (2.2.0)
|
rails-dom-testing (2.2.0)
|
||||||
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.8)
|
railties (6.1.7.9)
|
||||||
actionpack (= 6.1.7.8)
|
actionpack (= 6.1.7.9)
|
||||||
activesupport (= 6.1.7.8)
|
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)
|
||||||
@@ -214,20 +252,21 @@ GEM
|
|||||||
execjs
|
execjs
|
||||||
railties (>= 3.2)
|
railties (>= 3.2)
|
||||||
tilt
|
tilt
|
||||||
regexp_parser (2.5.0)
|
redis-client (0.22.2)
|
||||||
responders (3.0.1)
|
connection_pool
|
||||||
actionpack (>= 5.0)
|
regexp_parser (2.9.2)
|
||||||
railties (>= 5.0)
|
responders (3.1.1)
|
||||||
rexml (3.3.6)
|
actionpack (>= 5.2)
|
||||||
strscan
|
railties (>= 5.2)
|
||||||
rspec-core (3.12.2)
|
rexml (3.3.9)
|
||||||
rspec-support (~> 3.12.0)
|
rspec-core (3.13.2)
|
||||||
rspec-expectations (3.12.3)
|
rspec-support (~> 3.13.0)
|
||||||
|
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)
|
||||||
@@ -236,12 +275,33 @@ GEM
|
|||||||
rspec-expectations (~> 3.10)
|
rspec-expectations (~> 3.10)
|
||||||
rspec-mocks (~> 3.10)
|
rspec-mocks (~> 3.10)
|
||||||
rspec-support (~> 3.10)
|
rspec-support (~> 3.10)
|
||||||
rspec-support (3.12.0)
|
rspec-retry (0.6.2)
|
||||||
|
rspec-core (> 3.3)
|
||||||
|
rspec-support (3.13.1)
|
||||||
|
rswag-api (2.15.0)
|
||||||
|
activesupport (>= 5.2, < 8.0)
|
||||||
|
railties (>= 5.2, < 8.0)
|
||||||
|
rswag-specs (2.15.0)
|
||||||
|
activesupport (>= 5.2, < 8.0)
|
||||||
|
json-schema (>= 2.2, < 6.0)
|
||||||
|
railties (>= 5.2, < 8.0)
|
||||||
|
rspec-core (>= 2.14)
|
||||||
rubyzip (2.3.2)
|
rubyzip (2.3.2)
|
||||||
selenium-webdriver (4.1.0)
|
selenium-webdriver (4.17.0)
|
||||||
childprocess (>= 0.5, < 5.0)
|
base64 (~> 0.2)
|
||||||
rexml (~> 3.2, >= 3.2.5)
|
rexml (~> 3.2, >= 3.2.5)
|
||||||
rubyzip (>= 1.2.2)
|
rubyzip (>= 1.2.2, < 3.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)
|
||||||
@@ -249,45 +309,44 @@ GEM
|
|||||||
sprockets (4.2.1)
|
sprockets (4.2.1)
|
||||||
concurrent-ruby (~> 1.0)
|
concurrent-ruby (~> 1.0)
|
||||||
rack (>= 2.2.4, < 4)
|
rack (>= 2.2.4, < 4)
|
||||||
sprockets-rails (3.5.1)
|
sprockets-rails (3.5.2)
|
||||||
actionpack (>= 6.1)
|
actionpack (>= 6.1)
|
||||||
activesupport (>= 6.1)
|
activesupport (>= 6.1)
|
||||||
sprockets (>= 3.0.0)
|
sprockets (>= 3.0.0)
|
||||||
stripe (11.2.0)
|
stripe (11.2.0)
|
||||||
strscan (3.1.0)
|
thor (1.3.2)
|
||||||
thor (1.3.1)
|
tilt (2.4.0)
|
||||||
tilt (2.0.10)
|
timeout (0.4.2)
|
||||||
timeout (0.4.1)
|
|
||||||
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)
|
||||||
railties (>= 6.0.0)
|
railties (>= 6.0.0)
|
||||||
webdrivers (5.3.1)
|
websocket (1.2.11)
|
||||||
nokogiri (~> 1.6)
|
|
||||||
rubyzip (>= 1.3.0)
|
|
||||||
selenium-webdriver (~> 4.0, < 4.11)
|
|
||||||
websocket-driver (0.7.6)
|
websocket-driver (0.7.6)
|
||||||
websocket-extensions (>= 0.1.0)
|
websocket-extensions (>= 0.1.0)
|
||||||
websocket-extensions (0.1.5)
|
websocket-extensions (0.1.5)
|
||||||
xpath (3.2.0)
|
xpath (3.2.0)
|
||||||
nokogiri (~> 1.8)
|
nokogiri (~> 1.8)
|
||||||
zeitwerk (2.6.16)
|
zeitwerk (2.6.18)
|
||||||
|
|
||||||
PLATFORMS
|
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.36.0)
|
capybara (= 3.40.0)
|
||||||
cssbundling-rails (= 1.1.2)
|
cssbundling-rails (= 1.1.2)
|
||||||
devise (= 4.7.3)
|
devise (= 4.7.3)
|
||||||
factory_bot_rails (= 5.0.2)
|
factory_bot_rails (= 5.0.2)
|
||||||
@@ -297,23 +356,29 @@ 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)
|
||||||
pundit (= 2.2.0)
|
pundit (= 2.2.0)
|
||||||
rack-attack (= 6.7.0)
|
rack-attack (= 6.7.0)
|
||||||
rails (= 6.1.7.8)
|
rack-cors (= 2.0.2)
|
||||||
rake (= 12.3.3)
|
rails (= 6.1.7.9)
|
||||||
|
rake (= 13.2.1)
|
||||||
react-rails (= 2.6.2)
|
react-rails (= 2.6.2)
|
||||||
rspec-rails (= 4.0.2)
|
rspec-rails (= 4.0.2)
|
||||||
selenium-webdriver (= 4.1.0)
|
rspec-retry (= 0.6.2)
|
||||||
|
rswag-api (= 2.15.0)
|
||||||
|
rswag-specs (= 2.15.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)
|
||||||
turbolinks (= 5.2.1)
|
turbolinks (= 5.2.1)
|
||||||
tzinfo-data
|
tzinfo-data
|
||||||
web-console (>= 3.3.0)
|
web-console (>= 3.3.0)
|
||||||
webdrivers (= 5.3.1)
|
|
||||||
|
|
||||||
RUBY VERSION
|
RUBY VERSION
|
||||||
ruby 3.0.6p216
|
ruby 3.0.6p216
|
||||||
|
|||||||
12
README.md
12
README.md
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -23,7 +23,13 @@
|
|||||||
|
|
||||||
padding: 15px;
|
padding: 15px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
|
|
||||||
|
label[for=user_notifications_enabled] {
|
||||||
|
@extend .mb-0;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.apiKeyGenerateButton { width: 100%; }
|
||||||
|
|
||||||
.deviseLinks {
|
.deviseLinks {
|
||||||
@extend .new_user;
|
@extend .new_user;
|
||||||
|
|||||||
@@ -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%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,8 +300,8 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.staffIcon {
|
.staffIcon {
|
||||||
font-size: 24px;
|
font-size: 22px;
|
||||||
margin: 0 4px;
|
margin: 0 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.poweredBy {
|
.poweredBy {
|
||||||
@@ -350,3 +351,58 @@ body {
|
|||||||
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%); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
.commentsContainer {
|
.commentsContainer {
|
||||||
@extend .my-3;
|
@extend .mt-2;
|
||||||
|
|
||||||
|
.attachFilesSectionHidden { display: none; }
|
||||||
|
|
||||||
.commentForm {
|
.commentForm {
|
||||||
@extend
|
@extend
|
||||||
@@ -20,24 +22,44 @@
|
|||||||
@extend
|
@extend
|
||||||
.d-flex,
|
.d-flex,
|
||||||
.flex-column,
|
.flex-column,
|
||||||
.my-3;
|
.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;
|
||||||
|
|
||||||
|
&.commentIsUpdateFormWithAttachment {
|
||||||
|
margin-right: 108px;
|
||||||
|
.checkboxSwitch { align-self: flex-end; }
|
||||||
|
}
|
||||||
|
&.commentIsUpdateFormWithoutAttachment {
|
||||||
margin-left: 58px;
|
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; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,19 +107,27 @@
|
|||||||
.text-secondary,
|
.text-secondary,
|
||||||
.text-uppercase,
|
.text-uppercase,
|
||||||
.font-weight-lighter,
|
.font-weight-lighter,
|
||||||
.my-2;
|
.mt-5,
|
||||||
|
.mb-2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.commentList { @extend .mb-4; }
|
||||||
|
|
||||||
.commentList > .commentList {
|
.commentList > .commentList {
|
||||||
padding-left: 32px;
|
padding-left: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.comment {
|
.comment {
|
||||||
@extend
|
@extend
|
||||||
.my-4;
|
.mb-2;
|
||||||
|
|
||||||
.commentHeader {
|
.commentHeader {
|
||||||
@extend .titleText;
|
@extend
|
||||||
|
.d-flex,
|
||||||
|
.align-items-end,
|
||||||
|
.titleText;
|
||||||
|
|
||||||
|
height: 36px;
|
||||||
|
|
||||||
.commentAuthor {
|
.commentAuthor {
|
||||||
@extend .ml-2;
|
@extend .ml-2;
|
||||||
@@ -106,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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
@@ -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 {
|
||||||
@@ -84,9 +84,13 @@
|
|||||||
div.invitationInfo {
|
div.invitationInfo {
|
||||||
@extend .d-flex;
|
@extend .d-flex;
|
||||||
|
|
||||||
span.invitationAcceptedAt, span.invitationSentAt {
|
span.invitationAcceptedAt, span.invitationSentAt, span.invitationExpired {
|
||||||
@extend .align-self-center, .mutedText;
|
@extend .align-self-center, .mutedText;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
span.invitationExpired {
|
||||||
|
@extend .text-danger;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
95
app/controllers/api/base_controller.rb
Normal file
95
app/controllers/api/base_controller.rb
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
module Api
|
||||||
|
class BaseController < ApplicationController
|
||||||
|
include ApplicationHelper
|
||||||
|
include Pundit::Authorization
|
||||||
|
|
||||||
|
rescue_from StandardError, with: :unexpected_error # Must be at the top, catches exceptions not caught by other rescue_from
|
||||||
|
rescue_from ActiveRecord::InvalidForeignKey, with: :parameter_wrong
|
||||||
|
rescue_from ActiveRecord::RecordNotFound, with: :not_found
|
||||||
|
rescue_from ActionController::ParameterMissing, with: :parameter_missing
|
||||||
|
rescue_from Pundit::NotAuthorizedError, with: :not_authorized
|
||||||
|
rescue_from Api::V1::Helpers::ImpersonationError, with: :impersonation_error
|
||||||
|
|
||||||
|
skip_before_action :verify_authenticity_token
|
||||||
|
skip_before_action :check_tenant_is_private
|
||||||
|
skip_before_action :load_tenant_data
|
||||||
|
|
||||||
|
before_action :authenticate_with_api_key
|
||||||
|
prepend_before_action :set_current_tenant
|
||||||
|
|
||||||
|
attr_reader :current_user, :current_api_key
|
||||||
|
|
||||||
|
def pundit_user
|
||||||
|
current_api_key
|
||||||
|
end
|
||||||
|
|
||||||
|
protected
|
||||||
|
|
||||||
|
def set_current_tenant
|
||||||
|
Current.tenant = get_tenant_from_request(request)
|
||||||
|
|
||||||
|
# If current tenant is nil, return generic error message
|
||||||
|
request_http_token_authentication if Current.tenant.nil?
|
||||||
|
|
||||||
|
I18n.locale = I18n.default_locale
|
||||||
|
end
|
||||||
|
|
||||||
|
def not_authorized
|
||||||
|
render status: :unauthorized, json: {
|
||||||
|
errors: ['You are not authorized to perform this action.']
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def parameter_missing
|
||||||
|
render status: :bad_request, json: {
|
||||||
|
errors: ['Some parameters are missing from the request. Please check the documentation.']
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def parameter_wrong
|
||||||
|
render status: :bad_request, json: {
|
||||||
|
errors: ['Some parameters are wrong in the request. Please check the documentation.']
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def not_found(exception)
|
||||||
|
render status: :not_found, json: {
|
||||||
|
errors: [exception.message]
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def impersonation_error(exception)
|
||||||
|
render status: :unauthorized, json: {
|
||||||
|
errors: ["Impersonation error: #{exception.message}"]
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def unexpected_error(exception)
|
||||||
|
if Rails.env.development?
|
||||||
|
error = '[DEV-ONLY MESSAGE] ' + exception.message
|
||||||
|
else
|
||||||
|
error = 'An unexpected error occurred.'
|
||||||
|
end
|
||||||
|
|
||||||
|
render status: :internal_server_error, json: {
|
||||||
|
errors: [error]
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def authenticate_with_api_key
|
||||||
|
authenticate_or_request_with_http_token do |token, options|
|
||||||
|
@current_api_key = ApiKey.find_by_token(token)
|
||||||
|
@current_user = current_api_key&.user
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Override rails default 401 response to return JSON content-type
|
||||||
|
# with request for Bearer token
|
||||||
|
# https://api.rubyonrails.org/classes/ActionController/HttpAuthentication/Token/ControllerMethods.html
|
||||||
|
def request_http_token_authentication(realm = "Application", message = nil)
|
||||||
|
json_response = { errors: [message || "Access denied."] }
|
||||||
|
headers["WWW-Authenticate"] = %(Bearer realm="#{realm.tr('"', "")}")
|
||||||
|
render json: json_response, status: :unauthorized
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
49
app/controllers/api/v1/boards_controller.rb
Normal file
49
app/controllers/api/v1/boards_controller.rb
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
class BoardsController < BaseController
|
||||||
|
include Api::V1::Serializers
|
||||||
|
|
||||||
|
# List all boards
|
||||||
|
def index
|
||||||
|
boards = Board.all
|
||||||
|
|
||||||
|
authorize([:api, Board])
|
||||||
|
|
||||||
|
render json: boards.map { |board| board.slice(*BOARD_JSON_ATTRIBUTES) }
|
||||||
|
end
|
||||||
|
|
||||||
|
# Get the board by id or slug
|
||||||
|
def show
|
||||||
|
board = Board.find_by(id: params[:id]) || Board.find_by(slug: params[:id])
|
||||||
|
|
||||||
|
unless board
|
||||||
|
raise ActiveRecord::RecordNotFound, "Board with id #{params[:id]} not found"
|
||||||
|
end
|
||||||
|
|
||||||
|
authorize([:api, board])
|
||||||
|
|
||||||
|
render json: board.slice(*BOARD_JSON_ATTRIBUTES)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Create a new board
|
||||||
|
def create
|
||||||
|
board = Board.new(board_params)
|
||||||
|
|
||||||
|
authorize([:api, board])
|
||||||
|
|
||||||
|
if board.save
|
||||||
|
render json: { id: board.id }, status: :created
|
||||||
|
else
|
||||||
|
render json: { errors: board.errors.full_messages }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def board_params
|
||||||
|
params.require(:name)
|
||||||
|
params.permit(:name, :slug, :description)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
131
app/controllers/api/v1/comments_controller.rb
Normal file
131
app/controllers/api/v1/comments_controller.rb
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
class CommentsController < BaseController
|
||||||
|
include Api::V1::Serializers
|
||||||
|
include Api::V1::Helpers
|
||||||
|
|
||||||
|
# List comments
|
||||||
|
def index
|
||||||
|
comments = Comment
|
||||||
|
.includes(:user)
|
||||||
|
.order(created_at: :desc)
|
||||||
|
.limit(params[:limit] || 100)
|
||||||
|
.offset(params[:offset] || 0)
|
||||||
|
|
||||||
|
comments = comments.where(post_id: params[:post_id]) if params[:post_id].present?
|
||||||
|
|
||||||
|
authorize([:api, Comment])
|
||||||
|
|
||||||
|
render json: comments.as_json(only: COMMENT_JSON_ATTRIBUTES, include: {
|
||||||
|
user: { only: USER_JSON_ATTRIBUTES }
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
# Show a comment
|
||||||
|
def show
|
||||||
|
comment = Comment
|
||||||
|
.includes(:user)
|
||||||
|
.find_by(id: params[:id])
|
||||||
|
|
||||||
|
unless comment
|
||||||
|
raise ActiveRecord::RecordNotFound, "Comment with id #{params[:id]} not found"
|
||||||
|
end
|
||||||
|
|
||||||
|
authorize([:api, comment])
|
||||||
|
|
||||||
|
render json: comment.as_json(only: COMMENT_JSON_ATTRIBUTES, include: {
|
||||||
|
user: { only: USER_JSON_ATTRIBUTES }
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
# Create a new comment
|
||||||
|
def create
|
||||||
|
comment = Comment.new(comment_params)
|
||||||
|
|
||||||
|
authorize([:api, comment])
|
||||||
|
|
||||||
|
comment.user_id = impersonate_user_if_requested(params[:impersonated_user_id], current_api_key.user_id)
|
||||||
|
|
||||||
|
if comment.save
|
||||||
|
SendNotificationForCommentWorkflow.new(comment: comment).run
|
||||||
|
|
||||||
|
render json: { id: comment.id }, status: :created
|
||||||
|
else
|
||||||
|
render json: { errors: comment.errors.full_messages }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Update a comment
|
||||||
|
def update
|
||||||
|
comment = Comment.find_by(id: params[:id])
|
||||||
|
|
||||||
|
unless comment
|
||||||
|
raise ActiveRecord::RecordNotFound, "Comment with id #{params[:id]} not found"
|
||||||
|
end
|
||||||
|
|
||||||
|
authorize([:api, comment])
|
||||||
|
|
||||||
|
if comment.update(comment_update_params)
|
||||||
|
render json: { id: comment.id }, status: :ok
|
||||||
|
else
|
||||||
|
render json: { errors: comment.errors.full_messages }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Delete a comment
|
||||||
|
def destroy
|
||||||
|
comment = Comment.find_by(id: params[:id])
|
||||||
|
|
||||||
|
unless comment
|
||||||
|
raise ActiveRecord::RecordNotFound, "Comment with id #{params[:id]} not found"
|
||||||
|
end
|
||||||
|
|
||||||
|
authorize([:api, comment])
|
||||||
|
|
||||||
|
comment.destroy!
|
||||||
|
|
||||||
|
render json: { id: comment.id }, status: :ok
|
||||||
|
end
|
||||||
|
|
||||||
|
# Mark comment as post update
|
||||||
|
def mark_as_post_update
|
||||||
|
comment = Comment.find_by(id: params[:id])
|
||||||
|
|
||||||
|
unless comment
|
||||||
|
raise ActiveRecord::RecordNotFound, "Comment with id #{params[:id]} not found"
|
||||||
|
end
|
||||||
|
|
||||||
|
authorize([:api, comment])
|
||||||
|
|
||||||
|
comment.update!(is_post_update: true)
|
||||||
|
|
||||||
|
render json: { id: comment.id }, status: :ok
|
||||||
|
end
|
||||||
|
|
||||||
|
# Unmark comment as post update
|
||||||
|
def unmark_as_post_update
|
||||||
|
comment = Comment.find_by(id: params[:id])
|
||||||
|
|
||||||
|
unless comment
|
||||||
|
raise ActiveRecord::RecordNotFound, "Comment with id #{params[:id]} not found"
|
||||||
|
end
|
||||||
|
|
||||||
|
authorize([:api, comment])
|
||||||
|
|
||||||
|
comment.update!(is_post_update: false)
|
||||||
|
|
||||||
|
render json: { id: comment.id }, status: :ok
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def comment_params
|
||||||
|
params.permit(:body, :is_post_update, :post_id, :parent_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def comment_update_params
|
||||||
|
params.permit(:body)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
19
app/controllers/api/v1/helpers.rb
Normal file
19
app/controllers/api/v1/helpers.rb
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
module Helpers
|
||||||
|
class ImpersonationError < StandardError; end
|
||||||
|
|
||||||
|
# Impersonate a user if requested
|
||||||
|
# Note: only administrators can impersonate other users
|
||||||
|
# @param impersonated_user_id [Integer] the user id to impersonate
|
||||||
|
# @param current_user_id [Integer] the current user id (the one making the request with the API key)
|
||||||
|
def impersonate_user_if_requested(impersonated_user_id, current_user_id)
|
||||||
|
return current_user_id unless impersonated_user_id.present?
|
||||||
|
raise ImpersonationError, "You are not allowed to impersonate other users." unless User.find_by(id: current_user_id).admin?
|
||||||
|
raise ImpersonationError, "Could not find the user to impersonate." unless User.find_by(id: impersonated_user_id).present?
|
||||||
|
|
||||||
|
impersonated_user_id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
79
app/controllers/api/v1/likes_controller.rb
Normal file
79
app/controllers/api/v1/likes_controller.rb
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
class LikesController < BaseController
|
||||||
|
include Api::V1::Serializers
|
||||||
|
include Api::V1::Helpers
|
||||||
|
|
||||||
|
# List likes
|
||||||
|
def index
|
||||||
|
likes = Like
|
||||||
|
.includes(:user)
|
||||||
|
.order(created_at: :desc)
|
||||||
|
.limit(params[:limit] || 100)
|
||||||
|
.offset(params[:offset] || 0)
|
||||||
|
|
||||||
|
likes = likes.where(post_id: params[:post_id]) if params[:post_id].present?
|
||||||
|
|
||||||
|
authorize([:api, Like])
|
||||||
|
|
||||||
|
render json: likes.as_json(only: LIKE_JSON_ATTRIBUTES, include: {
|
||||||
|
user: { only: USER_JSON_ATTRIBUTES }
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
# Show a like
|
||||||
|
def show
|
||||||
|
like = Like
|
||||||
|
.includes(:user)
|
||||||
|
.find_by(id: params[:id])
|
||||||
|
|
||||||
|
unless like
|
||||||
|
raise ActiveRecord::RecordNotFound, "Like with id #{params[:id]} not found"
|
||||||
|
end
|
||||||
|
|
||||||
|
authorize([:api, like])
|
||||||
|
|
||||||
|
render json: like.as_json(only: LIKE_JSON_ATTRIBUTES, include: {
|
||||||
|
user: { only: USER_JSON_ATTRIBUTES }
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
# Create like
|
||||||
|
def create
|
||||||
|
like = Like.new(like_params)
|
||||||
|
|
||||||
|
authorize([:api, like])
|
||||||
|
|
||||||
|
like.user_id = impersonate_user_if_requested(params[:impersonated_user_id], current_api_key.user_id)
|
||||||
|
|
||||||
|
if like.save
|
||||||
|
render json: { id: like.id }, status: :created
|
||||||
|
else
|
||||||
|
render json: { errors: like.errors.full_messages }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Delete like
|
||||||
|
def destroy
|
||||||
|
like = Like.find_by(id: params[:id])
|
||||||
|
|
||||||
|
unless like
|
||||||
|
raise ActiveRecord::RecordNotFound, "Like with id #{params[:id]} not found"
|
||||||
|
end
|
||||||
|
|
||||||
|
authorize([:api, like])
|
||||||
|
|
||||||
|
like.destroy!
|
||||||
|
|
||||||
|
render json: { id: like.id }, status: :ok
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def like_params
|
||||||
|
params.permit(:post_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
16
app/controllers/api/v1/post_statuses_controller.rb
Normal file
16
app/controllers/api/v1/post_statuses_controller.rb
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
class PostStatusesController < BaseController
|
||||||
|
include Api::V1::Serializers
|
||||||
|
|
||||||
|
# List all post statuses
|
||||||
|
def index
|
||||||
|
post_statuses = PostStatus.all
|
||||||
|
|
||||||
|
authorize([:api, PostStatus])
|
||||||
|
|
||||||
|
render json: post_statuses.map { |post_status| post_status.slice(*POST_STATUS_JSON_ATTRIBUTES) }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
192
app/controllers/api/v1/posts_controller.rb
Normal file
192
app/controllers/api/v1/posts_controller.rb
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
class PostsController < BaseController
|
||||||
|
include Api::V1::Serializers
|
||||||
|
include Api::V1::Helpers
|
||||||
|
|
||||||
|
# List posts
|
||||||
|
def index
|
||||||
|
posts = Post
|
||||||
|
.includes(:board, :post_status, :user)
|
||||||
|
.order(created_at: :desc)
|
||||||
|
.limit(params[:limit] || 20)
|
||||||
|
.offset(params[:offset] || 0)
|
||||||
|
|
||||||
|
posts = posts.where(board_id: params[:board_id]) if params[:board_id].present?
|
||||||
|
posts = posts.where(user_id: params[:user_id]) if params[:user_id].present?
|
||||||
|
|
||||||
|
authorize([:api, Post])
|
||||||
|
|
||||||
|
render json: posts.as_json(only: POST_JSON_ATTRIBUTES, include: {
|
||||||
|
board: { only: BOARD_JSON_ATTRIBUTES },
|
||||||
|
post_status: { only: POST_STATUS_JSON_ATTRIBUTES },
|
||||||
|
user: { only: USER_JSON_ATTRIBUTES }
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
# Get a post by id
|
||||||
|
def show
|
||||||
|
post = Post
|
||||||
|
.includes(:board, :post_status, :user)
|
||||||
|
.find_by(id: params[:id])
|
||||||
|
|
||||||
|
unless post
|
||||||
|
raise ActiveRecord::RecordNotFound, "Post with id #{params[:id]} not found"
|
||||||
|
end
|
||||||
|
|
||||||
|
authorize([:api, post])
|
||||||
|
|
||||||
|
render json: post.as_json(only: POST_JSON_ATTRIBUTES, include: {
|
||||||
|
board: { only: BOARD_JSON_ATTRIBUTES },
|
||||||
|
post_status: { only: POST_STATUS_JSON_ATTRIBUTES },
|
||||||
|
user: { only: USER_JSON_ATTRIBUTES }
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
# Create a new post
|
||||||
|
def create
|
||||||
|
post = Post.new(post_params)
|
||||||
|
|
||||||
|
authorize([:api, post])
|
||||||
|
|
||||||
|
post.user_id = impersonate_user_if_requested(params[:impersonated_user_id], current_api_key.user_id)
|
||||||
|
|
||||||
|
if post.save
|
||||||
|
render json: { id: post.id }, status: :created
|
||||||
|
else
|
||||||
|
render json: { errors: post.errors.full_messages }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Update a post
|
||||||
|
def update
|
||||||
|
post = Post.find_by(id: params[:id])
|
||||||
|
|
||||||
|
unless post
|
||||||
|
raise ActiveRecord::RecordNotFound, "Post with id #{params[:id]} not found"
|
||||||
|
end
|
||||||
|
|
||||||
|
authorize([:api, post])
|
||||||
|
|
||||||
|
if post.update(post_update_params)
|
||||||
|
render json: { id: post.id }, status: :ok
|
||||||
|
else
|
||||||
|
render json: { errors: post.errors.full_messages }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Delete a post
|
||||||
|
def destroy
|
||||||
|
post = Post.find_by(id: params[:id])
|
||||||
|
|
||||||
|
unless post
|
||||||
|
raise ActiveRecord::RecordNotFound, "Post with id #{params[:id]} not found"
|
||||||
|
end
|
||||||
|
|
||||||
|
authorize([:api, post])
|
||||||
|
|
||||||
|
post.destroy!
|
||||||
|
|
||||||
|
render json: { id: post.id }, status: :ok
|
||||||
|
end
|
||||||
|
|
||||||
|
# Update post board
|
||||||
|
def update_board
|
||||||
|
post = Post.find_by(id: params[:id])
|
||||||
|
|
||||||
|
unless post
|
||||||
|
raise ActiveRecord::RecordNotFound, "Post with id #{params[:id]} not found"
|
||||||
|
end
|
||||||
|
|
||||||
|
authorize([:api, post])
|
||||||
|
|
||||||
|
post.update!(post_update_board_params)
|
||||||
|
|
||||||
|
render json: { id: post.id }, status: :ok
|
||||||
|
end
|
||||||
|
|
||||||
|
# Update post status
|
||||||
|
def update_status
|
||||||
|
post = Post.find_by(id: params[:id])
|
||||||
|
|
||||||
|
unless post
|
||||||
|
raise ActiveRecord::RecordNotFound, "Post with id #{params[:id]} not found"
|
||||||
|
end
|
||||||
|
|
||||||
|
authorize([:api, post])
|
||||||
|
|
||||||
|
user_id = impersonate_user_if_requested(params[:impersonated_user_id], current_api_key.user_id)
|
||||||
|
|
||||||
|
post.update!(post_update_status_params)
|
||||||
|
|
||||||
|
if post.post_status_id_previously_changed?
|
||||||
|
ExecutePostStatusChangeLogicWorkflow.new(
|
||||||
|
user_id: user_id,
|
||||||
|
post: post,
|
||||||
|
post_status_id: post.post_status_id
|
||||||
|
).run
|
||||||
|
end
|
||||||
|
|
||||||
|
render json: { id: post.id }, status: :ok
|
||||||
|
end
|
||||||
|
|
||||||
|
# Approve post
|
||||||
|
def approve
|
||||||
|
post = Post.find_by(id: params[:id])
|
||||||
|
|
||||||
|
unless post
|
||||||
|
raise ActiveRecord::RecordNotFound, "Post with id #{params[:id]} not found"
|
||||||
|
end
|
||||||
|
|
||||||
|
unless post.approval_status == 'pending'
|
||||||
|
raise StandardError, "Post with id #{params[:id]} is not pending approval"
|
||||||
|
end
|
||||||
|
|
||||||
|
authorize([:api, post])
|
||||||
|
|
||||||
|
post.update!(approval_status: 'approved')
|
||||||
|
|
||||||
|
render json: { id: post.id }, status: :ok
|
||||||
|
end
|
||||||
|
|
||||||
|
# Reject post
|
||||||
|
def reject
|
||||||
|
post = Post.find_by(id: params[:id])
|
||||||
|
|
||||||
|
unless post
|
||||||
|
raise ActiveRecord::RecordNotFound, "Post with id #{params[:id]} not found"
|
||||||
|
end
|
||||||
|
|
||||||
|
unless post.approval_status == 'pending'
|
||||||
|
raise StandardError, "Post with id #{params[:id]} is not pending approval"
|
||||||
|
end
|
||||||
|
|
||||||
|
authorize([:api, post])
|
||||||
|
|
||||||
|
post.update!(approval_status: 'rejected')
|
||||||
|
|
||||||
|
render json: { id: post.id }, status: :ok
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def post_params
|
||||||
|
params.require(:title)
|
||||||
|
params.permit(:title, :description, :board_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def post_update_params
|
||||||
|
params.permit(:title, :description)
|
||||||
|
end
|
||||||
|
|
||||||
|
def post_update_board_params
|
||||||
|
params.require(:board_id)
|
||||||
|
params.permit(:board_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def post_update_status_params
|
||||||
|
params.permit(:post_status_id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
62
app/controllers/api/v1/serializers.rb
Normal file
62
app/controllers/api/v1/serializers.rb
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
module Serializers
|
||||||
|
BOARD_JSON_ATTRIBUTES = [
|
||||||
|
:id,
|
||||||
|
:name,
|
||||||
|
:slug,
|
||||||
|
:description,
|
||||||
|
:created_at,
|
||||||
|
:updated_at
|
||||||
|
].freeze
|
||||||
|
|
||||||
|
COMMENT_JSON_ATTRIBUTES = [
|
||||||
|
:id,
|
||||||
|
:body,
|
||||||
|
:is_post_update,
|
||||||
|
:post_id,
|
||||||
|
:user,
|
||||||
|
:created_at,
|
||||||
|
:updated_at
|
||||||
|
].freeze
|
||||||
|
|
||||||
|
POST_STATUS_JSON_ATTRIBUTES = [
|
||||||
|
:id,
|
||||||
|
:name,
|
||||||
|
:color,
|
||||||
|
:show_in_roadmap,
|
||||||
|
:created_at,
|
||||||
|
:updated_at
|
||||||
|
].freeze
|
||||||
|
|
||||||
|
POST_JSON_ATTRIBUTES = [
|
||||||
|
:id,
|
||||||
|
:title,
|
||||||
|
:description,
|
||||||
|
:board,
|
||||||
|
:post_status,
|
||||||
|
:user,
|
||||||
|
:approval_status,
|
||||||
|
:slug,
|
||||||
|
:created_at,
|
||||||
|
:updated_at
|
||||||
|
].freeze
|
||||||
|
|
||||||
|
USER_JSON_ATTRIBUTES = [
|
||||||
|
:id,
|
||||||
|
:email,
|
||||||
|
:full_name,
|
||||||
|
:created_at,
|
||||||
|
:updated_at
|
||||||
|
].freeze
|
||||||
|
|
||||||
|
LIKE_JSON_ATTRIBUTES = [
|
||||||
|
:id,
|
||||||
|
:user,
|
||||||
|
:post_id,
|
||||||
|
:created_at,
|
||||||
|
:updated_at
|
||||||
|
].freeze
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
91
app/controllers/api/v1/users_controller.rb
Normal file
91
app/controllers/api/v1/users_controller.rb
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
class UsersController < BaseController
|
||||||
|
include Api::V1::Serializers
|
||||||
|
include Api::V1::Helpers
|
||||||
|
|
||||||
|
# List users
|
||||||
|
def index
|
||||||
|
users = User
|
||||||
|
.order(created_at: :desc)
|
||||||
|
.limit(params[:limit] || 100)
|
||||||
|
.offset(params[:offset] || 0)
|
||||||
|
|
||||||
|
authorize([:api, User])
|
||||||
|
|
||||||
|
render json: users.as_json(only: USER_JSON_ATTRIBUTES)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Get user by id
|
||||||
|
def show
|
||||||
|
user = User.find_by(id: params[:id])
|
||||||
|
|
||||||
|
unless user
|
||||||
|
raise ActiveRecord::RecordNotFound, "User with id #{params[:id]} not found"
|
||||||
|
end
|
||||||
|
|
||||||
|
authorize([:api, user])
|
||||||
|
|
||||||
|
render json: user.slice(*USER_JSON_ATTRIBUTES)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Get user by email
|
||||||
|
def show_by_email
|
||||||
|
user = User.find_by(email: params[:email])
|
||||||
|
|
||||||
|
unless user
|
||||||
|
raise ActiveRecord::RecordNotFound, "User with email #{params[:email]} not found"
|
||||||
|
end
|
||||||
|
|
||||||
|
authorize([:api, user])
|
||||||
|
|
||||||
|
render json: user.slice(*USER_JSON_ATTRIBUTES)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Create user
|
||||||
|
def create
|
||||||
|
# Check whether user already exists and return its id
|
||||||
|
user = User.find_by(email: params[:email])
|
||||||
|
|
||||||
|
if user
|
||||||
|
render json: { id: user.id }, status: :ok
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# ... otherwise, create a new user
|
||||||
|
user = User.new(
|
||||||
|
email: params[:email],
|
||||||
|
full_name: params[:full_name] || params[:email],
|
||||||
|
password: Devise.friendly_token,
|
||||||
|
has_set_password: false,
|
||||||
|
status: 'active'
|
||||||
|
)
|
||||||
|
user.skip_confirmation
|
||||||
|
|
||||||
|
authorize([:api, user])
|
||||||
|
|
||||||
|
if user.save
|
||||||
|
render json: { id: user.id }, status: :created
|
||||||
|
else
|
||||||
|
render json: { errors: user.errors.full_messages }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Block user
|
||||||
|
def block
|
||||||
|
user = User.find_by(id: params[:id])
|
||||||
|
|
||||||
|
unless user
|
||||||
|
raise ActiveRecord::RecordNotFound, "User with id #{params[:id]} not found"
|
||||||
|
end
|
||||||
|
|
||||||
|
authorize([:api, user])
|
||||||
|
|
||||||
|
user.update!(status: 'blocked')
|
||||||
|
|
||||||
|
render json: { id: user.id }, status: :ok
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
19
app/controllers/api_keys_controller.rb
Normal file
19
app/controllers/api_keys_controller.rb
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
class ApiKeysController < ApplicationController
|
||||||
|
|
||||||
|
before_action :authenticate_user!
|
||||||
|
|
||||||
|
def create
|
||||||
|
current_user.api_key&.destroy # Destroy existing API key
|
||||||
|
|
||||||
|
@api_key = ApiKey.new(user: current_user)
|
||||||
|
authorize @api_key
|
||||||
|
|
||||||
|
if @api_key.save
|
||||||
|
render json: { api_key: @api_key.token }, status: :created
|
||||||
|
else
|
||||||
|
render json: {
|
||||||
|
errors: @api_key.errors.full_messages
|
||||||
|
}, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -17,9 +17,11 @@ class ApplicationController < ActionController::Base
|
|||||||
if resource.admin? && resource.sign_in_count == 1
|
if resource.admin? && resource.sign_in_count == 1
|
||||||
root_path(tour: true)
|
root_path(tour: true)
|
||||||
else
|
else
|
||||||
|
safe_return_to_redirect(session[:return_to]) do
|
||||||
super
|
super
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# Override Devise after sign out path
|
# Override Devise after sign out path
|
||||||
def after_sign_out_path_for(resource_or_scope)
|
def after_sign_out_path_for(resource_or_scope)
|
||||||
@@ -33,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)
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ class BillingController < ApplicationController
|
|||||||
mode: 'subscription',
|
mode: 'subscription',
|
||||||
return_url: "#{return_url}?session_id={CHECKOUT_SESSION_ID}&tenant_id=#{params[:tenant_id]}",
|
return_url: "#{return_url}?session_id={CHECKOUT_SESSION_ID}&tenant_id=#{params[:tenant_id]}",
|
||||||
customer: Current.tenant.tenant_billing.customer_id,
|
customer: Current.tenant.tenant_billing.customer_id,
|
||||||
|
allow_promotion_codes: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
render json: { clientSecret: session.client_secret }
|
render json: { clientSecret: session.client_secret }
|
||||||
@@ -123,17 +124,18 @@ class BillingController < ApplicationController
|
|||||||
TenantMailer.subscription_confirmation(tenant: Current.tenant).deliver_later
|
TenantMailer.subscription_confirmation(tenant: Current.tenant).deliver_later
|
||||||
end
|
end
|
||||||
elsif event['type'] == 'customer.subscription.updated'
|
elsif event['type'] == 'customer.subscription.updated'
|
||||||
|
# This event is triggered when:
|
||||||
|
# (1) A subscription is canceled OR a subscription is reactivated after being canceled
|
||||||
|
# (2) A subscription is updated (e.g. switching from monthly to yearly plan or vice versa)
|
||||||
|
# (3) A subscription is automatically renewed at the end of the billing period (e.g. every month for a monthly subscription)
|
||||||
|
# Since it is difficult to distinguish between these cases, we only update the status if the subscription is active or canceled
|
||||||
|
# and we do not send any emails notifications.
|
||||||
|
|
||||||
Current.tenant = get_tenant_from_customer_id(event.data.object.customer)
|
Current.tenant = get_tenant_from_customer_id(event.data.object.customer)
|
||||||
|
|
||||||
if Current.tenant.tenant_billing.status == 'active' || Current.tenant.tenant_billing.status == 'canceled'
|
if Current.tenant.tenant_billing.status == 'active' || Current.tenant.tenant_billing.status == 'canceled'
|
||||||
has_canceled = event.data.object.cancel_at_period_end
|
has_canceled = event.data.object.cancel_at_period_end
|
||||||
Current.tenant.tenant_billing.update!(status: has_canceled ? 'canceled' : 'active')
|
Current.tenant.tenant_billing.update!(status: has_canceled ? 'canceled' : 'active')
|
||||||
|
|
||||||
if has_canceled
|
|
||||||
TenantMailer.cancellation_confirmation(tenant: Current.tenant).deliver_later
|
|
||||||
else
|
|
||||||
TenantMailer.renewal_confirmation(tenant: Current.tenant).deliver_later
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -56,7 +56,6 @@ class InvitationsController < ApplicationController
|
|||||||
render json: {}, status: :ok
|
render json: {}, status: :ok
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def invitation_params
|
def invitation_params
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
14
app/controllers/local_files_controller.rb
Normal file
14
app/controllers/local_files_controller.rb
Normal 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
|
||||||
@@ -132,6 +132,7 @@ class OAuthsController < ApplicationController
|
|||||||
remember_me user
|
remember_me user
|
||||||
user.invalidate_oauth_token
|
user.invalidate_oauth_token
|
||||||
flash[:notice] = I18n.t('devise.sessions.signed_in')
|
flash[:notice] = I18n.t('devise.sessions.signed_in')
|
||||||
|
|
||||||
redirect_to after_sign_in_path_for(user)
|
redirect_to after_sign_in_path_for(user)
|
||||||
else
|
else
|
||||||
flash[:alert] = I18n.t('errors.o_auth_login_error', name: @o_auth.name)
|
flash[:alert] = I18n.t('errors.o_auth_login_error', name: @o_auth.name)
|
||||||
@@ -169,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
|
||||||
@@ -197,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
|
||||||
@@ -205,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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -33,6 +33,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
|
||||||
|
|
||||||
@@ -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,20 +116,28 @@ 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?
|
||||||
PostStatusChange.create(
|
ExecutePostStatusChangeLogicWorkflow.new(
|
||||||
user_id: current_user.id,
|
user_id: current_user.id,
|
||||||
post_id: @post.id,
|
post: @post,
|
||||||
post_status_id: @post.post_status_id
|
post_status_id: @post.post_status_id
|
||||||
)
|
).run
|
||||||
|
|
||||||
@post.followers.each do |follower|
|
|
||||||
UserMailer.notify_follower_of_post_status_change(post: @post, follower: follower).deliver_later
|
|
||||||
end
|
|
||||||
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
|
||||||
@@ -154,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'
|
||||||
)
|
)
|
||||||
@@ -161,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
|
||||||
@@ -190,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
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
class RegistrationsController < Devise::RegistrationsController
|
class RegistrationsController < Devise::RegistrationsController
|
||||||
|
include ApplicationHelper
|
||||||
|
|
||||||
# Needed to have Current.tenant available in Devise's controllers
|
# Needed to have Current.tenant available in Devise's controllers
|
||||||
prepend_before_action :load_tenant_data
|
prepend_before_action :load_tenant_data
|
||||||
before_action :load_oauths, only: [:new]
|
before_action :load_oauths, only: [:new]
|
||||||
@@ -12,12 +14,11 @@ class RegistrationsController < Devise::RegistrationsController
|
|||||||
|
|
||||||
# Handle invitations
|
# Handle invitations
|
||||||
is_invitation = sign_up_params[:invitation_token].present?
|
is_invitation = sign_up_params[:invitation_token].present?
|
||||||
is_invitation_valid = true
|
|
||||||
invitation = nil
|
invitation = nil
|
||||||
if is_invitation
|
if is_invitation
|
||||||
invitation = Invitation.find_by(email: email)
|
invitation = Invitation.find_by(email: email)
|
||||||
|
|
||||||
if invitation.nil? || invitation.token_digest != Digest::SHA256.hexdigest(sign_up_params[:invitation_token]) || invitation.accepted_at.present?
|
if invitation.nil? || invitation.expired? || invitation.token_digest != Digest::SHA256.hexdigest(sign_up_params[:invitation_token]) || invitation.accepted_at.present?
|
||||||
flash[:alert] = t('errors.unauthorized')
|
flash[:alert] = t('errors.unauthorized')
|
||||||
redirect_to new_user_registration_path and return
|
redirect_to new_user_registration_path and return
|
||||||
end
|
end
|
||||||
@@ -76,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])
|
||||||
|
|
||||||
@@ -88,14 +96,17 @@ class RegistrationsController < Devise::RegistrationsController
|
|||||||
|
|
||||||
protected
|
protected
|
||||||
|
|
||||||
|
# Override Devise after inactive sign up path
|
||||||
def after_inactive_sign_up_path_for(resource)
|
def after_inactive_sign_up_path_for(resource)
|
||||||
if Current.tenant.tenant_setting.is_private
|
if Current.tenant.tenant_setting.is_private
|
||||||
# Redirect to log in page, since root page only visible to logged in users
|
# Redirect to log in page, since root page only visible to logged in users
|
||||||
new_user_session_path
|
new_user_session_path
|
||||||
else
|
else
|
||||||
|
safe_return_to_redirect(session[:return_to]) do
|
||||||
super
|
super
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,15 @@ class SessionsController < Devise::SessionsController
|
|||||||
before_action :load_oauths, only: [:new]
|
before_action :load_oauths, only: [:new]
|
||||||
before_action :set_page_title, only: [:new]
|
before_action :set_page_title, only: [:new]
|
||||||
|
|
||||||
|
def new
|
||||||
|
# Update return_to path if not coming from Devise user pages
|
||||||
|
if request.referer.present? && !request.referer.include?('/users')
|
||||||
|
session[:return_to] = request.referer
|
||||||
|
end
|
||||||
|
|
||||||
|
super
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def set_page_title
|
def set_page_title
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -20,9 +24,15 @@ class UsersController < ApplicationController
|
|||||||
# Handle special case: trying to set user role to 'owner'
|
# Handle special case: trying to set user role to 'owner'
|
||||||
raise Pundit::NotAuthorizedError if @user.owner?
|
raise Pundit::NotAuthorizedError if @user.owner?
|
||||||
|
|
||||||
|
ActiveRecord::Base.transaction do
|
||||||
|
DestroyApiKeyIfNeededWorkflow.new(user: @user).run
|
||||||
|
|
||||||
if @user.save
|
if @user.save
|
||||||
render json: @user
|
render json: @user
|
||||||
else
|
else
|
||||||
|
raise ActiveRecord::Rollback
|
||||||
|
end
|
||||||
|
rescue ActiveRecord::Rollback
|
||||||
render json: {
|
render json: {
|
||||||
error: @user.errors.full_messages
|
error: @user.errors.full_messages
|
||||||
}, status: :unprocessable_entity
|
}, status: :unprocessable_entity
|
||||||
|
|||||||
97
app/controllers/webhooks_controller.rb
Normal file
97
app/controllers/webhooks_controller.rb
Normal 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
|
||||||
5
app/helpers/api_keys_helper.rb
Normal file
5
app/helpers/api_keys_helper.rb
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
module ApiKeysHelper
|
||||||
|
def token_mask(prefix, length = 30)
|
||||||
|
"#{prefix}#{"•"*length}"
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -79,4 +92,12 @@ module ApplicationHelper
|
|||||||
|
|
||||||
tenant
|
tenant
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Redirect to previous page if present; otherwise redirect to root
|
||||||
|
def safe_return_to_redirect(url)
|
||||||
|
uri = URI.parse(url)
|
||||||
|
uri.host.present? && uri.host != request.host ? yield : url
|
||||||
|
rescue URI::InvalidURIError
|
||||||
|
yield
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
69
app/javascript/actions/Webhook/deleteWebhook.ts
Normal file
69
app/javascript/actions/Webhook/deleteWebhook.ts
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
59
app/javascript/actions/Webhook/requestWebhooks.ts
Normal file
59
app/javascript/actions/Webhook/requestWebhooks.ts
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
78
app/javascript/actions/Webhook/submitWebhook.ts
Normal file
78
app/javascript/actions/Webhook/submitWebhook.ts
Normal 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
96
app/javascript/actions/Webhook/updateWebhook.ts
Normal file
96
app/javascript/actions/Webhook/updateWebhook.ts
Normal 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,14 +224,17 @@ class NewPost extends React.Component<Props, State> {
|
|||||||
{board.description}
|
{board.description}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
|
|
||||||
|
{
|
||||||
|
showForm ?
|
||||||
|
<ActionLink
|
||||||
|
onClick={this.toggleForm}
|
||||||
|
icon={<CancelIcon />}
|
||||||
|
>
|
||||||
|
{I18n.t('common.buttons.cancel')}
|
||||||
|
</ActionLink>
|
||||||
|
:
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|
||||||
if (showForm) {
|
|
||||||
this.toggleForm();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoggedIn) {
|
if (isLoggedIn) {
|
||||||
this.toggleForm();
|
this.toggleForm();
|
||||||
this.setState({ isSubmissionAnonymous: false });
|
this.setState({ isSubmissionAnonymous: false });
|
||||||
@@ -226,13 +245,9 @@ class NewPost extends React.Component<Props, State> {
|
|||||||
className="submitBtn"
|
className="submitBtn"
|
||||||
outline={showForm}
|
outline={showForm}
|
||||||
>
|
>
|
||||||
{
|
{I18n.t('board.new_post.submit_button')}
|
||||||
showForm ?
|
|
||||||
I18n.t('board.new_post.cancel_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 }
|
||||||
|
|||||||
@@ -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,7 +105,23 @@ 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')}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
20
app/javascript/components/Comments/CommentAttachments.tsx
Normal file
20
app/javascript/components/Comments/CommentAttachments.tsx
Normal 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;
|
||||||
@@ -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,21 +59,89 @@ 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">
|
||||||
|
|
||||||
|
<div className="commentFormContainer">
|
||||||
<textarea
|
<textarea
|
||||||
value={body}
|
value={body}
|
||||||
onChange={e => this.handleCommentBodyChange(e.target.value)}
|
onChange={e => this.handleCommentBodyChange(e.target.value)}
|
||||||
rows={3}
|
rows={3}
|
||||||
|
autoFocus
|
||||||
className="commentForm"
|
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' }}>
|
||||||
|
{
|
||||||
|
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={() => 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>
|
||||||
|
|
||||||
|
{ /* Attachments dropzone */ }
|
||||||
|
{
|
||||||
|
tenantSetting.allow_attachment_upload &&
|
||||||
|
<div className="form-group">
|
||||||
|
<Dropzone
|
||||||
|
files={attachments}
|
||||||
|
setFiles={this.handleAttachmentsChange}
|
||||||
|
maxSizeKB={2048}
|
||||||
|
maxFiles={5}
|
||||||
|
customStyle={{ minHeight: '60px', marginTop: '16px' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div className="editCommentFormFooter">
|
||||||
|
{ /* Is post update */ }
|
||||||
|
<div className="editCommentFormPostUpdate">
|
||||||
{
|
{
|
||||||
isPowerUser &&
|
isPowerUser &&
|
||||||
<Switch
|
<Switch
|
||||||
@@ -80,12 +158,13 @@ class CommentEditForm extends React.Component<Props, State> {
|
|||||||
{I18n.t('common.buttons.cancel')}
|
{I18n.t('common.buttons.cancel')}
|
||||||
</ActionLink>
|
</ActionLink>
|
||||||
|
|
||||||
<Button onClick={() => handleUpdateComment(body, isPostUpdate)}>
|
<Button onClick={() => handleUpdateComment(body, isPostUpdate, attachmentsToDelete, attachments)}>
|
||||||
{I18n.t('common.buttons.update')}
|
{I18n.t('common.buttons.update')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,17 +4,20 @@ import I18n from 'i18n-js';
|
|||||||
import NewComment from './NewComment';
|
import NewComment from './NewComment';
|
||||||
import CommentList from './CommentList';
|
import CommentList from './CommentList';
|
||||||
import Spinner from '../common/Spinner';
|
import Spinner from '../common/Spinner';
|
||||||
import { DangerText } from '../common/CustomTexts';
|
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,20 +130,23 @@ 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}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{ areLoading ? <Spinner /> : null }
|
|
||||||
{ error ? <DangerText>{error}</DangerText> : null }
|
|
||||||
|
|
||||||
<div className="commentsTitle">
|
<div className="commentsTitle">
|
||||||
{I18n.t('post.comments.title')}
|
{I18n.t('post.comments.title')}
|
||||||
<Separator />
|
<Separator />
|
||||||
{I18n.t('common.comments_number', { count: comments.length })}
|
{I18n.t('common.comments_number', { count: comments.length })}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{ areLoading ? <Spinner /> : null }
|
||||||
|
{ error ? <DangerText>{error}</DangerText> : null }
|
||||||
|
{ comments.length === 0 && !areLoading && !error && <MutedText>{I18n.t('post.comments.empty')}</MutedText> }
|
||||||
|
|
||||||
<CommentList
|
<CommentList
|
||||||
comments={comments}
|
comments={comments}
|
||||||
replyForms={replyForms}
|
replyForms={replyForms}
|
||||||
@@ -141,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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,38 +44,95 @@ const NewComment = ({
|
|||||||
handlePostUpdateFlag,
|
handlePostUpdateFlag,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
|
|
||||||
|
allowAttachmentUpload,
|
||||||
isLoggedIn,
|
isLoggedIn,
|
||||||
isPowerUser,
|
isPowerUser,
|
||||||
userEmail,
|
userEmail,
|
||||||
}: Props) => (
|
userAvatar,
|
||||||
|
}: Props) => {
|
||||||
|
|
||||||
|
const [isAttachingFiles, setIsAttachingFiles] = React.useState(false);
|
||||||
|
const [attachments, setAttachments] = React.useState<File[]>([]);
|
||||||
|
|
||||||
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="newCommentForm">
|
<div className="newCommentForm">
|
||||||
{
|
{
|
||||||
isLoggedIn ?
|
isLoggedIn ?
|
||||||
<>
|
<>
|
||||||
<div className="commentBodyForm">
|
<div className="newCommentBodyForm">
|
||||||
<Gravatar email={userEmail} size={48} className="currentUserAvatar" />
|
<Avatar avatarUrl={userAvatar} email={userEmail} size={48} customClass="currentUserAvatar" />
|
||||||
|
|
||||||
|
<div style={{width: '100%', marginRight: '8px'}}>
|
||||||
<textarea
|
<textarea
|
||||||
value={body}
|
value={body}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
|
autoFocus={parentId != null}
|
||||||
placeholder={I18n.t('post.new_comment.body_placeholder')}
|
placeholder={I18n.t('post.new_comment.body_placeholder')}
|
||||||
className="commentForm"
|
className="commentForm"
|
||||||
/>
|
/>
|
||||||
|
<div style={{position: 'relative', width: 0, height: 0}}>
|
||||||
|
<MarkdownIcon style={{position: 'absolute', left: '6px', top: '-28px'}} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={() => handleSubmit(body, parentId, postUpdateFlagValue)}
|
onClick={() => {
|
||||||
|
handleSubmit(
|
||||||
|
body,
|
||||||
|
parentId,
|
||||||
|
postUpdateFlagValue,
|
||||||
|
attachments,
|
||||||
|
() => { setIsAttachingFiles(false); setAttachments([]); }
|
||||||
|
);
|
||||||
|
}}
|
||||||
className="submitCommentButton">
|
className="submitCommentButton">
|
||||||
{ isSubmitting ? <Spinner color="light" /> : I18n.t('post.new_comment.submit_button') }
|
{ isSubmitting ? <Spinner color="light" /> : I18n.t('post.new_comment.submit_button') }
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="newCommentFooter">
|
||||||
|
{ /* Attach files */ }
|
||||||
|
<div className={`attachFilesSection${allowAttachmentUpload ? '' : ' attachFilesSectionHidden'}`}>
|
||||||
{
|
{
|
||||||
isPowerUser && parentId == null ?
|
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
|
<NewCommentUpdateSection
|
||||||
postUpdateFlagValue={postUpdateFlagValue}
|
postUpdateFlagValue={postUpdateFlagValue}
|
||||||
handlePostUpdateFlag={handlePostUpdateFlag}
|
handlePostUpdateFlag={handlePostUpdateFlag}
|
||||||
|
allowAttachmentUpload={allowAttachmentUpload}
|
||||||
/>
|
/>
|
||||||
:
|
|
||||||
null
|
|
||||||
}
|
}
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
:
|
:
|
||||||
<a href="/users/sign_in" className="loginInfo">
|
<a href="/users/sign_in" className="loginInfo">
|
||||||
@@ -80,5 +144,6 @@ const NewComment = ({
|
|||||||
{ error ? <DangerText>{error}</DangerText> : null }
|
{ error ? <DangerText>{error}</DangerText> : null }
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default NewComment;
|
export default NewComment;
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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} />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
20
app/javascript/components/Post/PostAttachments.tsx
Normal file
20
app/javascript/components/Post/PostAttachments.tsx
Normal 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;
|
||||||
@@ -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,19 +60,29 @@ const PostEditForm = ({
|
|||||||
handleChangeBoard,
|
handleChangeBoard,
|
||||||
handleChangePostStatus,
|
handleChangePostStatus,
|
||||||
|
|
||||||
|
tenantSetting,
|
||||||
isPowerUser,
|
isPowerUser,
|
||||||
boards,
|
boards,
|
||||||
postStatuses,
|
postStatuses,
|
||||||
|
|
||||||
toggleEditMode,
|
toggleEditMode,
|
||||||
handleUpdatePost,
|
handleUpdatePost,
|
||||||
}: Props) => (
|
}: Props) => {
|
||||||
|
const [attachmentsToDelete, setAttachmentsToDelete] = React.useState<number[]>([]);
|
||||||
|
const [attachments, setAttachments] = React.useState<File[]>([]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setAttachmentsToDelete([]);
|
||||||
|
}, [attachmentUrls]);
|
||||||
|
|
||||||
|
return (
|
||||||
<div className="postEditForm">
|
<div className="postEditForm">
|
||||||
<div className="postHeader">
|
<div className="postHeader">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={title}
|
value={title}
|
||||||
onChange={e => handleChangeTitle(e.target.value)}
|
onChange={e => handleChangeTitle(e.target.value)}
|
||||||
|
autoFocus
|
||||||
className="postTitle form-control"
|
className="postTitle form-control"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -94,16 +112,70 @@ const PostEditForm = ({
|
|||||||
className="postDescription form-control"
|
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">
|
<div className="postEditFormButtons">
|
||||||
<ActionLink onClick={toggleEditMode} icon={<CancelIcon />}>
|
<ActionLink onClick={toggleEditMode} icon={<CancelIcon />}>
|
||||||
{I18n.t('common.buttons.cancel')}
|
{I18n.t('common.buttons.cancel')}
|
||||||
</ActionLink>
|
</ActionLink>
|
||||||
|
|
||||||
<Button onClick={() => handleUpdatePost(title, description, boardId, postStatusId)}>
|
<Button onClick={() => handleUpdatePost(title, description, boardId, postStatusId, attachmentsToDelete, attachments)}>
|
||||||
{ isUpdating ? <Spinner /> : I18n.t('common.buttons.update') }
|
{ isUpdating ? <Spinner /> : I18n.t('common.buttons.update') }
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{ error && <DangerText>{error}</DangerText> }
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default PostEditForm;
|
export default PostEditForm;
|
||||||
@@ -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()} </span>
|
<span>{I18n.t('post.published_by').toLowerCase()} </span>
|
||||||
<Gravatar email={authorEmail} size={24} className="postAuthorAvatar" />
|
<Avatar avatarUrl={authorAvatar} email={authorEmail} size={24} customClass="postAuthorAvatar" />
|
||||||
<span>{authorFullName}</span>
|
<span>{authorFullName}</span>
|
||||||
</>
|
</>
|
||||||
:
|
:
|
||||||
|
|||||||
@@ -2,13 +2,14 @@ import * as React from 'react';
|
|||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import I18n from 'i18n-js';
|
import I18n from 'i18n-js';
|
||||||
|
|
||||||
import IPost, { POST_APPROVAL_STATUS_APPROVED, POST_APPROVAL_STATUS_PENDING } from '../../interfaces/IPost';
|
import IPost, { POST_APPROVAL_STATUS_APPROVED, POST_APPROVAL_STATUS_PENDING, postJSON2JS } from '../../interfaces/IPost';
|
||||||
import IPostStatus from '../../interfaces/IPostStatus';
|
import IPostStatus from '../../interfaces/IPostStatus';
|
||||||
import IBoard from '../../interfaces/IBoard';
|
import IBoard from '../../interfaces/IBoard';
|
||||||
import ITenantSetting from '../../interfaces/ITenantSetting';
|
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';
|
||||||
@@ -29,6 +30,7 @@ import HttpStatus from '../../constants/http_status';
|
|||||||
import ActionLink from '../common/ActionLink';
|
import ActionLink from '../common/ActionLink';
|
||||||
import { EditIcon } from '../common/Icons';
|
import { EditIcon } from '../common/Icons';
|
||||||
import Badge, { BADGE_TYPE_DANGER, BADGE_TYPE_WARNING } from '../common/Badge';
|
import Badge, { BADGE_TYPE_DANGER, BADGE_TYPE_WARNING } from '../common/Badge';
|
||||||
|
import { likeJSON2JS } from '../../interfaces/ILike';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
postId: number;
|
postId: number;
|
||||||
@@ -41,24 +43,28 @@ interface Props {
|
|||||||
postStatusChanges: PostStatusChangesState;
|
postStatusChanges: PostStatusChangesState;
|
||||||
boards: Array<IBoard>;
|
boards: Array<IBoard>;
|
||||||
postStatuses: Array<IPostStatus>;
|
postStatuses: Array<IPostStatus>;
|
||||||
|
originPost: any;
|
||||||
isLoggedIn: boolean;
|
isLoggedIn: boolean;
|
||||||
isPowerUser: boolean;
|
isPowerUser: boolean;
|
||||||
currentUserFullName: string;
|
currentUserFullName: string;
|
||||||
currentUserEmail: string;
|
currentUserEmail: string;
|
||||||
|
currentUserAvatar?: string;
|
||||||
tenantSetting: ITenantSetting;
|
tenantSetting: ITenantSetting;
|
||||||
authenticityToken: string;
|
authenticityToken: string;
|
||||||
|
|
||||||
requestPost(postId: number): void;
|
requestPost(postId: number): Promise<any>;
|
||||||
updatePost(
|
updatePost(
|
||||||
postId: number,
|
postId: number,
|
||||||
title: string,
|
title: string,
|
||||||
description: string,
|
description: string,
|
||||||
boardId: number,
|
boardId: number,
|
||||||
postStatusId: number,
|
postStatusId: number,
|
||||||
|
attachmentsToDelete: number[],
|
||||||
|
attachments: File[],
|
||||||
authenticityToken: string,
|
authenticityToken: string,
|
||||||
): Promise<any>;
|
): Promise<any>;
|
||||||
|
|
||||||
requestLikes(postId: number): void;
|
requestLikes(postId: number): Promise<any>;
|
||||||
requestFollow(postId: number): void;
|
requestFollow(postId: number): void;
|
||||||
requestPostStatusChanges(postId: number): void;
|
requestPostStatusChanges(postId: number): void;
|
||||||
|
|
||||||
@@ -83,10 +89,20 @@ interface Props {
|
|||||||
): void;
|
): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
class PostP extends React.Component<Props> {
|
interface State {
|
||||||
|
postLoaded: boolean;
|
||||||
|
likesLoaded: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
class PostP extends React.Component<Props, State> {
|
||||||
constructor(props: Props) {
|
constructor(props: Props) {
|
||||||
super(props);
|
super(props);
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
postLoaded: false,
|
||||||
|
likesLoaded: false,
|
||||||
|
}
|
||||||
|
|
||||||
this._handleUpdatePost = this._handleUpdatePost.bind(this);
|
this._handleUpdatePost = this._handleUpdatePost.bind(this);
|
||||||
this._handleDeletePost = this._handleDeletePost.bind(this);
|
this._handleDeletePost = this._handleDeletePost.bind(this);
|
||||||
}
|
}
|
||||||
@@ -94,13 +110,20 @@ class PostP extends React.Component<Props> {
|
|||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
const { postId } = this.props;
|
const { postId } = this.props;
|
||||||
|
|
||||||
this.props.requestPost(postId);
|
this.props.requestPost(postId).then(() => this.setState({ postLoaded: true }));
|
||||||
this.props.requestLikes(postId);
|
this.props.requestLikes(postId).then(() => this.setState({ likesLoaded: true }));
|
||||||
this.props.requestFollow(postId);
|
this.props.requestFollow(postId);
|
||||||
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,
|
||||||
@@ -120,6 +143,8 @@ class PostP extends React.Component<Props> {
|
|||||||
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;
|
||||||
@@ -137,7 +162,10 @@ class PostP extends React.Component<Props> {
|
|||||||
this.props.deletePost(
|
this.props.deletePost(
|
||||||
this.props.postId,
|
this.props.postId,
|
||||||
this.props.authenticityToken
|
this.props.authenticityToken
|
||||||
).then(() => window.location.href = `/boards/${this.props.post.boardId}`);
|
).then(() => {
|
||||||
|
const board = this.props.boards.find(board => board.id === this.props.post.boardId);
|
||||||
|
window.location.href = `/boards/${board.slug || board.id}`;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
@@ -151,10 +179,12 @@ class PostP extends React.Component<Props> {
|
|||||||
postStatusChanges,
|
postStatusChanges,
|
||||||
boards,
|
boards,
|
||||||
postStatuses,
|
postStatuses,
|
||||||
|
originPost,
|
||||||
|
|
||||||
isLoggedIn,
|
isLoggedIn,
|
||||||
isPowerUser,
|
isPowerUser,
|
||||||
currentUserEmail,
|
currentUserEmail,
|
||||||
|
currentUserAvatar,
|
||||||
tenantSetting,
|
tenantSetting,
|
||||||
authenticityToken,
|
authenticityToken,
|
||||||
|
|
||||||
@@ -166,6 +196,14 @@ class PostP extends React.Component<Props> {
|
|||||||
handleChangeEditFormPostStatus,
|
handleChangeEditFormPostStatus,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
|
const {
|
||||||
|
postLoaded,
|
||||||
|
likesLoaded,
|
||||||
|
} = this.state;
|
||||||
|
|
||||||
|
const postToShow = postLoaded ? post : postJSON2JS(originPost.post);
|
||||||
|
const likesToShow = likesLoaded ? likes : { items: originPost.likes.map(l => likeJSON2JS(l)), areLoading: false, error: null };
|
||||||
|
|
||||||
const postUpdates = [
|
const postUpdates = [
|
||||||
...comments.items.filter(comment => comment.isPostUpdate === true),
|
...comments.items.filter(comment => comment.isPostUpdate === true),
|
||||||
...postStatusChanges.items,
|
...postStatusChanges.items,
|
||||||
@@ -187,15 +225,15 @@ class PostP extends React.Component<Props> {
|
|||||||
{
|
{
|
||||||
isPowerUser &&
|
isPowerUser &&
|
||||||
<LikeList
|
<LikeList
|
||||||
likes={likes.items}
|
likes={likesToShow.items}
|
||||||
areLoading={likes.areLoading}
|
areLoading={likesToShow.areLoading}
|
||||||
error={likes.error}
|
error={likesToShow.error}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|
||||||
<ActionBox
|
<ActionBox
|
||||||
followed={followed}
|
followed={followed}
|
||||||
submitFollow={() => submitFollow(post.id, !followed, authenticityToken)}
|
submitFollow={() => submitFollow(postToShow.id, !followed, authenticityToken)}
|
||||||
|
|
||||||
isLoggedIn={isLoggedIn}
|
isLoggedIn={isLoggedIn}
|
||||||
/>
|
/>
|
||||||
@@ -208,12 +246,14 @@ class PostP extends React.Component<Props> {
|
|||||||
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}
|
||||||
@@ -225,24 +265,24 @@ class PostP extends React.Component<Props> {
|
|||||||
<>
|
<>
|
||||||
<div className="postHeader">
|
<div className="postHeader">
|
||||||
<LikeButton
|
<LikeButton
|
||||||
postId={post.id}
|
postId={postToShow.id}
|
||||||
likeCount={likes.items.length}
|
likeCount={likesToShow.items.length}
|
||||||
showLikeCount={isPowerUser || tenantSetting.show_vote_count}
|
showLikeCount={isPowerUser || tenantSetting.show_vote_count}
|
||||||
liked={likes.items.find(like => like.email === currentUserEmail) ? 1 : 0}
|
liked={likesToShow.items.find(like => like.email === currentUserEmail) ? 1 : 0}
|
||||||
size="large"
|
size="large"
|
||||||
isLoggedIn={isLoggedIn}
|
isLoggedIn={isLoggedIn}
|
||||||
authenticityToken={authenticityToken}
|
authenticityToken={authenticityToken}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<h3>{post.title}</h3>
|
<h3>{postToShow.title}</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="postInfo">
|
<div className="postInfo">
|
||||||
<PostBoardLabel
|
<PostBoardLabel
|
||||||
{...boards.find(board => board.id === post.boardId)}
|
{...boards.find(board => board.id === postToShow.boardId)}
|
||||||
/>
|
/>
|
||||||
<PostStatusLabel
|
<PostStatusLabel
|
||||||
{...postStatuses.find(postStatus => postStatus.id === post.postStatusId)}
|
{...postStatuses.find(postStatus => postStatus.id === postToShow.postStatusId)}
|
||||||
/>
|
/>
|
||||||
{ isPowerUser &&
|
{ isPowerUser &&
|
||||||
<ActionLink onClick={toggleEditMode} icon={<EditIcon />} customClass='editAction'>
|
<ActionLink onClick={toggleEditMode} icon={<EditIcon />} customClass='editAction'>
|
||||||
@@ -252,10 +292,10 @@ class PostP extends React.Component<Props> {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{
|
{
|
||||||
(isPowerUser && post.approvalStatus !== POST_APPROVAL_STATUS_APPROVED) &&
|
(isPowerUser && postToShow.approvalStatus !== POST_APPROVAL_STATUS_APPROVED) &&
|
||||||
<div className="postInfo">
|
<div className="postInfo">
|
||||||
<Badge type={post.approvalStatus === POST_APPROVAL_STATUS_PENDING ? BADGE_TYPE_WARNING : BADGE_TYPE_DANGER}>
|
<Badge type={postToShow.approvalStatus === POST_APPROVAL_STATUS_PENDING ? BADGE_TYPE_WARNING : BADGE_TYPE_DANGER}>
|
||||||
{ I18n.t(`activerecord.attributes.post.approval_status_${post.approvalStatus.toLowerCase()}`) }
|
{ I18n.t(`activerecord.attributes.post.approval_status_${postToShow.approvalStatus.toLowerCase()}`) }
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -265,17 +305,22 @@ class PostP extends React.Component<Props> {
|
|||||||
disallowedTypes={['heading', 'image', 'html']}
|
disallowedTypes={['heading', 'image', 'html']}
|
||||||
unwrapDisallowed
|
unwrapDisallowed
|
||||||
>
|
>
|
||||||
{post.description}
|
{postToShow.description}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
|
|
||||||
|
<PostAttachments
|
||||||
|
attachmentUrls={postToShow?.attachmentUrls}
|
||||||
|
/>
|
||||||
|
|
||||||
<PostFooter
|
<PostFooter
|
||||||
createdAt={post.createdAt}
|
createdAt={postToShow.createdAt}
|
||||||
handleDeletePost={this._handleDeletePost}
|
handleDeletePost={this._handleDeletePost}
|
||||||
toggleEditMode={toggleEditMode}
|
toggleEditMode={toggleEditMode}
|
||||||
|
|
||||||
isPowerUser={isPowerUser}
|
isPowerUser={isPowerUser}
|
||||||
authorEmail={post.userEmail}
|
authorEmail={postToShow.userEmail}
|
||||||
authorFullName={post.userFullName}
|
authorFullName={postToShow.userFullName}
|
||||||
|
authorAvatar={originPost.authorAvatar}
|
||||||
currentUserEmail={currentUserEmail}
|
currentUserEmail={currentUserEmail}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
@@ -283,9 +328,11 @@ class PostP extends React.Component<Props> {
|
|||||||
|
|
||||||
<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>
|
||||||
|
|||||||
@@ -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>;
|
||||||
@@ -33,7 +33,7 @@ const PostUpdateList = ({
|
|||||||
|
|
||||||
<div className="postUpdateList">
|
<div className="postUpdateList">
|
||||||
{
|
{
|
||||||
postUpdates.length === 0 ?
|
postUpdates.length === 0 && !areLoading && !error ?
|
||||||
<CenteredMutedText>{I18n.t('post.updates_box.empty')}</CenteredMutedText>
|
<CenteredMutedText>{I18n.t('post.updates_box.empty')}</CenteredMutedText>
|
||||||
:
|
:
|
||||||
null
|
null
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -16,10 +16,12 @@ interface Props {
|
|||||||
postId: number;
|
postId: number;
|
||||||
boards: Array<IBoard>;
|
boards: Array<IBoard>;
|
||||||
postStatuses: Array<IPostStatus>;
|
postStatuses: Array<IPostStatus>;
|
||||||
|
originPost: any;
|
||||||
isLoggedIn: boolean;
|
isLoggedIn: boolean;
|
||||||
isPowerUser: boolean;
|
isPowerUser: boolean;
|
||||||
currentUserFullName: string;
|
currentUserFullName: string;
|
||||||
currentUserEmail: string;
|
currentUserEmail: string;
|
||||||
|
currentUserAvatar?: string;
|
||||||
tenantSetting: ITenantSetting;
|
tenantSetting: ITenantSetting;
|
||||||
authenticityToken: string;
|
authenticityToken: string;
|
||||||
}
|
}
|
||||||
@@ -38,10 +40,12 @@ class PostRoot extends React.Component<Props> {
|
|||||||
postId,
|
postId,
|
||||||
boards,
|
boards,
|
||||||
postStatuses,
|
postStatuses,
|
||||||
|
originPost,
|
||||||
isLoggedIn,
|
isLoggedIn,
|
||||||
isPowerUser,
|
isPowerUser,
|
||||||
currentUserFullName,
|
currentUserFullName,
|
||||||
currentUserEmail,
|
currentUserEmail,
|
||||||
|
currentUserAvatar,
|
||||||
tenantSetting,
|
tenantSetting,
|
||||||
authenticityToken
|
authenticityToken
|
||||||
} = this.props;
|
} = this.props;
|
||||||
@@ -52,11 +56,13 @@ class PostRoot extends React.Component<Props> {
|
|||||||
postId={postId}
|
postId={postId}
|
||||||
boards={boards}
|
boards={boards}
|
||||||
postStatuses={postStatuses}
|
postStatuses={postStatuses}
|
||||||
|
originPost={originPost}
|
||||||
|
|
||||||
isLoggedIn={isLoggedIn}
|
isLoggedIn={isLoggedIn}
|
||||||
isPowerUser={isPowerUser}
|
isPowerUser={isPowerUser}
|
||||||
currentUserFullName={currentUserFullName}
|
currentUserFullName={currentUserFullName}
|
||||||
currentUserEmail={currentUserEmail}
|
currentUserEmail={currentUserEmail}
|
||||||
|
currentUserAvatar={currentUserAvatar}
|
||||||
tenantSetting={tenantSetting}
|
tenantSetting={tenantSetting}
|
||||||
authenticityToken={authenticityToken}
|
authenticityToken={authenticityToken}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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');
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,12 +135,80 @@ 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 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>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
<div>
|
||||||
<textarea
|
<textarea
|
||||||
{...register('description')}
|
{...register('description')}
|
||||||
placeholder={I18n.t('site_settings.boards.form.description')}
|
placeholder={I18n.t('site_settings.boards.form.description')}
|
||||||
className="boardDescriptionTextArea formControl"
|
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' && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
@@ -10,9 +9,10 @@ import buildRequestHeaders from '../../../helpers/buildRequestHeaders';
|
|||||||
import HttpStatus from '../../../constants/http_status';
|
import HttpStatus from '../../../constants/http_status';
|
||||||
import { isValidEmail } from '../../../helpers/regex';
|
import { isValidEmail } from '../../../helpers/regex';
|
||||||
import IInvitation from '../../../interfaces/IInvitation';
|
import IInvitation from '../../../interfaces/IInvitation';
|
||||||
import friendlyDate 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>
|
||||||
|
|
||||||
@@ -229,9 +230,14 @@ const Invitations = ({ siteName, invitations, currentUserEmail, authenticityToke
|
|||||||
{ I18n.t('site_settings.invitations.accepted_at', { when: friendlyDate(invitation.accepted_at) }) }
|
{ I18n.t('site_settings.invitations.accepted_at', { when: friendlyDate(invitation.accepted_at) }) }
|
||||||
</span>
|
</span>
|
||||||
:
|
:
|
||||||
|
fromRailsStringToJavascriptDate(invitation.updated_at) > nMonthsAgo(3) ?
|
||||||
<span className="invitationSentAt" title={invitation.updated_at}>
|
<span className="invitationSentAt" title={invitation.updated_at}>
|
||||||
{ I18n.t('site_settings.invitations.sent_at', { when: friendlyDate(invitation.updated_at) }) }
|
{ I18n.t('site_settings.invitations.sent_at', { when: friendlyDate(invitation.updated_at) }) }
|
||||||
</span>
|
</span>
|
||||||
|
:
|
||||||
|
<span className="invitationExpired">
|
||||||
|
{ I18n.t('site_settings.invitations.expired') }
|
||||||
|
</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
415
app/javascript/components/SiteSettings/Webhooks/WebhookForm.tsx
Normal file
415
app/javascript/components/SiteSettings/Webhooks/WebhookForm.tsx
Normal 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') }
|
||||||
|
|
||||||
|
{ <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') }
|
||||||
|
|
||||||
|
{ <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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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>:
|
||||||
|
<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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
33
app/javascript/components/SiteSettings/Webhooks/index.tsx
Normal file
33
app/javascript/components/SiteSettings/Webhooks/index.tsx
Normal 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;
|
||||||
@@ -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}
|
||||||
|
|||||||
55
app/javascript/components/UserProfile/DeleteAvatarButton.tsx
Normal file
55
app/javascript/components/UserProfile/DeleteAvatarButton.tsx
Normal 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;
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import I18n from 'i18n-js';
|
||||||
|
|
||||||
|
import { DangerText, SmallMutedText, SuccessText } from '../common/CustomTexts';
|
||||||
|
import Button from '../common/Button';
|
||||||
|
import CopyToClipboardButton from '../common/CopyToClipboardButton';
|
||||||
|
import buildRequestHeaders from '../../helpers/buildRequestHeaders';
|
||||||
|
import HttpStatus from '../../constants/http_status';
|
||||||
|
import ActionLink from '../common/ActionLink';
|
||||||
|
import { LearnMoreIcon } from '../common/Icons';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
currentApiKey?: string;
|
||||||
|
generateApiKeyEndpoint: string;
|
||||||
|
authenticityToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GenerateApiKeyDialog = ({
|
||||||
|
currentApiKey,
|
||||||
|
generateApiKeyEndpoint,
|
||||||
|
authenticityToken,
|
||||||
|
}: Props) => {
|
||||||
|
const [hasBeenGenerated, setHasBeenGenerated] = React.useState(false);
|
||||||
|
const [apiKey, setApiKey] = React.useState('');
|
||||||
|
const [error, setError] = React.useState('');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h3>{I18n.t('common.forms.api_key.title')}</h3>
|
||||||
|
|
||||||
|
{
|
||||||
|
(currentApiKey && !hasBeenGenerated) &&
|
||||||
|
<>
|
||||||
|
<input type="disabled" readOnly value={currentApiKey} className="form-control" />
|
||||||
|
<SmallMutedText>{I18n.t('common.forms.api_key.current_api_key_help')}</SmallMutedText>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
hasBeenGenerated ?
|
||||||
|
<>
|
||||||
|
<input type="disabled" readOnly value={apiKey} className="form-control" />
|
||||||
|
<CopyToClipboardButton
|
||||||
|
label={I18n.t('common.buttons.copy_to_clipboard')}
|
||||||
|
textToCopy={apiKey}
|
||||||
|
copiedLabel={I18n.t('common.copied')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SmallMutedText>{I18n.t('common.forms.api_key.generated_api_key_help')}</SmallMutedText>
|
||||||
|
<br />
|
||||||
|
<SuccessText>{I18n.t('common.forms.api_key.generated_api_key_successfully')}</SuccessText>
|
||||||
|
</>
|
||||||
|
:
|
||||||
|
<>
|
||||||
|
<br />
|
||||||
|
<Button
|
||||||
|
onClick={async () => {
|
||||||
|
// If there is already an API key, ask for confirmation before generating a new one
|
||||||
|
if (currentApiKey) {
|
||||||
|
const confirmation = confirm(I18n.t('common.forms.api_key.confirm_generate_new_api_key'));
|
||||||
|
if (!confirmation) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a new API key
|
||||||
|
const res = await fetch(generateApiKeyEndpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: buildRequestHeaders(authenticityToken),
|
||||||
|
});
|
||||||
|
if (res.status === HttpStatus.Created) {
|
||||||
|
const json = await res.json();
|
||||||
|
setApiKey(json.api_key);
|
||||||
|
setHasBeenGenerated(true);
|
||||||
|
} else {
|
||||||
|
setError(I18n.t('errors.unknown'));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="btnPrimary apiKeyGenerateButton"
|
||||||
|
>
|
||||||
|
{I18n.t('common.forms.api_key.generate_api_key')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
|
||||||
|
<br /><br />
|
||||||
|
<ActionLink
|
||||||
|
icon={<LearnMoreIcon />}
|
||||||
|
onClick={() => window.open('https://docs.astuto.io/api', '_blank')}
|
||||||
|
>
|
||||||
|
{I18n.t('common.forms.api_key.api_key_learn_more')}
|
||||||
|
</ActionLink>
|
||||||
|
|
||||||
|
{ error && <DangerText>{error}</DangerText> }
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GenerateApiKeyDialog;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user