diff --git a/.circleci/config.yml b/.circleci/config.yml
deleted file mode 100644
index 7d9e042b..00000000
--- a/.circleci/config.yml
+++ /dev/null
@@ -1,85 +0,0 @@
-# Ruby CircleCI 2.0 configuration file
-#
-# Check https://circleci.com/docs/2.0/language-ruby/ for more details
-#
-version: 2
-jobs:
- build:
- docker:
- - image: circleci/ruby:2.6.3-node
- environment:
- PGHOST: localhost
- PGUSER: retrospective
- RAILS_ENV: test
- - image: postgres:9.5
- environment:
- POSTGRES_USER: retrospective
- POSTGRES_DB: retrospective_test
- POSTGRES_PASSWORD: ""
-
- working_directory: ~/retrospective
-
- steps:
- - checkout
- - run:
- name: Check current version of node
- command: node -v
-
- - restore_cache:
- keys:
- - v1-dependencies-{{ checksum "Gemfile.lock" }}
- - v1-dependencies-
-
- - run:
- name: install dependencies
- command: |
- gem install bundler && bundle install --jobs=4 --retry=3 --path vendor/bundle
-
- - save_cache:
- paths:
- - ./vendor/bundle
- key: v1-dependencies-{{ checksum "Gemfile.lock" }}
-
- - restore_cache:
- keys:
- - v2-yarn-{{ checksum "yarn.lock" }}
- - v2-yarn-
-
- - run:
- name: Yarn Install
- command: yarn install --cache-folder ~/.cache/yarn
-
- - save_cache:
- key: v2-yarn-{{ checksum "yarn.lock" }}
- paths:
- - ~/.cache/yarn
-
- - run:
- name: Yarn Install
- command: yarn install --cache-folder ~/.cache/yarn
-
- - run: bundle exec rake db:migrate
-
- - run:
- name: run rubocop
- command: bundle exec rubocop
-
- - run:
- name: run tests
- command: |
- mkdir /tmp/test-results
- TEST_FILES="$(circleci tests glob "spec/**/*_spec.rb" | \
- circleci tests split --split-by=timings)"
-
- bundle exec rspec \
- --format progress \
- --format RspecJunitFormatter \
- --out /tmp/test-results/rspec.xml \
- --format progress \
- $TEST_FILES
-
- - store_test_results:
- path: /tmp/test-results
- - store_artifacts:
- path: /tmp/test-results
- destination: test-results
diff --git a/.dockerdev/.psqlrc b/.dockerdev/.psqlrc
new file mode 100644
index 00000000..c6fef144
--- /dev/null
+++ b/.dockerdev/.psqlrc
@@ -0,0 +1 @@
+\set HISTFILE `[[ -z $PSQL_HISTFILE ]] && echo $HOME/.psql_history || echo $PSQL_HISTFILE`
diff --git a/.dockerdev/Aptfile b/.dockerdev/Aptfile
new file mode 100644
index 00000000..f027e0d4
--- /dev/null
+++ b/.dockerdev/Aptfile
@@ -0,0 +1 @@
+vim
diff --git a/.dockerdev/Dockerfile b/.dockerdev/Dockerfile
new file mode 100644
index 00000000..be7d067b
--- /dev/null
+++ b/.dockerdev/Dockerfile
@@ -0,0 +1,55 @@
+# Source: https://evilmartians.com/chronicles/ruby-on-whales-docker-for-ruby-rails-development
+
+ARG RUBY_VERSION
+
+FROM ruby:$RUBY_VERSION
+
+ARG NODE_MAJOR
+ARG PG_MAJOR
+ARG BUNDLER_VERSION
+ARG YARN_VERSION
+
+# Add PostgreSQL to sources list
+RUN curl -sSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add - \
+ && echo 'deb http://apt.postgresql.org/pub/repos/apt/ stretch-pgdg main' $PG_MAJOR > /etc/apt/sources.list.d/pgdg.list
+
+# Add NodeJS to sources list
+RUN curl -sL https://deb.nodesource.com/setup_$NODE_MAJOR.x | bash -
+
+# Add Yarn to the sources list
+RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - \
+ && echo 'deb http://dl.yarnpkg.com/debian/ stable main' > /etc/apt/sources.list.d/yarn.list
+
+# Install dependencies
+# We use an external Aptfile for that, stay tuned
+COPY .dockerdev/Aptfile /tmp/Aptfile
+RUN apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get -yq dist-upgrade && \
+ DEBIAN_FRONTEND=noninteractive apt-get install -yq --no-install-recommends \
+ build-essential \
+ postgresql-client-$PG_MAJOR \
+ nodejs \
+ yarn=$YARN_VERSION-1 \
+ $(cat /tmp/Aptfile | xargs) && \
+ apt-get clean && \
+ rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* && \
+ truncate -s 0 /var/log/*log
+RUN yarn
+
+# Configure bundler and PATH
+ENV LANG=C.UTF-8 \
+ GEM_HOME=/bundle \
+ BUNDLE_JOBS=4 \
+ BUNDLE_RETRY=3
+ENV BUNDLE_PATH $GEM_HOME
+ENV BUNDLE_APP_CONFIG=$BUNDLE_PATH \
+ BUNDLE_BIN=$BUNDLE_PATH/bin
+ENV PATH /app/bin:$BUNDLE_BIN:$PATH
+
+# Upgrade RubyGems and install required Bundler version
+RUN gem update --system && \
+ gem install bundler:$BUNDLER_VERSION
+
+# Create a directory for the app code
+RUN mkdir -p /app
+
+WORKDIR /app
diff --git a/app/javascript/components/.keep b/.dockerdev/bundle/.gitkeep
similarity index 100%
rename from app/javascript/components/.keep
rename to .dockerdev/bundle/.gitkeep
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 00000000..f68e97b0
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,11 @@
+# http://editorconfig.org
+
+root = true
+
+[*]
+indent_style = space
+indent_size = 2
+end_of_line = lf
+charset = utf-8
+trim_trailing_whitespace = true
+insert_final_newline = true
diff --git a/.env.development.example b/.env.development.example
index ac89e985..e460c4be 100644
--- a/.env.development.example
+++ b/.env.development.example
@@ -1,7 +1,15 @@
DATABASE_URL=postgres://user:password@postgresql:5432/retrospective_development?max_connections=5
-GITHUB_ID=
-GITHUB_SECRET=
+ALFRED_KEY=
+ALFRED_SECRET=
S3_BUCKET_NAME=
S3_KEY=
S3_SECRET=
SENTRY_KEY=
+SKIP_ALFRED=
+
+GOOGLE_KEY=
+GOOGLE_SECRET=
+FACEBOOK_KEY=
+FACEBOOK_SECRET=
+GITHUB_KEY=
+GITHUB_SECRET=
diff --git a/.env.test.example b/.env.test.example
index c7c6aebe..8be3b774 100644
--- a/.env.test.example
+++ b/.env.test.example
@@ -1,7 +1,7 @@
DATABASE_URL=postgres://user:password@postgresql:5432/retrospective_development?max_connections=5
DATABASE_CLEANER_ALLOW_REMOTE_DATABASE_URL=true
-GITHUB_ID=
-GITHUB_SECRET=
+ALFRED_KEY=
+ALFRED_SECRET=
S3_BUCKET_NAME=
S3_KEY=
S3_SECRET=
diff --git a/.github/workflows/aws.yml b/.github/workflows/aws.yml
new file mode 100644
index 00000000..eb80ae05
--- /dev/null
+++ b/.github/workflows/aws.yml
@@ -0,0 +1,49 @@
+on:
+ workflow_dispatch
+
+name: Deploy to Amazon ECS
+
+jobs:
+ deploy:
+ name: Deploy
+ runs-on: self-hosted
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v2
+
+ - name: Configure AWS credentials
+ uses: aws-actions/configure-aws-credentials@v1
+ with:
+ aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
+ aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+ aws-region: us-east-1
+
+ - name: Login to Amazon ECR
+ id: login-ecr
+ uses: aws-actions/amazon-ecr-login@v1
+
+ - name: Build, tag, and push image to Amazon ECR
+ id: build-image
+ env:
+ ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
+ ECR_REPOSITORY: retro
+ IMAGE_TAG: ${{ github.sha }}
+ run: |
+ # Build a docker container and
+ # push it to ECR so that it can
+ # be deployed to ECS.
+ docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG --build-arg RUBY_VERSION=2.6.6 --build-arg NODE_MAJOR=12 --build-arg PG_MAJOR=11 --build-arg YARN_VERSION=1.19.1 --build-arg BUNDLER_VERSION=2.0.2 --build-arg SECRET_KEY_BASE=${{ secrets.SECRET_KEY_BASE }} .
+ docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
+ echo "::set-output name=image::$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG"
+ - name: Execute SSH commmands on remote server
+ uses: JimCronqvist/action-ssh@master
+ id: deploy-ec2
+ env:
+ ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
+ ECR_REPOSITORY: retro
+ IMAGE_TAG: ${{ github.sha }}
+ with:
+ hosts: 'ubuntu@${{ secrets.HOST }}'
+ privateKey: ${{ secrets.DEPLOY_KEY }}
+ command: ECR="$ECR_REGISTRY/$ECR_REPOSITORY" IMAGE="$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" ~/deploy/deploy.sh
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
new file mode 100644
index 00000000..99c995e8
--- /dev/null
+++ b/.github/workflows/main.yml
@@ -0,0 +1,59 @@
+env:
+ RUBY_VERSION: 2.6.6
+ POSTGRESQL_USERNAME: postgres
+ POSTGRESQL_PASSWORD: postgres
+ POSTGRES_DB: postgres
+
+name: Rails tests
+on: [push,pull_request]
+jobs:
+ rubocop-test:
+ name: Rubocop
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v1
+ - uses: ruby/setup-ruby@v1
+ - name: Install Rubocop
+ run: gem install rubocop -v 0.74.0
+ - name: Check code
+ run: rubocop
+ rspec-test:
+ name: Rspec
+ needs: rubocop-test
+ runs-on: ubuntu-latest
+ services:
+ postgres:
+ image: postgres:latest
+ ports:
+ - 5432:5432
+ env:
+ POSTGRES_USER: ${{ env.POSTGRESQL_USERNAME }}
+ POSTGRES_PASSWORD: ${{ env.POSTGRESQL_PASSWORD }}
+ steps:
+ - uses: actions/checkout@v1
+ - uses: ruby/setup-ruby@v1
+ - name: Install postgres client
+ run: sudo apt-get install libpq-dev
+ - uses: actions/cache@v2
+ with:
+ path: vendor/bundle
+ key: ${{ runner.os }}-gems-${{ hashFiles('**/Gemfile.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-gems-
+ - name: Install dependencies
+ run: |
+ gem install bundler
+ bundle config path vendor/bundle
+ bundle install --jobs 4 --retry 3
+ - name: Create database
+ run: |
+ bundler exec rails db:create RAILS_ENV=test
+ bundler exec rails db:migrate RAILS_ENV=test
+ - name: Run tests
+ run: bundle exec rspec
+ - name: Upload coverage results
+ uses: actions/upload-artifact@master
+ if: always()
+ with:
+ name: coverage report
+ path: coverage
diff --git a/.gitignore b/.gitignore
index 1345a95b..b2bde509 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,6 +6,7 @@
# Ignore bundler config.
/.bundle
+/.dockerdev/bundle
# Ignore all logfiles and tempfiles.
/log/*
@@ -22,6 +23,7 @@
/public/assets
.byebug_history
+.ruby-gemset
# Ignore master key for decrypting credentials and more.
/config/master.key
@@ -29,7 +31,9 @@
/vendor/*
.env
-
+.generators
+.rakeTasks
+.idea/
/public/packs
/public/packs-test
/node_modules
@@ -38,3 +42,6 @@ yarn-debug.log*
.yarn-integrity
coverage
**/uploads
+**/.DS_Store
+
+/config/credentials/production.key
diff --git a/.rubocop.yml b/.rubocop.yml
index 58a66480..b8ae6c8d 100644
--- a/.rubocop.yml
+++ b/.rubocop.yml
@@ -16,6 +16,7 @@ Metrics/LineLength:
Metrics/BlockLength:
Exclude:
- ./spec/**/*_spec.rb
+ - ./lib/tasks/*.rake
Metrics/MethodLength:
Exclude:
@@ -28,5 +29,12 @@ Lint/AmbiguousBlockAssociation:
Style/Documentation:
Enabled: false
+Style/IfUnlessModifier:
+ Exclude:
+ - ./db/seeds.rb
+
Bundler/OrderedGems:
Enabled: false
+
+Metrics/AbcSize:
+ Max: 20
diff --git a/.ruby-version b/.ruby-version
index b2eb3147..fe30cc14 100644
--- a/.ruby-version
+++ b/.ruby-version
@@ -1 +1 @@
-ruby-2.6.3
\ No newline at end of file
+ruby-2.6.6
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 00000000..cb19b264
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,70 @@
+# Source: https://evilmartians.com/chronicles/ruby-on-whales-docker-for-ruby-rails-development
+
+ARG RUBY_VERSION
+
+FROM ruby:$RUBY_VERSION
+
+ARG SECRET_KEY_BASE
+ARG NODE_MAJOR
+ARG PG_MAJOR
+ARG BUNDLER_VERSION
+ARG YARN_VERSION
+
+# Add PostgreSQL to sources list
+RUN curl -sSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add - \
+ && echo 'deb http://apt.postgresql.org/pub/repos/apt/ stretch-pgdg main' $PG_MAJOR > /etc/apt/sources.list.d/pgdg.list
+
+# Add NodeJS to sources list
+RUN curl -sL https://deb.nodesource.com/setup_$NODE_MAJOR.x | bash -
+
+# Add Yarn to the sources list
+RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - \
+ && echo 'deb http://dl.yarnpkg.com/debian/ stable main' > /etc/apt/sources.list.d/yarn.list
+
+# Install dependencies
+# We use an external Aptfile for that, stay tuned
+COPY .dockerdev/Aptfile /tmp/Aptfile
+RUN apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get -yq dist-upgrade && \
+ DEBIAN_FRONTEND=noninteractive apt-get install -yq --no-install-recommends \
+ build-essential \
+ postgresql-client-$PG_MAJOR \
+ nodejs \
+ yarn=$YARN_VERSION-1 \
+ $(cat /tmp/Aptfile | xargs) && \
+ apt-get clean && \
+ rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* && \
+ truncate -s 0 /var/log/*log
+
+# Configure bundler and PATH
+ENV LANG=C.UTF-8 \
+ GEM_HOME=/bundle \
+ BUNDLE_JOBS=4 \
+ BUNDLE_RETRY=3
+ENV BUNDLE_PATH $GEM_HOME
+ENV BUNDLE_APP_CONFIG=$BUNDLE_PATH \
+ BUNDLE_BIN=$BUNDLE_PATH/bin
+ENV PATH /app/bin:$BUNDLE_BIN:$PATH
+
+# Upgrade RubyGems and install required Bundler version
+RUN gem update --system && \
+ gem install bundler:$BUNDLER_VERSION
+
+# Create a directory for the app code
+RUN mkdir -p /app
+
+WORKDIR /app
+
+COPY . .
+
+ENV RAILS_ENV production
+
+RUN bundle config github.https true && \
+ bundle install \
+ --without development test \
+ --jobs=4 \
+ --deployment
+
+RUN yarn
+
+RUN SECRET_KEY_BASE=$SECRET_KEY_BASE RAILS_ENV=production bundle exec rails assets:precompile
+
diff --git a/Gemfile b/Gemfile
index 5dc0309e..8c1e8073 100644
--- a/Gemfile
+++ b/Gemfile
@@ -3,29 +3,29 @@
source 'https://rubygems.org'
git_source(:github) { |repo| "https://github.com/#{repo}.git" }
-ruby '2.6.3'
+ruby '2.6.6'
# Bundle edge Rails instead: gem 'rails', github: 'rails/rails'
-gem 'rails', '~> 6.0'
+gem 'rails', '~> 6.1'
# Use postgresql as the database for Active Record
gem 'pg', '>= 0.18', '< 2.0'
# Use Puma as the app server
-gem 'puma', '~> 3.11'
+gem 'puma', '~> 5.1'
# Use SCSS for stylesheets
-gem 'sass-rails', '~> 5.0'
+gem 'sass-rails', '~> 6.0'
# Use Uglifier as compressor for JavaScript assets
gem 'uglifier', '>= 1.3.0'
# See https://github.com/rails/execjs#readme for more supported runtimes
# gem 'mini_racer', platforms: :ruby
# Use CoffeeScript for .coffee assets and views
-gem 'coffee-rails', '~> 4.2'
+gem 'coffee-rails', '~> 5.0'
# Turbolinks makes navigating your web application faster. Read more: https://github.com/turbolinks/turbolinks
# gem 'turbolinks', '~> 5'
# Build JSON APIs with ease. Read more: https://github.com/rails/jbuilder
gem 'jbuilder', '~> 2.5'
# Use Redis adapter to run Action Cable in production
-# gem 'redis', '~> 4.0'
+gem 'redis', '~> 4.2'
# Use ActiveModel has_secure_password
# gem 'bcrypt', '~> 3.1.7'
@@ -40,10 +40,17 @@ gem 'bootsnap', '>= 1.1.0', require: false
gem 'dotenv-rails'
gem 'sentry-raven'
+# Authentication
+gem 'devise'
+gem 'omniauth-alfred', git: 'https://github.com/cybergizer-hq/omniauth-alfred', branch: 'master'
+gem 'omniauth-google-oauth2'
+gem 'omniauth-facebook'
+gem 'omniauth-github'
+
group :development, :test do
# Call 'byebug' anywhere in the code to stop execution and get a debugger console
- gem 'rubocop', '~> 0.74.0', require: false
- gem 'rspec-rails', '~> 4.0.0.beta2'
+ gem 'rubocop', '~> 1.6.1', require: false
+ gem 'rspec-rails', '~> 4.0.1'
gem 'rspec_junit_formatter'
gem 'faker', git: 'https://github.com/stympy/faker.git', branch: 'master'
gem 'pry'
@@ -53,8 +60,9 @@ end
group :development do
# Access an interactive console on exception pages or by calling 'console' anywhere in the code.
gem 'web-console', '>= 3.3.0'
- gem 'listen', '>= 3.0.5', '< 3.2'
+ gem 'listen', '>= 3.0.5', '< 3.4'
gem 'rails-erd'
+ gem 'letter_opener'
end
group :test do
@@ -75,15 +83,23 @@ end
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
gem 'tzinfo-data', platforms: %i[mingw mswin x64_mingw jruby]
-gem 'devise'
-gem 'omniauth-github'
-gem 'webpacker', '~> 4.x'
+gem 'webpacker', '~> 5'
gem 'rails-assets-bulma', source: 'https://rails-assets.org'
gem 'fog-aws', require: false
gem 'carrierwave', '>= 2.0.0.rc', '< 3.0'
gem 'nanoid'
-gem 'action_policy', '~> 0.3.0'
+gem 'action_policy', '~> 0.5.4'
gem 'react-rails'
-gem 'active_model_serializers', '~> 0.10.0'
+gem 'active_model_serializers', '~> 0.10.12'
gem 'dry-monads'
gem 'aasm'
+gem 'sidekiq'
+
+gem 'graphql', '~> 1.11'
+gem 'action_policy-graphql', '~> 0.5'
+gem 'graphiql-rails', group: :development
+group :test, :development do
+ gem 'action-cable-testing'
+end
+
+gem 'bullet', group: 'test'
diff --git a/Gemfile.lock b/Gemfile.lock
index a670cc17..2876681b 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -1,94 +1,117 @@
+GIT
+ remote: https://github.com/cybergizer-hq/omniauth-alfred
+ revision: 9cbda091493b3a68fe7d5136d91199db32741ff5
+ branch: master
+ specs:
+ omniauth-alfred (0.0.0)
+ omniauth (~> 1.0)
+ omniauth-oauth2 (~> 1.0)
+
GIT
remote: https://github.com/stympy/faker.git
- revision: 4e9144825fcc9ba5c83cc0fd037779ab82f3120b
+ revision: ec06d217644cf3d66bd0e658c9b168af8f0c38c7
branch: master
specs:
- faker (2.6.0)
- i18n (>= 1.6, < 1.8)
+ faker (2.15.1)
+ i18n (>= 1.6, < 2)
GEM
remote: https://rubygems.org/
remote: https://rails-assets.org/
specs:
- aasm (5.0.6)
+ aasm (5.1.1)
concurrent-ruby (~> 1.0)
- action_policy (0.3.2)
- actioncable (6.0.0)
- actionpack (= 6.0.0)
+ action-cable-testing (0.6.1)
+ actioncable (>= 5.0)
+ action_policy (0.5.4)
+ ruby-next-core (>= 0.10.3)
+ action_policy-graphql (0.5.2)
+ action_policy (>= 0.5.0)
+ graphql (>= 1.9.3)
+ ruby-next-core (>= 0.10.0)
+ actioncable (6.1.0)
+ actionpack (= 6.1.0)
+ activesupport (= 6.1.0)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
- actionmailbox (6.0.0)
- actionpack (= 6.0.0)
- activejob (= 6.0.0)
- activerecord (= 6.0.0)
- activestorage (= 6.0.0)
- activesupport (= 6.0.0)
+ actionmailbox (6.1.0)
+ actionpack (= 6.1.0)
+ activejob (= 6.1.0)
+ activerecord (= 6.1.0)
+ activestorage (= 6.1.0)
+ activesupport (= 6.1.0)
mail (>= 2.7.1)
- actionmailer (6.0.0)
- actionpack (= 6.0.0)
- actionview (= 6.0.0)
- activejob (= 6.0.0)
+ actionmailer (6.1.0)
+ actionpack (= 6.1.0)
+ actionview (= 6.1.0)
+ activejob (= 6.1.0)
+ activesupport (= 6.1.0)
mail (~> 2.5, >= 2.5.4)
rails-dom-testing (~> 2.0)
- actionpack (6.0.0)
- actionview (= 6.0.0)
- activesupport (= 6.0.0)
- rack (~> 2.0)
+ actionpack (6.1.0)
+ actionview (= 6.1.0)
+ activesupport (= 6.1.0)
+ rack (~> 2.0, >= 2.0.9)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.2.0)
- actiontext (6.0.0)
- actionpack (= 6.0.0)
- activerecord (= 6.0.0)
- activestorage (= 6.0.0)
- activesupport (= 6.0.0)
+ actiontext (6.1.0)
+ actionpack (= 6.1.0)
+ activerecord (= 6.1.0)
+ activestorage (= 6.1.0)
+ activesupport (= 6.1.0)
nokogiri (>= 1.8.5)
- actionview (6.0.0)
- activesupport (= 6.0.0)
+ actionview (6.1.0)
+ activesupport (= 6.1.0)
builder (~> 3.1)
erubi (~> 1.4)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.1, >= 1.2.0)
- active_model_serializers (0.10.10)
- actionpack (>= 4.1, < 6.1)
- activemodel (>= 4.1, < 6.1)
+ active_model_serializers (0.10.12)
+ actionpack (>= 4.1, < 6.2)
+ activemodel (>= 4.1, < 6.2)
case_transform (>= 0.2)
jsonapi-renderer (>= 0.1.1.beta1, < 0.3)
- activejob (6.0.0)
- activesupport (= 6.0.0)
+ activejob (6.1.0)
+ activesupport (= 6.1.0)
globalid (>= 0.3.6)
- activemodel (6.0.0)
- activesupport (= 6.0.0)
- activerecord (6.0.0)
- activemodel (= 6.0.0)
- activesupport (= 6.0.0)
- activestorage (6.0.0)
- actionpack (= 6.0.0)
- activejob (= 6.0.0)
- activerecord (= 6.0.0)
+ activemodel (6.1.0)
+ activesupport (= 6.1.0)
+ activerecord (6.1.0)
+ activemodel (= 6.1.0)
+ activesupport (= 6.1.0)
+ activestorage (6.1.0)
+ actionpack (= 6.1.0)
+ activejob (= 6.1.0)
+ activerecord (= 6.1.0)
+ activesupport (= 6.1.0)
marcel (~> 0.3.1)
- activesupport (6.0.0)
+ mimemagic (~> 0.3.2)
+ activesupport (6.1.0)
concurrent-ruby (~> 1.0, >= 1.0.2)
- i18n (>= 0.7, < 2)
- minitest (~> 5.1)
- tzinfo (~> 1.1)
- zeitwerk (~> 2.1, >= 2.1.8)
+ i18n (>= 1.6, < 2)
+ minitest (>= 5.1)
+ tzinfo (~> 2.0)
+ zeitwerk (~> 2.3)
addressable (2.7.0)
public_suffix (>= 2.0.2, < 5.0)
archive-zip (0.12.0)
io-like (~> 0.3.0)
- ast (2.4.0)
+ ast (2.4.1)
babel-source (5.8.35)
babel-transpiler (0.7.0)
babel-source (>= 4.0, < 6)
execjs (~> 2.0)
- bcrypt (3.1.13)
+ bcrypt (3.1.16)
bindex (0.8.1)
- bootsnap (1.4.5)
+ bootsnap (1.5.1)
msgpack (~> 1.0)
- builder (3.2.3)
- byebug (11.0.1)
- capybara (3.29.0)
+ builder (3.2.4)
+ bullet (6.1.2)
+ activesupport (>= 3.0.0)
+ uniform_notifier (~> 1.11)
+ byebug (11.1.3)
+ capybara (3.34.0)
addressable
mini_mime (>= 0.1.3)
nokogiri (~> 1.8)
@@ -96,7 +119,7 @@ GEM
rack-test (>= 0.6.3)
regexp_parser (~> 1.5)
xpath (~> 3.2)
- carrierwave (2.0.2)
+ carrierwave (2.1.0)
activemodel (>= 5.0.0)
activesupport (>= 5.0.0)
addressable (~> 2.6)
@@ -110,58 +133,58 @@ GEM
chromedriver-helper (2.1.1)
archive-zip (~> 0.10)
nokogiri (~> 1.8)
- coderay (1.1.2)
- coffee-rails (4.2.2)
+ coderay (1.1.3)
+ coffee-rails (5.0.0)
coffee-script (>= 2.2.0)
- railties (>= 4.0.0)
+ railties (>= 5.2.0)
coffee-script (2.4.1)
coffee-script-source
execjs
coffee-script-source (1.12.2)
- concurrent-ruby (1.1.5)
- connection_pool (2.2.2)
- crack (0.4.3)
- safe_yaml (~> 1.0.0)
- crass (1.0.4)
- database_cleaner (1.7.0)
- devise (4.7.1)
+ concurrent-ruby (1.1.7)
+ connection_pool (2.2.3)
+ crack (0.4.4)
+ crass (1.0.6)
+ database_cleaner (1.8.5)
+ devise (4.7.3)
bcrypt (~> 3.0)
orm_adapter (~> 0.1)
railties (>= 4.1.0)
responders
warden (~> 1.2.3)
- diff-lcs (1.3)
+ diff-lcs (1.4.4)
docile (1.3.2)
- dotenv (2.7.5)
- dotenv-rails (2.7.5)
- dotenv (= 2.7.5)
- railties (>= 3.2, < 6.1)
- dry-core (0.4.9)
+ dotenv (2.7.6)
+ dotenv-rails (2.7.6)
+ dotenv (= 2.7.6)
+ railties (>= 3.2)
+ dry-core (0.5.0)
concurrent-ruby (~> 1.0)
- dry-equalizer (0.2.2)
- dry-monads (1.3.1)
+ dry-equalizer (0.3.0)
+ dry-monads (1.3.5)
concurrent-ruby (~> 1.0)
dry-core (~> 0.4, >= 0.4.4)
dry-equalizer
- erubi (1.9.0)
- excon (0.67.0)
+ erubi (1.10.0)
+ excon (0.78.1)
execjs (2.7.0)
- factory_bot (5.1.1)
- activesupport (>= 4.2.0)
- factory_bot_rails (5.1.1)
- factory_bot (~> 5.1.0)
- railties (>= 4.2.0)
- faraday (0.17.0)
+ factory_bot (6.1.0)
+ activesupport (>= 5.0.0)
+ factory_bot_rails (6.1.0)
+ factory_bot (~> 6.1.0)
+ railties (>= 5.0.0)
+ faraday (1.1.0)
multipart-post (>= 1.2, < 3)
- ffi (1.11.1)
- fog-aws (3.5.2)
+ ruby2_keywords
+ ffi (1.14.1)
+ fog-aws (3.7.0)
fog-core (~> 2.1)
fog-json (~> 1.1)
fog-xml (~> 0.1)
ipaddress (~> 0.8)
- fog-core (2.1.2)
+ fog-core (2.2.3)
builder
- excon (~> 0.58)
+ excon (~> 0.71)
formatador (~> 0.2)
mime-types
fog-json (1.2.0)
@@ -173,29 +196,35 @@ GEM
formatador (0.2.5)
globalid (0.4.2)
activesupport (>= 4.2.0)
- hashdiff (1.0.0)
- hashie (3.6.0)
- i18n (1.7.0)
+ graphiql-rails (1.7.0)
+ railties
+ sprockets-rails
+ graphql (1.11.6)
+ hashdiff (1.0.1)
+ hashie (4.1.0)
+ i18n (1.8.5)
concurrent-ruby (~> 1.0)
- image_processing (1.9.3)
+ image_processing (1.12.1)
mini_magick (>= 4.9.5, < 5)
- ruby-vips (>= 2.0.13, < 3)
+ ruby-vips (>= 2.0.17, < 3)
io-like (0.3.0)
ipaddress (0.8.3)
- jaro_winkler (1.5.3)
jbuilder (2.9.1)
activesupport (>= 4.2.0)
- json (2.2.0)
+ json (2.3.1)
json_matchers (0.11.1)
json_schema
json_schema (0.20.8)
jsonapi-renderer (0.2.2)
- jwt (2.2.1)
- listen (3.1.5)
- rb-fsevent (~> 0.9, >= 0.9.4)
- rb-inotify (~> 0.9, >= 0.9.7)
- ruby_dep (~> 1.2)
- loofah (2.3.0)
+ jwt (2.2.2)
+ launchy (2.5.0)
+ addressable (~> 2.7)
+ letter_opener (1.7.0)
+ launchy (~> 2.2)
+ listen (3.3.3)
+ rb-fsevent (~> 0.10, >= 0.10.3)
+ rb-inotify (~> 0.9, >= 0.9.10)
+ loofah (2.8.0)
crass (~> 1.0.2)
nokogiri (>= 1.5.9)
mail (2.7.1)
@@ -203,69 +232,79 @@ GEM
marcel (0.3.3)
mimemagic (~> 0.3.2)
method_source (0.9.2)
- mime-types (3.3)
+ mime-types (3.3.1)
mime-types-data (~> 3.2015)
- mime-types-data (3.2019.1009)
- mimemagic (0.3.3)
- mini_magick (4.9.5)
+ mime-types-data (3.2020.1104)
+ mimemagic (0.3.10)
+ nokogiri (~> 1)
+ rake
+ mini_magick (4.11.0)
mini_mime (1.0.2)
mini_portile2 (2.4.0)
- minitest (5.12.2)
- msgpack (1.3.1)
- multi_json (1.13.1)
+ minitest (5.14.2)
+ msgpack (1.3.3)
+ multi_json (1.15.0)
multi_xml (0.6.0)
multipart-post (2.1.1)
nanoid (2.0.0)
- nio4r (2.5.2)
- nokogiri (1.10.4)
+ nio4r (2.5.4)
+ nokogiri (1.10.10)
mini_portile2 (~> 2.4.0)
- oauth2 (1.4.2)
+ oauth2 (1.4.4)
faraday (>= 0.8, < 2.0)
jwt (>= 1.0, < 3.0)
multi_json (~> 1.3)
multi_xml (~> 0.5)
rack (>= 1.2, < 3)
- omniauth (1.9.0)
- hashie (>= 3.4.6, < 3.7.0)
+ omniauth (1.9.1)
+ hashie (>= 3.4.6)
rack (>= 1.6.2, < 3)
- omniauth-github (1.3.0)
+ omniauth-facebook (8.0.0)
+ omniauth-oauth2 (~> 1.2)
+ omniauth-github (1.4.0)
omniauth (~> 1.5)
omniauth-oauth2 (>= 1.4.0, < 2.0)
- omniauth-oauth2 (1.6.0)
+ omniauth-google-oauth2 (0.8.1)
+ jwt (>= 2.0)
oauth2 (~> 1.1)
+ omniauth (>= 1.1.1)
+ omniauth-oauth2 (>= 1.6)
+ omniauth-oauth2 (1.7.0)
+ oauth2 (~> 1.4)
omniauth (~> 1.9)
orm_adapter (0.5.0)
- parallel (1.18.0)
- parser (2.6.5.0)
- ast (~> 2.4.0)
- pg (1.1.4)
+ parallel (1.20.1)
+ parser (2.7.2.0)
+ ast (~> 2.4.1)
+ pg (1.2.3)
pry (0.12.2)
coderay (~> 1.1.0)
method_source (~> 0.9.0)
- pry-byebug (3.7.0)
+ pry-byebug (3.8.0)
byebug (~> 11.0)
pry (~> 0.10)
- public_suffix (4.0.1)
- puma (3.12.1)
- rack (2.0.7)
+ public_suffix (4.0.6)
+ puma (5.1.1)
+ nio4r (~> 2.0)
+ rack (2.2.3)
rack-proxy (0.6.5)
rack
rack-test (1.1.0)
rack (>= 1.0, < 3)
- rails (6.0.0)
- actioncable (= 6.0.0)
- actionmailbox (= 6.0.0)
- actionmailer (= 6.0.0)
- actionpack (= 6.0.0)
- actiontext (= 6.0.0)
- actionview (= 6.0.0)
- activejob (= 6.0.0)
- activemodel (= 6.0.0)
- activerecord (= 6.0.0)
- activestorage (= 6.0.0)
- activesupport (= 6.0.0)
- bundler (>= 1.3.0)
- railties (= 6.0.0)
+ rails (6.1.0)
+ actioncable (= 6.1.0)
+ actionmailbox (= 6.1.0)
+ actionmailer (= 6.1.0)
+ actionpack (= 6.1.0)
+ actiontext (= 6.1.0)
+ actionview (= 6.1.0)
+ activejob (= 6.1.0)
+ activemodel (= 6.1.0)
+ activerecord (= 6.1.0)
+ activestorage (= 6.1.0)
+ activesupport (= 6.1.0)
+ bundler (>= 1.15.0)
+ railties (= 6.1.0)
sprockets-rails (>= 2.0.0)
rails-assets-bulma (0.7.4)
rails-controller-testing (1.0.4)
@@ -282,132 +321,146 @@ GEM
ruby-graphviz (~> 1.2)
rails-html-sanitizer (1.3.0)
loofah (~> 2.3)
- railties (6.0.0)
- actionpack (= 6.0.0)
- activesupport (= 6.0.0)
+ railties (6.1.0)
+ actionpack (= 6.1.0)
+ activesupport (= 6.1.0)
method_source
rake (>= 0.8.7)
- thor (>= 0.20.3, < 2.0)
+ thor (~> 1.0)
rainbow (3.0.0)
- rake (13.0.0)
- rb-fsevent (0.10.3)
- rb-inotify (0.10.0)
+ rake (13.0.3)
+ rb-fsevent (0.10.4)
+ rb-inotify (0.10.1)
ffi (~> 1.0)
- react-rails (2.6.0)
+ react-rails (2.6.1)
babel-transpiler (>= 0.7.0)
connection_pool
execjs
railties (>= 3.2)
tilt
- regexp_parser (1.6.0)
- responders (3.0.0)
+ redis (4.2.5)
+ regexp_parser (1.8.2)
+ responders (3.0.1)
actionpack (>= 5.0)
railties (>= 5.0)
- rspec-core (3.9.0)
- rspec-support (~> 3.9.0)
- rspec-expectations (3.9.0)
+ rexml (3.2.4)
+ rspec-core (3.10.0)
+ rspec-support (~> 3.10.0)
+ rspec-expectations (3.10.0)
diff-lcs (>= 1.2.0, < 2.0)
- rspec-support (~> 3.9.0)
- rspec-mocks (3.9.0)
+ rspec-support (~> 3.10.0)
+ rspec-mocks (3.10.0)
diff-lcs (>= 1.2.0, < 2.0)
- rspec-support (~> 3.9.0)
- rspec-rails (4.0.0.beta2)
+ rspec-support (~> 3.10.0)
+ rspec-rails (4.0.1)
actionpack (>= 4.2)
activesupport (>= 4.2)
railties (>= 4.2)
- rspec-core (~> 3.8)
- rspec-expectations (~> 3.8)
- rspec-mocks (~> 3.8)
- rspec-support (~> 3.8)
- rspec-support (3.9.0)
+ rspec-core (~> 3.9)
+ rspec-expectations (~> 3.9)
+ rspec-mocks (~> 3.9)
+ rspec-support (~> 3.9)
+ rspec-support (3.10.0)
rspec_junit_formatter (0.4.1)
rspec-core (>= 2, < 4, != 2.12.0)
- rubocop (0.74.0)
- jaro_winkler (~> 1.5.1)
+ rubocop (1.6.1)
parallel (~> 1.10)
- parser (>= 2.6)
+ parser (>= 2.7.1.5)
rainbow (>= 2.2.2, < 4.0)
+ regexp_parser (>= 1.8, < 3.0)
+ rexml
+ rubocop-ast (>= 1.2.0, < 2.0)
ruby-progressbar (~> 1.7)
- unicode-display_width (>= 1.4.0, < 1.7)
+ unicode-display_width (>= 1.4.0, < 2.0)
+ rubocop-ast (1.3.0)
+ parser (>= 2.7.1.5)
ruby-graphviz (1.2.4)
+ ruby-next-core (0.10.5)
ruby-progressbar (1.10.1)
- ruby-vips (2.0.15)
+ ruby-vips (2.0.17)
ffi (~> 1.9)
- ruby_dep (1.5.0)
- rubyzip (2.0.0)
- safe_yaml (1.0.5)
- sass (3.7.4)
- sass-listen (~> 4.0.0)
- sass-listen (4.0.0)
- rb-fsevent (~> 0.9, >= 0.9.4)
- rb-inotify (~> 0.9, >= 0.9.7)
- sass-rails (5.1.0)
- railties (>= 5.2.0)
- sass (~> 3.1)
- sprockets (>= 2.8, < 4.0)
- sprockets-rails (>= 2.0, < 4.0)
- tilt (>= 1.1, < 3)
- selenium-webdriver (3.142.6)
+ ruby2_keywords (0.0.2)
+ rubyzip (2.3.0)
+ sass-rails (6.0.0)
+ sassc-rails (~> 2.1, >= 2.1.1)
+ sassc (2.4.0)
+ ffi (~> 1.9)
+ sassc-rails (2.1.2)
+ railties (>= 4.0.0)
+ sassc (>= 2.0)
+ sprockets (> 3.0)
+ sprockets-rails
+ tilt
+ selenium-webdriver (3.142.7)
childprocess (>= 0.5, < 4.0)
rubyzip (>= 1.2.2)
- sentry-raven (2.11.3)
- faraday (>= 0.7.6, < 1.0)
+ semantic_range (2.3.1)
+ sentry-raven (3.1.1)
+ faraday (>= 1.0)
+ sidekiq (6.1.2)
+ connection_pool (>= 2.2.2)
+ rack (~> 2.0)
+ redis (>= 4.2.0)
simplecov (0.17.1)
docile (~> 1.1)
json (>= 1.8, < 3)
simplecov-html (~> 0.10.0)
simplecov-html (0.10.2)
- sprockets (3.7.2)
+ sprockets (4.0.2)
concurrent-ruby (~> 1.0)
rack (> 1, < 3)
- sprockets-rails (3.2.1)
+ sprockets-rails (3.2.2)
actionpack (>= 4.0)
activesupport (>= 4.0)
sprockets (>= 3.0.0)
test-prof (0.10.0)
- thor (0.20.3)
- thread_safe (0.3.6)
+ thor (1.0.1)
tilt (2.0.10)
- tzinfo (1.2.5)
- thread_safe (~> 0.1)
+ tzinfo (2.0.4)
+ concurrent-ruby (~> 1.0)
uglifier (4.2.0)
execjs (>= 0.3.0, < 3)
- unicode-display_width (1.6.0)
- vcr (5.0.0)
- warden (1.2.8)
- rack (>= 2.0.6)
- web-console (4.0.1)
+ unicode-display_width (1.7.0)
+ uniform_notifier (1.13.0)
+ vcr (6.0.0)
+ warden (1.2.9)
+ rack (>= 2.0.9)
+ web-console (4.1.0)
actionview (>= 6.0.0)
activemodel (>= 6.0.0)
bindex (>= 0.4.0)
railties (>= 6.0.0)
- webmock (3.7.6)
+ webmock (3.10.0)
addressable (>= 2.3.6)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
- webpacker (4.0.7)
- activesupport (>= 4.2)
+ webpacker (5.2.1)
+ activesupport (>= 5.2)
rack-proxy (>= 0.6.1)
- railties (>= 4.2)
- websocket-driver (0.7.1)
+ railties (>= 5.2)
+ semantic_range (>= 2.3.0)
+ websocket-driver (0.7.3)
websocket-extensions (>= 0.1.0)
- websocket-extensions (0.1.4)
+ websocket-extensions (0.1.5)
xpath (3.2.0)
nokogiri (~> 1.8)
- zeitwerk (2.2.0)
+ zeitwerk (2.4.2)
PLATFORMS
ruby
DEPENDENCIES
aasm
- action_policy (~> 0.3.0)
- active_model_serializers (~> 0.10.0)
+ action-cable-testing
+ action_policy (~> 0.5.4)
+ action_policy-graphql (~> 0.5)
+ active_model_serializers (~> 0.10.12)
bootsnap (>= 1.1.0)
+ bullet
capybara (>= 2.15)
carrierwave (>= 2.0.0.rc, < 3.0)
chromedriver-helper
- coffee-rails (~> 4.2)
+ coffee-rails (~> 5.0)
database_cleaner
devise
dotenv-rails
@@ -415,26 +468,34 @@ DEPENDENCIES
factory_bot_rails
faker!
fog-aws
+ graphiql-rails
+ graphql (~> 1.11)
jbuilder (~> 2.5)
json_matchers
- listen (>= 3.0.5, < 3.2)
+ letter_opener
+ listen (>= 3.0.5, < 3.4)
nanoid
+ omniauth-alfred!
+ omniauth-facebook
omniauth-github
+ omniauth-google-oauth2
pg (>= 0.18, < 2.0)
pry
pry-byebug
- puma (~> 3.11)
- rails (~> 6.0)
+ puma (~> 5.1)
+ rails (~> 6.1)
rails-assets-bulma!
rails-controller-testing
rails-erd
react-rails
- rspec-rails (~> 4.0.0.beta2)
+ redis (~> 4.2)
+ rspec-rails (~> 4.0.1)
rspec_junit_formatter
- rubocop (~> 0.74.0)
- sass-rails (~> 5.0)
+ rubocop (~> 1.6.1)
+ sass-rails (~> 6.0)
selenium-webdriver
sentry-raven
+ sidekiq
simplecov
test-prof
tzinfo-data
@@ -442,10 +503,10 @@ DEPENDENCIES
vcr
web-console (>= 3.3.0)
webmock
- webpacker (~> 4.x)
+ webpacker (~> 5)
RUBY VERSION
- ruby 2.6.3p62
+ ruby 2.6.6p146
BUNDLED WITH
- 2.0.2
+ 2.1.4
diff --git a/README.md b/README.md
index 7db80e4c..aa963bc2 100644
--- a/README.md
+++ b/README.md
@@ -19,6 +19,34 @@ Things you may want to cover:
* Services (job queues, cache servers, search engines, etc.)
-* Deployment instructions
-
* ...
+
+## Development instructions
+
+1. Install docker: [docker engine install](https://docs.docker.com/engine/install/ "docker engine install")
+
+
+2. Install docker-compose: [docker compose install](https://docs.docker.com/compose/install/ "docker compose install")
+
+
+3. Clone the project: https://github.com/cybergizer-hq/retrospective
+
+
+4. Create and setup postgres db:
+```
+docker-compose run rake db:create db:setup
+```
+
+
+5. Run the containers:
+```
+docker-compose up -d rails
+```
+
+run Rails console if needed:
+```
+docker-compose run runner
+```
+
+6. In order to skip Alfred login and login with the first seed user
+ put `SKIP_ALFRED=true` in your .env file
diff --git a/app/assets/images/chevron-down.svg b/app/assets/images/chevron-down.svg
new file mode 100644
index 00000000..cff15593
--- /dev/null
+++ b/app/assets/images/chevron-down.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/assets/images/default_avatar.jpg b/app/assets/images/default_avatar.jpg
deleted file mode 100644
index 8f553cee..00000000
Binary files a/app/assets/images/default_avatar.jpg and /dev/null differ
diff --git a/app/assets/images/icon-calendar.png b/app/assets/images/icon-calendar.png
new file mode 100644
index 00000000..c7a041cd
Binary files /dev/null and b/app/assets/images/icon-calendar.png differ
diff --git a/app/assets/images/icon-cards.png b/app/assets/images/icon-cards.png
new file mode 100644
index 00000000..fbb75490
Binary files /dev/null and b/app/assets/images/icon-cards.png differ
diff --git a/app/assets/images/icon-done.svg b/app/assets/images/icon-done.svg
new file mode 100644
index 00000000..9a6f4031
--- /dev/null
+++ b/app/assets/images/icon-done.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/assets/images/icon-members.svg b/app/assets/images/icon-members.svg
new file mode 100644
index 00000000..8efab22d
--- /dev/null
+++ b/app/assets/images/icon-members.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/assets/images/icon-plus.svg b/app/assets/images/icon-plus.svg
new file mode 100644
index 00000000..6b2d6fbf
--- /dev/null
+++ b/app/assets/images/icon-plus.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/assets/images/right-arrow.svg b/app/assets/images/right-arrow.svg
new file mode 100644
index 00000000..f30e9d54
--- /dev/null
+++ b/app/assets/images/right-arrow.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/app/assets/images/setting.svg b/app/assets/images/setting.svg
new file mode 100644
index 00000000..cd21378c
--- /dev/null
+++ b/app/assets/images/setting.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/app/assets/stylesheets/board.scss b/app/assets/stylesheets/board.scss
new file mode 100644
index 00000000..53304c57
--- /dev/null
+++ b/app/assets/stylesheets/board.scss
@@ -0,0 +1,97 @@
+// board's style
+
+// .board-button{
+// font-weight: 500;
+// cursor: pointer;
+// -webkit-user-select: none;
+// -moz-user-select: none;
+// -ms-user-select: none;
+// user-select: none;
+// border: 1px solid;
+// border-radius: 6px;
+// -webkit-appearance: none;
+// -moz-appearance: none;
+// color: var(--color-text-primary);
+// background-color: var(--color-btn-bg);
+// border-color: var(--color-btn-border);
+// padding: 3px 12px;
+// font-size: 12px;
+// line-height: 20px;
+// margin: 5px;
+// }
+
+// .board-title{
+// font-size: 30px;
+// font-weight: 400;
+// }
+
+// .link-as-button{
+// display: block;
+// }
+
+// .board-button.is-success{
+// background-color: #23d160;
+// }
+
+// .cyber-logo-link:hover{
+// text-decoration: underline;
+// color: #0366d6;
+// }
+
+
+// .board-subtitle {
+// font-size: 14px!important;
+// font-weight: 600;
+// padding-bottom: 10px;
+// }
+
+// .board-users {
+// float: left;
+// padding-right: 10px;
+// margin-top: 13px;
+// }
+
+// .outer-circle {
+// background: #385a94;
+// border-radius: 50%;
+// height: 50px;
+// width: 50px;
+// margin-left: 10px;
+// }
+
+// .is-success {
+// background-color: #23d160;
+// color: #fff;
+// }
+
+// .is-info {
+// background-color: #209cee;
+// color: #fff;
+// }
+
+// .board-column-title {
+// font-size: 14px!important;
+// font-weight: 600;
+// padding-bottom: 10px;
+// height: 30px;
+// }
+
+// .float_left {
+// float: left;
+// }
+
+// .float_right {
+// float: right;
+// }
+
+// .card-buttons {
+// padding-top: 5px;
+// width: 100%;
+// height: 40px;
+// }
+
+
+
+
+
+
diff --git a/app/assets/stylesheets/common.scss b/app/assets/stylesheets/common.scss
new file mode 100644
index 00000000..c750bf02
--- /dev/null
+++ b/app/assets/stylesheets/common.scss
@@ -0,0 +1,183 @@
+/*
+* Prefixed by https://autoprefixer.github.io
+* PostCSS: v7.0.29,
+* Autoprefixer: v9.7.6
+* Browsers: last 4 version
+*/
+
+.animated-board {
+ max-height: 0;
+ opacity: 0;
+ -webkit-transition: 0.6s;
+ -o-transition: 0.6s;
+ transition: 0.6s;
+ -webkit-transform: translate(0, -100%);
+ -ms-transform: translate(0, -100%);
+ transform: translate(0, -100%);
+ visibility: hidden;
+ &.active {
+ opacity: 1;
+ -webkit-transform: translate(0, 0);
+ -ms-transform: translate(0, 0);
+ transform: translate(0, 0);
+ max-height: none;
+ visibility: visible;
+ }
+}
+
+.board-card {
+ padding: 1.25rem;
+ min-height: 215px;
+ background: #ffffff;
+ -webkit-box-shadow: 0px 0px 40px rgba(0, 0, 0, 0.25);
+ box-shadow: 0px 0px 40px rgba(0, 0, 0, 0.25);
+ position: relative;
+ .board-title {
+ font-family: Roboto;
+ font-style: normal;
+ font-weight: bold;
+ font-size: 1.125rem;
+ text-decoration: none;
+ a {
+ color: black;
+ }
+ }
+ .board-stats-table {
+ font-family: Roboto;
+ font-style: normal;
+ font-weight: normal;
+ margin-top: 20px;
+ display: -ms-grid;
+ display: grid;
+ -ms-grid-columns: 20px 10px 1fr;
+ grid-template-columns: 20px 1fr;
+ -ms-grid-rows: 30px 0px 30px 0px 30px;
+ grid-template-rows: 30px 30px 30px;
+ grid-gap: 0px 10px;
+ .icon-container {
+ text-align: right;
+ img {
+ padding: 0;
+ }
+ }
+ }
+ .board-stats-table > *:nth-child(1) {
+ -ms-grid-row: 1;
+ -ms-grid-column: 1;
+ }
+ .board-stats-table > *:nth-child(2) {
+ -ms-grid-row: 1;
+ -ms-grid-column: 3;
+ }
+ .board-stats-table > *:nth-child(3) {
+ -ms-grid-row: 3;
+ -ms-grid-column: 1;
+ }
+ .board-stats-table > *:nth-child(4) {
+ -ms-grid-row: 3;
+ -ms-grid-column: 3;
+ }
+ .board-stats-table > *:nth-child(5) {
+ -ms-grid-row: 5;
+ -ms-grid-column: 1;
+ }
+ .board-stats-table > *:nth-child(6) {
+ -ms-grid-row: 5;
+ -ms-grid-column: 3;
+ }
+ .button-grid {
+ position: absolute;
+ right: 0;
+ bottom: 0;
+ display: -ms-grid;
+ display: grid;
+ -ms-grid-columns: 90px 5px 90px;
+ grid-template-columns: 90px 90px;
+ grid-gap: 5px;
+ .board-button {
+ height: 20px;
+ background-color: #c4c4c4;
+ font-family: Roboto;
+ font-style: normal;
+ font-weight: normal;
+ padding: 10px 30px;
+ &:last-child {
+ -ms-grid-column: 2;
+ grid-column: 2;
+ }
+ }
+ }
+}
+
+.board-date {
+ font-family: Roboto;
+ font-style: normal;
+ font-weight: normal;
+ font-size: 1.5rem;
+ margin-bottom: 40px;
+ &.animated-board:not(.active) {
+ margin-bottom: 0px;
+ }
+}
+
+.dropdown_boards_index {
+ position: relative;
+ display: inline-block;
+}
+
+.dropdown-content_boards_index {
+ padding: 10px;
+ display: none;
+ position: absolute;
+ background-color: #f1f1f1;
+ -webkit-box-shadow: 0px 8px 16px 0px rgba(0, 0, 0, 0.2);
+ box-shadow: 0px 8px 16px 0px rgba(0, 0, 0, 0.2);
+ z-index: 1;
+}
+
+.dropdown_boards_index:hover .dropdown-content_boards_index {
+ display: block;
+}
+
+.inline-icon {
+ display: inline-block;
+ padding-right: 10px;
+}
+
+.load-more-boards-container {
+ margin-top: 90px;
+ margin-left: auto;
+ margin-right: auto;
+ text-align: center;
+ z-index: 100;
+ position: relative;
+ .load-more-boards-button {
+ width: 71.5px;
+ height: auto;
+ -webkit-transition: -webkit-transform 0.45s;
+ transition: -webkit-transform 0.45s;
+ -o-transition: transform 0.45s;
+ transition: transform 0.45s;
+ transition: transform 0.45s, -webkit-transform 0.45s;
+ &.clicked {
+ -webkit-transform: rotate(180deg);
+ -ms-transform: rotate(180deg);
+ transform: rotate(180deg);
+ }
+ }
+}
+
+.new_board_button {
+ max-width: 180px;
+ height: 40px;
+ margin-top: 40px;
+ background: #92b5fa;
+ font-size: 16px;
+ display: -webkit-box;
+ display: -ms-flexbox;
+ display: flex;
+ -webkit-box-align: center;
+ -ms-flex-align: center;
+ align-items: center;
+ padding-right: 10px;
+}
diff --git a/app/assets/stylesheets/users.scss b/app/assets/stylesheets/users.scss
new file mode 100644
index 00000000..cd793241
--- /dev/null
+++ b/app/assets/stylesheets/users.scss
@@ -0,0 +1,18 @@
+// Place all the styles related to the Users controller here.
+// They will automatically be included in application.css.
+// You can use Sass (SCSS) here: http://sass-lang.com/
+
+// .avatar-upload > input {
+// display: none;
+// }
+// .image-upload > label {
+// cursor:pointer;
+// }
+
+// .delete-button {
+// display: none;
+// }
+
+// .avatar-delete > label {
+// cursor: pointer;
+// }
diff --git a/app/channels/action_items_channel.rb b/app/channels/action_items_channel.rb
new file mode 100644
index 00000000..b89de20c
--- /dev/null
+++ b/app/channels/action_items_channel.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+class ActionItemsChannel < ApplicationCable::Channel
+ def subscribed
+ params.deep_transform_keys!(&:underscore)
+ board = Board.find_by(slug: params[:board_slug])
+
+ return reject unless board
+
+ stream_for board
+ end
+
+ def unsubscribed
+ stop_all_streams
+ end
+end
diff --git a/app/channels/application_cable/connection.rb b/app/channels/application_cable/connection.rb
index 8d6c2a1b..630f7658 100644
--- a/app/channels/application_cable/connection.rb
+++ b/app/channels/application_cable/connection.rb
@@ -2,5 +2,22 @@
module ApplicationCable
class Connection < ActionCable::Connection::Base
+ identified_by :current_user
+
+ def connect
+ self.current_user = find_verified_user
+ logger.add_tags 'ActionCable', current_user.id
+ end
+
+ protected
+
+ def find_verified_user
+ verified_user = User.find_by(id: cookies.signed['user.id'])
+ if verified_user && cookies.signed['user.expires_at'] > Time.now
+ verified_user
+ else
+ reject_unauthorized_connection
+ end
+ end
end
end
diff --git a/app/channels/cards_channel.rb b/app/channels/cards_channel.rb
new file mode 100644
index 00000000..465fba65
--- /dev/null
+++ b/app/channels/cards_channel.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+class CardsChannel < ApplicationCable::Channel
+ def subscribed
+ params.deep_transform_keys!(&:underscore)
+ board = Board.find_by(slug: params[:board_slug])
+
+ return reject unless board
+
+ stream_for board
+ end
+
+ def unsubscribed
+ stop_all_streams
+ end
+end
diff --git a/app/channels/comments_channel.rb b/app/channels/comments_channel.rb
new file mode 100644
index 00000000..a546ec0a
--- /dev/null
+++ b/app/channels/comments_channel.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+class CommentsChannel < ApplicationCable::Channel
+ def subscribed
+ params.deep_transform_keys!(&:underscore)
+ board = Board.find_by(slug: params[:board_slug])
+
+ return reject unless board
+
+ stream_for board
+ end
+
+ def unsubscribed
+ stop_all_streams
+ end
+end
diff --git a/app/channels/graphql_channel.rb b/app/channels/graphql_channel.rb
new file mode 100644
index 00000000..500bcf1c
--- /dev/null
+++ b/app/channels/graphql_channel.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+class GraphqlChannel < ApplicationCable::Channel
+ def subscribed
+ @subscription_ids = []
+ end
+
+ def execute(data)
+ result = execute_query(data)
+
+ payload = {
+ result: result.subscription? ? { data: nil } : result.to_h,
+ more: result.subscription?
+ }
+
+ @subscription_ids << context[:subscription_id] if result.context[:subscription_id]
+
+ transmit(payload)
+ end
+
+ def unsubscribed
+ @subscription_ids.each { |sid| RetrospectiveSchema.subscriptions.delete_subscription(sid) }
+ end
+
+ private
+
+ def execute_query(data)
+ RetrospectiveSchema.execute(
+ query: data['query'],
+ context: context,
+ variables: data['variables'],
+ operation_name: data['operationName']
+ )
+ end
+
+ def context
+ {
+ current_user_id: current_user&.id,
+ current_user: current_user,
+ channel: self
+ }
+ end
+end
diff --git a/app/channels/memberships_channel.rb b/app/channels/memberships_channel.rb
new file mode 100644
index 00000000..25300165
--- /dev/null
+++ b/app/channels/memberships_channel.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+class MembershipsChannel < ApplicationCable::Channel
+ def subscribed
+ params.deep_transform_keys!(&:underscore)
+ board = Board.find_by(slug: params[:board_slug])
+
+ return reject unless board
+
+ stream_for board
+ end
+
+ def unsubscribed
+ stop_all_streams
+ end
+end
diff --git a/app/controllers/action_items_controller.rb b/app/controllers/action_items_controller.rb
index 936f77e3..478307cd 100644
--- a/app/controllers/action_items_controller.rb
+++ b/app/controllers/action_items_controller.rb
@@ -9,12 +9,14 @@ class ActionItemsController < ApplicationController
skip_verify_authorized only: :index
- rescue_from ActionPolicy::Unauthorized do |ex|
- redirect_to action_items_path, alert: ex.result.message
- end
-
def index
- @action_items = ActionItem.eager_load(:board).order(created_at: :asc)
+ @action_items = user_action_items.where(status: 'pending')
+ .eager_load(:board)
+ .order(times_moved: :desc, created_at: :asc)
+
+ @action_items_resolved = user_action_items.where.not(status: 'pending')
+ .eager_load(:board)
+ .order(updated_at: :desc)
end
def close
@@ -35,9 +37,15 @@ def complete
def reopen
if @action_item.reopen!
- redirect_to action_items_path, notice: 'Action Item was successfully reopend'
+ redirect_to action_items_path, notice: 'Action Item was successfully reopened'
else
redirect_to action_items_path, alert: @action_item.errors.full_messages.join(', ')
end
end
+
+ private
+
+ def user_action_items
+ current_user&.action_items
+ end
end
diff --git a/app/controllers/api/action_items_controller.rb b/app/controllers/api/action_items_controller.rb
deleted file mode 100644
index 3c55ac20..00000000
--- a/app/controllers/api/action_items_controller.rb
+++ /dev/null
@@ -1,68 +0,0 @@
-# frozen_string_literal: true
-
-module API
- class ActionItemsController < API::ApplicationController
- before_action :set_board, :set_action_item
- before_action do
- authorize! @action_item, context: { board: @board }
- end
-
- def update
- if @action_item.update(body: params.permit(:edited_body)[:edited_body])
- render json: { updated_body: @action_item.body }, status: :ok
- else
- render json: { error: @action_item.errors }, status: :bad_request
- end
- end
-
- def destroy
- if @action_item.destroy
- head :no_content
- else
- render json: { error: @action_item.errors.full_messages.join(',') }, status: :bad_request
- end
- end
-
- def move
- if @action_item.move!(@board)
- head :ok
- else
- render json: { error: @action_item.errors.full_messages.join(',') }, status: :bad_request
- end
- end
-
- def close
- if @action_item.close!
- head :ok
- else
- render json: { error: @action_item.errors.full_messages.join(',') }, status: :bad_request
- end
- end
-
- def complete
- if @action_item.complete!
- head :ok
- else
- render json: { error: @action_item.errors.full_messages.join(',') }, status: :bad_request
- end
- end
-
- def reopen
- if @action_item.reopen!
- head :ok
- else
- render json: { error: @action_item.errors.full_messages.join(',') }, status: :bad_request
- end
- end
-
- private
-
- def set_board
- @board = Board.find_by!(slug: params[:board_slug])
- end
-
- def set_action_item
- @action_item = ActionItem.find(params[:id])
- end
- end
-end
diff --git a/app/controllers/api/application_controller.rb b/app/controllers/api/application_controller.rb
deleted file mode 100644
index e8d1963e..00000000
--- a/app/controllers/api/application_controller.rb
+++ /dev/null
@@ -1,19 +0,0 @@
-# frozen_string_literal: true
-
-module API
- class ApplicationController < ActionController::Base
- before_action :authenticate_user!
- authorize :user, through: :current_or_guest_user
- verify_authorized
-
- rescue_from ActionPolicy::Unauthorized do |ex|
- render json: { error: ex.result.message }, status: :unauthorized
- end
-
- private
-
- def current_or_guest_user
- current_user || User.new
- end
- end
-end
diff --git a/app/controllers/api/boards_controller.rb b/app/controllers/api/boards_controller.rb
deleted file mode 100644
index 8a9a61b4..00000000
--- a/app/controllers/api/boards_controller.rb
+++ /dev/null
@@ -1,35 +0,0 @@
-# frozen_string_literal: true
-
-module API
- class BoardsController < API::ApplicationController
- before_action :set_board
- before_action do
- authorize! @board
- end
-
- def invite
- users = Boards::FindUsersToInvite.new(board_params[:email], @board).call
- if users.any?
- result = Boards::InviteUsers.new(@board, users).call
- render json: result.value!, each_serializer: MembershipSerializer
- else
- render json: { error: 'User was not found' }, status: 400
- end
- end
-
- def suggestions
- result = Boards::Suggestions.new(params[:autocomplete]).call
- render json: result
- end
-
- private
-
- def board_params
- params.require(:board).permit(:email)
- end
-
- def set_board
- @board = Board.find_by!(slug: params[:slug])
- end
- end
-end
diff --git a/app/controllers/api/cards_controller.rb b/app/controllers/api/cards_controller.rb
deleted file mode 100644
index 461efbb7..00000000
--- a/app/controllers/api/cards_controller.rb
+++ /dev/null
@@ -1,44 +0,0 @@
-# frozen_string_literal: true
-
-module API
- class CardsController < API::ApplicationController
- before_action :set_board, :set_card
- before_action do
- authorize! @card
- end
-
- def update
- if @card.update(body: params.permit(:edited_body)[:edited_body])
- render json: { updated_body: @card.body }, status: :ok
- else
- render json: { error: @card.errors }, status: :bad_request
- end
- end
-
- def destroy
- if @card.destroy
- head :no_content
- else
- render json: { error: @card.errors.full_messages.join(',') }, status: :bad_request
- end
- end
-
- def like
- if @card.like!
- render json: { likes: @card.likes }, status: :ok
- else
- render json: { error: @card.errors.full_messages.join(',') }, status: :bad_request
- end
- end
-
- private
-
- def set_board
- @board = Board.find_by!(slug: params[:board_slug])
- end
-
- def set_card
- @card = Card.find(params[:id])
- end
- end
-end
diff --git a/app/controllers/api/memberships_controller.rb b/app/controllers/api/memberships_controller.rb
deleted file mode 100644
index ef1aa9dd..00000000
--- a/app/controllers/api/memberships_controller.rb
+++ /dev/null
@@ -1,54 +0,0 @@
-# frozen_string_literal: true
-
-module API
- class MembershipsController < API::ApplicationController
- before_action :set_board, :set_membership
- authorize :membership, through: :current_membership
-
- before_action except: :destroy do
- authorize!
- end
-
- def index
- members = @board.memberships
- render json: members, each_serializer: MembershipSerializer
- end
-
- def destroy
- member = Membership.find(params[:id])
- authorize! member
- if member.destroy
- head :no_content
- else
- render json: { error: member.errors }, status: :bad_request
- end
- end
-
- def ready_status
- render json: @membership.ready
- end
-
- def ready_toggle
- @membership.update(ready: !@membership.ready)
- render json: @membership.ready
- end
-
- private
-
- def membership_params
- params.require(:membership).permit(:email)
- end
-
- def set_board
- @board = Board.find_by!(slug: params[:board_slug])
- end
-
- def set_membership
- @membership = Membership.find_by(board_id: @board.id, user_id: current_user.id)
- end
-
- def current_membership
- @membership
- end
- end
-end
diff --git a/app/controllers/api/v1/action_items_controller.rb b/app/controllers/api/v1/action_items_controller.rb
new file mode 100644
index 00000000..3d8ed6f3
--- /dev/null
+++ b/app/controllers/api/v1/action_items_controller.rb
@@ -0,0 +1,112 @@
+# frozen_string_literal: true
+
+module API
+ module V1
+ class ActionItemsController < API::V1::BaseController
+ before_action only: %i[create update] do
+ params.deep_transform_keys!(&:underscore)
+ end
+
+ before_action except: %i[update destroy] do
+ @board = Board.find_by!(slug: params[:board_slug])
+ end
+
+ before_action except: :create do
+ @action_item = ActionItem.find(params[:id])
+ end
+
+ # app/graphql/mutations/add_action_item_mutation.rb
+ def create
+ action_item = ActionItem.new(action_item_params.merge!(board: @board,
+ author: current_user))
+
+ authorize! action_item, context: { user: current_user, board: @board }
+
+ if action_item.save
+ prepare_and_make_response(action_item, @board)
+ else
+ render_json_error(action_item.errors.full_messages)
+ end
+ end
+
+ # app/graphql/mutations/update_action_item_mutation.rb
+ def update
+ authorize! @action_item, context: { user: current_user, board: @action_item.board }
+
+ if @action_item.update(action_item_params)
+ prepare_and_make_response(@action_item, @action_item.board)
+ else
+ render_json_error(@action_item.errors.full_messages)
+ end
+ end
+
+ # app/graphql/mutations/destroy_action_item_mutation.rb
+ def destroy
+ authorize! @action_item, context: { user: current_user, board: @action_item.board }
+
+ if @action_item.destroy
+ prepare_and_make_response(@action_item, @action_item.board)
+ else
+ render_json_error(@action_item.errors.full_messages)
+ end
+ end
+
+ # app/graphql/mutations/close_action_item_mutation.rb
+ def close
+ authorize! @action_item, context: { user: current_user, board: @board }
+
+ if @action_item.close!
+ prepare_and_make_response(@action_item, @board)
+ else
+ render_json_error(@action_item.errors.full_messages)
+ end
+ end
+
+ # app/graphql/mutations/complete_action_item_mutation.rb
+ def complete
+ authorize! @action_item, context: { user: current_user, board: @board }
+
+ if @action_item.complete!
+ prepare_and_make_response(@action_item, @board)
+ else
+ render_json_error(@action_item.errors.full_messages)
+ end
+ end
+
+ # app/graphql/mutations/reopen_action_item_mutation.rb
+ def reopen
+ authorize! @action_item, context: { user: current_user, board: @board }
+
+ if @action_item.reopen!
+ prepare_and_make_response(@action_item, @board)
+ else
+ render_json_error(@action_item.errors.full_messages)
+ end
+ end
+
+ # app/graphql/mutations/move_action_item_mutation.rb
+ def move
+ authorize! @action_item, context: { user: current_user, board: @board }
+
+ if @action_item.move!(@board)
+ prepare_and_make_response(@action_item, @board)
+ else
+ render_json_error(@action_item.errors.full_messages)
+ end
+ end
+
+ private
+
+ def action_item_params
+ params.permit(:body, :assignee_id)
+ end
+
+ def prepare_and_make_response(action_item, board)
+ payload = serialize_resource(action_item)
+
+ ActionItemsChannel.broadcast_to(board, payload)
+ render json: payload
+ end
+ end
+ end
+end
diff --git a/app/controllers/api/v1/base_controller.rb b/app/controllers/api/v1/base_controller.rb
new file mode 100644
index 00000000..974c6a43
--- /dev/null
+++ b/app/controllers/api/v1/base_controller.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+module API
+ module V1
+ class BaseController < ApplicationController
+ respond_to :json
+
+ rescue_from ActiveRecord::RecordNotFound, with: :not_found
+
+ def serialize_resource(resource, adapter = :json, key_transform = :camel_lower)
+ {
+ data:
+ ActiveModelSerializers::SerializableResource.new(
+ resource, adapter: adapter, key_transform: key_transform
+ ).as_json
+ }.to_json
+ end
+
+ def render_json_error(message, status = :unprocessable_entity)
+ render json: { errors: { fullMessages: message } },
+ status: status
+ end
+
+ private
+
+ def not_found
+ render json: {
+ errors: {
+ status: '404',
+ fullMessages: 'Not Found'
+ }
+ }, status: :not_found
+ end
+ end
+ end
+end
diff --git a/app/controllers/api/v1/boards_controller.rb b/app/controllers/api/v1/boards_controller.rb
new file mode 100644
index 00000000..d9411187
--- /dev/null
+++ b/app/controllers/api/v1/boards_controller.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module API
+ module V1
+ class BoardsController < API::V1::BaseController
+ # app/graphql/queries/boards.rb
+ def index
+ authorize!
+
+ boards = Board.includes(:cards).limit(10)
+
+ render json: serialize_resource(boards)
+ end
+
+ # app/graphql/queries/board.rb
+ def show
+ board = Board.find(params[:id])
+
+ authorize! board
+
+ render json: serialize_resource(board)
+ end
+ end
+ end
+end
diff --git a/app/controllers/api/v1/cards_controller.rb b/app/controllers/api/v1/cards_controller.rb
new file mode 100644
index 00000000..286a5443
--- /dev/null
+++ b/app/controllers/api/v1/cards_controller.rb
@@ -0,0 +1,69 @@
+# frozen_string_literal: true
+
+module API
+ module V1
+ class CardsController < API::V1::BaseController
+ before_action only: %i[create update] do
+ params.deep_transform_keys!(&:underscore)
+ end
+
+ before_action except: :create do
+ @card = Card.find(params[:id])
+ authorize! @card
+ end
+
+ # app/graphql/mutations/add_card_mutation.rb
+ def create
+ board = Board.find_by!(slug: params[:board_slug])
+ authorize! board, to: :create_cards?
+ card = Card.new(card_params.merge!(board: board, author: current_user))
+
+ if card.save
+ prepare_and_make_response(card)
+ else
+ render_json_error(card.errors.full_messages)
+ end
+ end
+
+ # app/graphql/mutations/update_card_mutation.rb
+ def update
+ if @card.update(card_params)
+ prepare_and_make_response(@card)
+ else
+ render_json_error(@card.errors.full_messages)
+ end
+ end
+
+ # app/graphql/mutations/destroy_card_mutation.rb
+ def destroy
+ if @card.destroy
+ prepare_and_make_response(@card)
+ else
+ render_json_error(@card.errors.full_messages)
+ end
+ end
+
+ # app/graphql/mutations/like_card_mutation.rb
+ def like
+ if @card.like!
+ prepare_and_make_response(@card)
+ else
+ render_json_error(@card.errors.full_messages)
+ end
+ end
+
+ private
+
+ def card_params
+ params.permit(:kind, :body)
+ end
+
+ def prepare_and_make_response(card)
+ payload = serialize_resource(card)
+
+ CardsChannel.broadcast_to(card.board, payload)
+ render json: payload
+ end
+ end
+ end
+end
diff --git a/app/controllers/api/v1/comments_controller.rb b/app/controllers/api/v1/comments_controller.rb
new file mode 100644
index 00000000..3cd565cf
--- /dev/null
+++ b/app/controllers/api/v1/comments_controller.rb
@@ -0,0 +1,69 @@
+# frozen_string_literal: true
+
+module API
+ module V1
+ class CommentsController < API::V1::BaseController
+ before_action only: %i[create update] do
+ params.deep_transform_keys!(&:underscore)
+ end
+
+ before_action except: :create do
+ @comment = Comment.find(params[:id])
+ authorize! @comment
+ end
+
+ # app/graphql/mutations/add_comment_mutation.rb
+ def create
+ board = Board.find_by!(params[:id])
+ authorize! board, to: :create_comments?
+ comment = Comment.new(comment_params.merge!(author: current_user))
+
+ if comment.save
+ prepare_and_make_response(comment)
+ else
+ render_json_error(@card.errors.full_messages)
+ end
+ end
+
+ # app/graphql/mutations/update_comment_mutation.rb
+ def update
+ if @comment.update(comment_params)
+ prepare_and_make_response(@comment)
+ else
+ render_json_error(@card.errors.full_messages)
+ end
+ end
+
+ # app/graphql/mutations/destroy_comment_mutation.rb
+ def destroy
+ if @comment.destroy
+ prepare_and_make_response(@comment)
+ else
+ render_json_error(@card.errors.full_messages)
+ end
+ end
+
+ # app/graphql/mutations/like_comment_mutation.rb
+ def like
+ if @comment.like!
+ prepare_and_make_response(@comment)
+ else
+ render_json_error(@card.errors.full_messages)
+ end
+ end
+
+ private
+
+ def comment_params
+ params.permit(:card_id, :content)
+ end
+
+ def prepare_and_make_response(comment)
+ payload = serialize_resource(comment)
+
+ CommentsChannel.broadcast_to(comment.card.board, payload)
+ render json: payload
+ end
+ end
+ end
+end
diff --git a/app/controllers/api/v1/memberships_controller.rb b/app/controllers/api/v1/memberships_controller.rb
new file mode 100644
index 00000000..0cddd234
--- /dev/null
+++ b/app/controllers/api/v1/memberships_controller.rb
@@ -0,0 +1,88 @@
+# frozen_string_literal: true
+
+module API
+ module V1
+ class MembershipsController < API::V1::BaseController
+ before_action only: %i[index current create] do
+ @board = Board.find_by!(slug: params[:board_slug])
+ end
+
+ before_action only: %i[index current] do
+ @current_membership = Membership.find_by!(user_id: current_user.id, board_id: @board.id)
+ end
+
+ before_action only: %i[destroy toggle_ready_status] do
+ @membership = Membership.find(params[:id])
+ end
+
+ skip_verify_authorized only: %i[index current]
+
+ # app/graphql/queries/memberships.rb
+ def index
+ memberships = @board.memberships.includes(:user)
+ render json: serialize_resource(memberships)
+ end
+
+ # app/graphql/queries/membership.rb
+ def current
+ membership = @board.memberships.find_by(user: current_user)
+ render json: serialize_resource(membership)
+ end
+
+ # app/graphql/mutations/invite_members_mutation.rb
+ def create
+ authorize! @board, to: :invite?, context: { user: current_user }
+
+ users = Boards::FindUsersToInvite.new(params[:email], @board).call
+
+ if users.any?
+ result = Boards::InviteUsers.new(@board, users).call
+ prepare_and_make_response(result.value!, @board)
+ else
+ render_json_error('User was not found', :not_found)
+ end
+ end
+
+ # app/graphql/mutations/destroy_membership_mutation.rb
+ # rubocop:disable Metrics/MethodLength
+ def destroy
+ authorize! @membership,
+ context: { membership: Membership.find_by(user: current_user,
+ board: @membership.board) }
+
+ @membership.transaction do
+ @membership.board.board_permissions_users.where(user: @membership.user).destroy_all
+ @membership.destroy
+ end
+
+ if !@membership.persisted?
+ prepare_and_make_response(@membership, @membership.board)
+ else
+ render_json_error(@membership.errors.full_messages)
+ end
+ end
+ # rubocop:enable Metrics/MethodLength
+
+ # app/graphql/mutations/toggle_ready_status_mutation.rb
+ def toggle_ready_status
+ authorize! @membership, to: :ready_toggle?,
+ context: { membership: @membership }
+
+ if @membership.update(ready: !@membership.ready)
+ prepare_and_make_response(@membership, @membership.board)
+ else
+ render_json_error(@membership.errors.full_messages)
+ end
+ end
+
+ private
+
+ def prepare_and_make_response(membership, board)
+ payload = serialize_resource(membership)
+
+ MembershipsChannel.broadcast_to(board, payload)
+ render json: payload
+ end
+ end
+ end
+end
diff --git a/app/controllers/api/v1/users_controller.rb b/app/controllers/api/v1/users_controller.rb
new file mode 100644
index 00000000..e3fb84ed
--- /dev/null
+++ b/app/controllers/api/v1/users_controller.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module API
+ module V1
+ class UsersController < API::V1::BaseController
+ skip_verify_authorized only: :suggestions
+
+ # app/graphql/queries/suggestions.rb
+ def suggestions
+ autocomplete = params[:autocomplete]
+ @suggestions = Boards::Suggestions.new(autocomplete).call
+
+ render json: {
+ data: {
+ suggestions: @suggestions
+ }
+ }
+ end
+ end
+ end
+end
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 5e5e09c6..8e001ec7 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -2,10 +2,15 @@
class ApplicationController < ActionController::Base
before_action :set_raven_context
+ before_action :store_user_location!, if: :storable_location?
before_action :authenticate_user!, except: %i[sign_in sign_up]
authorize :user, through: :current_or_guest_user
verify_authorized unless: [:devise_controller?]
+ rescue_from ActionPolicy::Unauthorized do |ex|
+ redirect_to root_path, alert: ex.result.message
+ end
+
private
def set_raven_context
@@ -16,4 +21,18 @@ def set_raven_context
def current_or_guest_user
current_user || User.new
end
+
+ # Its important that the location is NOT stored if:
+ # - The request method is not GET (non idempotent)
+ # - The request is handled by a Devise controller such as Devise::SessionsController
+ # as that could cause an infinite redirect loop.
+ # - The request is an Ajax request as this can lead to very unexpected behaviour.
+ def storable_location?
+ request.get? && is_navigational_format? && !devise_controller? && !request.xhr?
+ end
+
+ def store_user_location!
+ # :user is the scope we are authenticating
+ store_location_for(:user, request.fullpath)
+ end
end
diff --git a/app/controllers/boards/action_items_controller.rb b/app/controllers/boards/action_items_controller.rb
deleted file mode 100644
index 20f1e694..00000000
--- a/app/controllers/boards/action_items_controller.rb
+++ /dev/null
@@ -1,27 +0,0 @@
-# frozen_string_literal: true
-
-module Boards
- class ActionItemsController < Boards::ApplicationController
- authorize :board, through: :current_board
-
- def create
- action_item = @board.action_items.build(action_item_params)
- authorize! action_item
- if action_item.save
- redirect_to @board, notice: 'Action Item was successfully saved'
- else
- redirect_to @board, alert: action_item.errors.full_messages.join(', ')
- end
- end
-
- private
-
- def current_board
- @board
- end
-
- def action_item_params
- params.require(:action_item).permit(:status, :body)
- end
- end
-end
diff --git a/app/controllers/boards/application_controller.rb b/app/controllers/boards/application_controller.rb
deleted file mode 100644
index 2d32f90b..00000000
--- a/app/controllers/boards/application_controller.rb
+++ /dev/null
@@ -1,17 +0,0 @@
-# frozen_string_literal: true
-
-module Boards
- class ApplicationController < ::ApplicationController
- before_action :set_board
-
- rescue_from ActionPolicy::Unauthorized do |ex|
- redirect_to @board, alert: ex.result.message
- end
-
- private
-
- def set_board
- @board = Board.find_by!(slug: params[:board_slug])
- end
- end
-end
diff --git a/app/controllers/boards/cards_controller.rb b/app/controllers/boards/cards_controller.rb
deleted file mode 100644
index 35e47935..00000000
--- a/app/controllers/boards/cards_controller.rb
+++ /dev/null
@@ -1,18 +0,0 @@
-# frozen_string_literal: true
-
-module Boards
- class CardsController < Boards::ApplicationController
- def create
- card = @board.cards.build(card_params.merge(author_id: current_user.id))
- authorize! card
- card.save!
- redirect_to @board
- end
-
- private
-
- def card_params
- params.require(:card).permit(:kind, :body)
- end
- end
-end
diff --git a/app/controllers/boards/memberships_controller.rb b/app/controllers/boards/memberships_controller.rb
deleted file mode 100644
index c9b2e226..00000000
--- a/app/controllers/boards/memberships_controller.rb
+++ /dev/null
@@ -1,16 +0,0 @@
-# frozen_string_literal: true
-
-module Boards
- class MembershipsController < Boards::ApplicationController
- skip_verify_authorized
-
- def create
- @membership = @board.memberships.build(user_id: current_user.id, role: :member)
- if @membership.save
- redirect_to @board, notice: "#{current_user.email.split('@').first} has joined the board!"
- else
- redirect_to @board, alert: @membership.errors.full_messages.join(', ')
- end
- end
- end
-end
diff --git a/app/controllers/boards_controller.rb b/app/controllers/boards_controller.rb
index a054a0c4..56b1bedb 100644
--- a/app/controllers/boards_controller.rb
+++ b/app/controllers/boards_controller.rb
@@ -1,34 +1,53 @@
# frozen_string_literal: true
class BoardsController < ApplicationController
- before_action :set_board, only: %i[show continue edit update destroy]
+ layout 'board', only: :show
+
+ before_action :set_board, only: %i[show continue edit update destroy history]
skip_before_action :authenticate_user!, only: :show
skip_verify_authorized only: :show
- rescue_from ActionPolicy::Unauthorized do |ex|
- redirect_to boards_path, alert: ex.result.message
+ def my
+ authorize!
+
+ @boards_by_date = boards_by_role(:creator)
end
- def index
+ def participating
authorize!
- @boards = current_user.boards.order(created_at: :desc)
+
+ @boards_by_date = boards_by_role(:member)
end
- # rubocop: disable Metrics/AbcSize
+ def history
+ authorize!
+
+ boards = Boards::GetHistoryOfBoard.new(@board.id).call
+ @boards_by_date = boards.order(created_at: :desc)
+ .group_by { |record| record.created_at.strftime('%B, %Y') }
+ end
+
+ # rubocop:disable Metrics/AbcSize
+ # rubocop:disable Metrics/MethodLength
+ # rubocop:disable Metrics/LineLength
def show
- @cards_by_type = {
- mad: @board.cards.mad.includes(:author),
- sad: @board.cards.sad.includes(:author),
- glad: @board.cards.glad.includes(:author)
- }
- @action_items = @board.action_items
+ authorize! @board
+ @cards_by_type = @board.column_names.map do |column|
+ [[column, ActiveModelSerializers::SerializableResource.new(@board.cards.where(kind: column)
+ .includes(:author, comments: [:author]).order(created_at: :desc)).as_json]].to_h
+ end.reduce({}, :merge).as_json
+ @action_items = ActiveModelSerializers::SerializableResource.new(@board.action_items.order(created_at: :desc)).as_json
@action_item = ActionItem.new(board_id: @board.id)
-
+ @board_creators = @board.memberships.where(role: 'creator').pluck(:user_id)
@previous_action_items = if @board.previous_board&.action_items&.any?
- @board.previous_board.action_items
+ ActiveModelSerializers::SerializableResource.new(@board.previous_board.action_items).as_json
end
+ @users = ActiveModelSerializers::SerializableResource.new(User.find(@board.memberships.pluck(:user_id))).as_json
+ # @data = RetrospectiveSchema.execute(INITIAL__QUERY)
end
- # rubocop: enable Metrics/AbcSize
+ # rubocop:enable Metrics/LineLength
+ # rubocop:enable Metrics/MethodLength
+ # rubocop:enable Metrics/AbcSize
def new
authorize!
@@ -44,17 +63,24 @@ def create
@board = Board.new(board_params)
@board.memberships.build(user_id: current_user.id, role: 'creator')
- if @board.save
+ result = Boards::BuildPermissions.new(@board, current_user).call(identifiers_scope: 'creator')
+
+ if result.success? && @board.save
redirect_to @board, notice: 'Board was successfully created.'
else
+ flash[:alert] = result.failure
render :new
end
end
def update
authorize! @board
+ old_column_names = @board.column_names
if @board.update(board_params)
- redirect_to boards_path, notice: 'Board was successfully updated.'
+ result = Boards::RenameColumns.new(@board).call(old_column_names, @board.column_names)
+ if result.success?
+ redirect_to edit_board_path(@board), notice: 'Board was successfully updated.'
+ end
else
render :edit
end
@@ -63,9 +89,9 @@ def update
def destroy
authorize! @board
if @board.destroy
- redirect_to boards_path, notice: 'Board was successfully deleted.'
+ redirect_to my_boards_path, notice: 'Board was successfully deleted.'
else
- redirect_to boards_path, alert: @board.errors.full_messages.join(', ')
+ redirect_to my_boards_path, alert: @board.errors.full_messages.join(', ')
end
end
@@ -75,17 +101,25 @@ def continue
if result.success?
redirect_to result.value!, notice: 'Board was successfully created.'
else
- redirect_to boards_path, alert: result.failure
+ redirect_to my_boards_path, alert: result.failure
end
end
private
def board_params
- params.require(:board).permit(:title, :team_id, :email)
+ params.require(:board).permit(:title, :team_id, :email, :private, column_names: [])
end
def set_board
@board = Board.find_by!(slug: params[:slug])
end
+
+ def boards_by_role(role)
+ Board.where.not(id: Board.select(:previous_board_id).where.not(previous_board_id: nil))
+ .joins(:memberships).where(memberships: { user_id: current_user.id, role: role })
+ .includes(:users, :cards, :action_items)
+ .order(created_at: :desc)
+ .group_by { |record| record.created_at.strftime('%B, %Y') }
+ end
end
diff --git a/app/controllers/boardsql_controller.rb b/app/controllers/boardsql_controller.rb
new file mode 100644
index 00000000..0ee804eb
--- /dev/null
+++ b/app/controllers/boardsql_controller.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class BoardsqlController < ApplicationController
+ skip_verify_authorized only: :show
+
+ def show; end
+end
diff --git a/app/controllers/graphql_controller.rb b/app/controllers/graphql_controller.rb
new file mode 100644
index 00000000..e53bfeda
--- /dev/null
+++ b/app/controllers/graphql_controller.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+class GraphqlController < ActionController::Base
+ # If accessing from outside this domain, nullify the session
+ # This allows for outside API access while preventing CSRF attacks,
+ # but you'll have to authenticate your user separately
+ protect_from_forgery with: :null_session, if: proc { |c| c.request.format == 'application/json' }
+
+ # rubocop:disable Metrics/LineLength, Metrics/MethodLength
+ def execute
+ variables = ensure_hash(params[:variables])
+ query = params[:query]
+ operation_name = params[:operationName]
+ context = {
+ current_user: current_user
+ }
+ result = RetrospectiveSchema.execute(query, variables: variables, context: context, operation_name: operation_name)
+ render json: result
+ rescue StandardError => e
+ raise e unless Rails.env.development?
+
+ handle_error_in_development e
+ end
+ # rubocop:enable Metrics/LineLength, Metrics/MethodLength
+
+ private
+
+ # Handle form data, JSON body, or a blank value
+ # rubocop:disable Metrics/MethodLength
+ def ensure_hash(ambiguous_param)
+ case ambiguous_param
+ when String
+ if ambiguous_param.present?
+ ensure_hash(JSON.parse(ambiguous_param))
+ else
+ {}
+ end
+ when Hash, ActionController::Parameters
+ ambiguous_param
+ when nil
+ {}
+ else
+ raise ArgumentError, "Unexpected parameter: #{ambiguous_param}"
+ end
+ end
+ # rubocop:enable Metrics/MethodLength
+
+ # rubocop:disable Metrics/LineLength
+ def handle_error_in_development(err)
+ logger.error err.message
+ logger.error err.backtrace.join("\n")
+
+ render json: { error: { message: err.message, backtrace: err.backtrace }, data: {} }, status: 500
+ end
+ # rubocop:enable Metrics/LineLength
+end
diff --git a/app/controllers/users/omniauth_callbacks_controller.rb b/app/controllers/users/omniauth_callbacks_controller.rb
index 7df82476..880a301f 100644
--- a/app/controllers/users/omniauth_callbacks_controller.rb
+++ b/app/controllers/users/omniauth_callbacks_controller.rb
@@ -2,19 +2,69 @@
module Users
class OmniauthCallbacksController < Devise::OmniauthCallbacksController
+ def alfred
+ @user = User.from_omniauth(auth.provider, auth.uid, auth.info)
+
+ if @user.valid?
+ sign_in_and_redirect @user, event: :authentication
+ set_flash_message(:notice, :success, kind: 'Alfred') if is_navigational_format?
+ else
+ session['devise.alfred_data'] = auth.except('extra')
+ redirect_to new_user_session_path, alert: @user.errors.full_messages.join("\n")
+ end
+ end
+
+ def google
+ @user = User.from_omniauth(auth.provider, auth.uid, auth.info)
+
+ if @user.valid?
+ sign_in_and_redirect @user, event: :authentication
+ set_flash_message(:notice, :success, kind: 'Google') if is_navigational_format?
+ else
+ session['devise.google_data'] = auth.except('extra')
+ redirect_to new_user_session_path, alert: @user.errors.full_messages.join("\n")
+ end
+ end
+
+ def facebook
+ @user = User.from_omniauth(auth.provider, auth.uid, auth.info)
+
+ if @user.valid?
+ sign_in_and_redirect @user, event: :authentication
+ set_flash_message(:notice, :success, kind: 'Facebook') if is_navigational_format?
+ else
+ session['devise.facebook_data'] = auth.except('extra')
+ redirect_to new_user_session_path, alert: @user.errors.full_messages.join("\n")
+ end
+ end
+
def github
- @user = User.from_omniauth(request.env['omniauth.auth'])
- if @user.persisted?
+ @user = User.from_omniauth(auth.provider, auth.uid, auth.info)
+
+ if @user.valid?
sign_in_and_redirect @user, event: :authentication
- set_flash_message(:notice, :success, kind: 'GitHub') if is_navigational_format?
+ set_flash_message(:notice, :success, kind: 'Github') if is_navigational_format?
else
- session['devise.github_data'] = request.env['omniauth.auth']
- redirect_to new_user_registration_url
+ session['devise.github_data'] = auth.except('extra')
+ redirect_to new_user_session_path, alert: @user.errors.full_messages.join("\n")
end
end
+ def developer
+ @user = User.first
+
+ sign_in_and_redirect @user, event: :authentication
+ set_flash_message(:notice, :success, kind: 'Developer') if is_navigational_format?
+ end
+
def failure
- redirect_to root_path
+ redirect_to new_user_session_path
+ end
+
+ private
+
+ def auth
+ request.env['omniauth.auth']
end
end
end
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
new file mode 100644
index 00000000..b581c5cc
--- /dev/null
+++ b/app/controllers/users_controller.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+class UsersController < ApplicationController
+ before_action :set_user
+ before_action do
+ authorize! @user
+ end
+
+ def edit; end
+
+ def update
+ @user.update(user_params)
+ render :edit
+ end
+
+ def avatar_destroy
+ @user.save if @user.remove_avatar!
+ render :edit
+ end
+
+ private
+
+ def set_user
+ @user = User.find(params[:id])
+ end
+
+ def user_params
+ params.require(:user).permit(:nickname, :first_name, :last_name, :avatar)
+ end
+end
diff --git a/app/graphql/mutations/.keep b/app/graphql/mutations/.keep
new file mode 100644
index 00000000..e69de29b
diff --git a/app/graphql/mutations/add_action_item_mutation.rb b/app/graphql/mutations/add_action_item_mutation.rb
new file mode 100644
index 00000000..0deebd7e
--- /dev/null
+++ b/app/graphql/mutations/add_action_item_mutation.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module Mutations
+ class AddActionItemMutation < Mutations::BaseMutation
+ argument :attributes, Types::ActionItemAttributes, required: true
+
+ field :action_item, Types::ActionItemType, null: true
+
+ # rubocop:disable Metrics/MethodLength
+ def resolve(attributes:)
+ params = attributes.to_h
+ board = Board.find_by!(slug: params.delete(:board_slug))
+ action_item = ActionItem.new(item_params(params, board))
+ authorize! action_item, to: :create?, context: { user: context[:current_user], board: board }
+ if action_item.save
+ RetrospectiveSchema.subscriptions.trigger('action_item_added', { board_slug: board.slug },
+ action_item)
+ { action_item: action_item }
+ else
+ { errors: { full_messages: action_item.errors.full_messages } }
+ end
+ end
+ # rubocop:enable Metrics/MethodLength
+
+ def item_params(params, board)
+ params.merge(board: board, author: context[:current_user])
+ end
+ end
+end
diff --git a/app/graphql/mutations/add_card_mutation.rb b/app/graphql/mutations/add_card_mutation.rb
new file mode 100644
index 00000000..8dd17cc6
--- /dev/null
+++ b/app/graphql/mutations/add_card_mutation.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module Mutations
+ class AddCardMutation < Mutations::BaseMutation
+ argument :attributes, Types::CardAttributes, required: true
+
+ field :card, Types::CardType, null: true
+
+ # rubocop:disable Metrics/MethodLength
+ def resolve(attributes:)
+ params = attributes.to_h
+ board = Board.find_by!(slug: params.delete(:board_slug))
+ authorize! board, to: :create_cards?, context: { user: context[:current_user], board: board }
+
+ result = Boards::Cards::Create.new(current_user).call(card_params(params, board))
+
+ if result.success?
+ card = result.value!
+ RetrospectiveSchema.subscriptions.trigger('card_added', { board_slug: board.slug }, card)
+ { card: card }
+ else
+ { errors: { full_messages: result.failure.message } }
+ end
+ end
+ # rubocop:enable Metrics/MethodLength
+
+ def card_params(params, board)
+ params.merge(board: board, author: context[:current_user])
+ end
+ end
+end
diff --git a/app/graphql/mutations/add_comment_mutation.rb b/app/graphql/mutations/add_comment_mutation.rb
new file mode 100644
index 00000000..5be13ec4
--- /dev/null
+++ b/app/graphql/mutations/add_comment_mutation.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+module Mutations
+ class AddCommentMutation < Mutations::BaseMutation
+ argument :attributes, Types::CommentAttributes, required: true
+
+ field :comment, Types::CommentType, null: true
+
+ # rubocop:disable Metrics/MethodLength
+ # rubocop:disable Metrics/AbcSize
+ def resolve(attributes:)
+ params = attributes.to_h
+ card = Card.find(params[:card_id])
+ authorize! card.board, to: :create_comments?, context: { user: context[:current_user] }
+
+ result = Boards::Cards::Comments::Create.new(current_user).call(comment_params(params))
+
+ if result.success?
+ comment = result.value!
+ card = comment.card
+ RetrospectiveSchema.subscriptions.trigger('card_updated',
+ { board_slug: card.board.slug }, card)
+ { comment: comment }
+ else
+ { errors: { full_messages: result.failure.message } }
+ end
+ end
+ # rubocop:enable Metrics/MethodLength
+ # rubocop:enable Metrics/AbcSize
+
+ def comment_params(params)
+ params.merge(author: context[:current_user])
+ end
+ end
+end
diff --git a/app/graphql/mutations/base_mutation.rb b/app/graphql/mutations/base_mutation.rb
new file mode 100644
index 00000000..412ba927
--- /dev/null
+++ b/app/graphql/mutations/base_mutation.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module Mutations
+ class BaseMutation < GraphQL::Schema::RelayClassicMutation
+ include ActionPolicy::GraphQL::Behaviour
+ argument_class Types::BaseArgument
+ field_class Types::BaseField
+ input_object_class Types::BaseInputObject
+ object_class Types::BaseObject
+
+ field :errors, Types::ValidationErrorsType, null: true
+ end
+end
diff --git a/app/graphql/mutations/close_action_item_mutation.rb b/app/graphql/mutations/close_action_item_mutation.rb
new file mode 100644
index 00000000..0097ce2c
--- /dev/null
+++ b/app/graphql/mutations/close_action_item_mutation.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+module Mutations
+ class CloseActionItemMutation < Mutations::BaseMutation
+ argument :id, ID, required: true
+ argument :board_slug, String, required: true
+
+ field :action_item, Types::ActionItemType, null: true
+
+ # rubocop:disable Metrics/MethodLength
+ def resolve(id:, board_slug:)
+ action_item = ActionItem.find(id)
+ board = Board.find_by!(slug: board_slug)
+ authorize! action_item, to: :close?,
+ context: { user: context[:current_user],
+ board: board }
+
+ if action_item.close!
+ RetrospectiveSchema.subscriptions.trigger('action_item_updated', { board_slug: board.slug },
+ action_item)
+ { action_item: action_item }
+ else
+ { errors: { full_messages: action_item.errors.full_messages } }
+ end
+ end
+ # rubocop:enable Metrics/MethodLength
+ end
+end
diff --git a/app/graphql/mutations/complete_action_item_mutation.rb b/app/graphql/mutations/complete_action_item_mutation.rb
new file mode 100644
index 00000000..20f8ea0e
--- /dev/null
+++ b/app/graphql/mutations/complete_action_item_mutation.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module Mutations
+ class CompleteActionItemMutation < Mutations::BaseMutation
+ argument :id, ID, required: true
+ argument :board_slug, String, required: true
+
+ field :action_item, Types::ActionItemType, null: true
+
+ # rubocop:disable Metrics/MethodLength
+ def resolve(id:, board_slug:)
+ action_item = ActionItem.find(id)
+ board = Board.find_by!(slug: board_slug)
+ authorize! action_item, to: :complete?,
+ context: { user: context[:current_user], board: board }
+
+ if action_item.complete!
+ RetrospectiveSchema.subscriptions.trigger('action_item_updated', { board_slug: board.slug },
+ action_item)
+ { action_item: action_item }
+ else
+ { errors: { full_messages: action_item.errors.full_messages } }
+ end
+ end
+ # rubocop:enable Metrics/MethodLength
+ end
+end
diff --git a/app/graphql/mutations/destroy_action_item_mutation.rb b/app/graphql/mutations/destroy_action_item_mutation.rb
new file mode 100644
index 00000000..44f77739
--- /dev/null
+++ b/app/graphql/mutations/destroy_action_item_mutation.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+module Mutations
+ class DestroyActionItemMutation < Mutations::BaseMutation
+ argument :id, ID, required: true
+
+ field :id, Int, null: true
+
+ # rubocop:disable Metrics/MethodLength
+ def resolve(id:)
+ action_item = ActionItem.find(id)
+ authorize! action_item, to: :destroy?, context: { user: context[:current_user],
+ board: action_item.board }
+
+ if action_item.destroy
+ RetrospectiveSchema.subscriptions.trigger('action_item_destroyed',
+ { board_slug: action_item.board.slug },
+ id: id)
+ { id: id }
+ else
+ { errors: { full_messages: action_item.errors.full_messages } }
+ end
+ end
+ # rubocop:enable Metrics/MethodLength
+ end
+end
diff --git a/app/graphql/mutations/destroy_card_mutation.rb b/app/graphql/mutations/destroy_card_mutation.rb
new file mode 100644
index 00000000..7afca25f
--- /dev/null
+++ b/app/graphql/mutations/destroy_card_mutation.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module Mutations
+ class DestroyCardMutation < Mutations::BaseMutation
+ argument :id, ID, required: true
+
+ field :id, Int, null: false
+ def resolve(id:)
+ card = Card.find(id)
+ authorize! card, to: :destroy?, context: { user: context[:current_user] }
+
+ if card.destroy
+ RetrospectiveSchema.subscriptions.trigger('card_destroyed',
+ { board_slug: card.board.slug },
+ id: id, kind: card.kind)
+ { id: id }
+ else
+ { errors: { full_messages: card.errors.full_messages } }
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/destroy_comment_mutation.rb b/app/graphql/mutations/destroy_comment_mutation.rb
new file mode 100644
index 00000000..891bf9e6
--- /dev/null
+++ b/app/graphql/mutations/destroy_comment_mutation.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Mutations
+ class DestroyCommentMutation < Mutations::BaseMutation
+ argument :id, ID, required: true
+
+ field :id, Int, null: false
+ def resolve(id:)
+ comment = Comment.find(id)
+ authorize! comment, to: :destroy?, context: { user: context[:current_user] }
+ card = comment.card
+ if comment.destroy
+ RetrospectiveSchema.subscriptions.trigger('card_updated',
+ { board_slug: card.board.slug }, card)
+ { id: id }
+ else
+ { errors: { full_messages: comment.errors.full_messages } }
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/destroy_membership_mutation.rb b/app/graphql/mutations/destroy_membership_mutation.rb
new file mode 100644
index 00000000..7dba9994
--- /dev/null
+++ b/app/graphql/mutations/destroy_membership_mutation.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+module Mutations
+ class DestroyMembershipMutation < Mutations::BaseMutation
+ argument :id, ID, required: true
+
+ field :id, Int, null: true
+
+ # rubocop:disable Metrics/MethodLength
+ # rubocop:disable Metrics/AbcSize
+ def resolve(id:)
+ membership = Membership.find(id)
+ authorize! membership, to: :destroy?,
+ context: { membership: Membership.find_by(user: context[:current_user],
+ board: membership.board) }
+
+ membership.transaction do
+ membership.board.board_permissions_users.where(user: membership.user).destroy_all
+ membership.destroy
+ end
+
+ if !membership.persisted?
+ RetrospectiveSchema.subscriptions.trigger('membership_destroyed',
+ { board_slug: membership.board.slug },
+ id: id)
+ { id: id }
+ else
+ { errors: { full_messages: membership.errors.full_messages } }
+ end
+ end
+ # rubocop:enable Metrics/MethodLength
+ # rubocop:enable Metrics/AbcSize
+ end
+end
diff --git a/app/graphql/mutations/invite_members_mutation.rb b/app/graphql/mutations/invite_members_mutation.rb
new file mode 100644
index 00000000..4918fbd4
--- /dev/null
+++ b/app/graphql/mutations/invite_members_mutation.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+module Mutations
+ class InviteMembersMutation < Mutations::BaseMutation
+ argument :email, String, required: true
+ argument :board_slug, String, required: true
+
+ field :memberships, [Types::MembershipType], null: true
+
+ # rubocop:disable Metrics/MethodLength
+ def resolve(email:, board_slug:)
+ board = Board.find_by!(slug: board_slug)
+ authorize! board, to: :invite?, context: { user: context[:current_user] }
+
+ users = Boards::FindUsersToInvite.new(email, board).call
+ if users.any?
+ result = Boards::InviteUsers.new(board, users).call
+ memberships = result.value!
+ RetrospectiveSchema.subscriptions.trigger('membership_list_updated',
+ { board_slug: board.slug },
+ memberships)
+ { memberships: memberships }
+ else
+ { errors:
+ { full_messages: ['User was not found'] } }
+ end
+ end
+ # rubocop:enable Metrics/MethodLength
+ end
+end
diff --git a/app/graphql/mutations/like_card_mutation.rb b/app/graphql/mutations/like_card_mutation.rb
new file mode 100644
index 00000000..17262d3c
--- /dev/null
+++ b/app/graphql/mutations/like_card_mutation.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module Mutations
+ class LikeCardMutation < Mutations::BaseMutation
+ argument :id, ID, required: true
+
+ field :card, Types::CardType, null: true
+ def resolve(id:)
+ card = Card.find(id)
+ authorize! card, to: :like?, context: { user: context[:current_user] }
+
+ if card.like!
+ RetrospectiveSchema.subscriptions.trigger('card_updated',
+ { board_slug: card.board.slug },
+ card)
+ { card: card }
+ else
+ { errors: { full_messages: card.errors.full_messages } }
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/like_comment_mutation.rb b/app/graphql/mutations/like_comment_mutation.rb
new file mode 100644
index 00000000..97e445e2
--- /dev/null
+++ b/app/graphql/mutations/like_comment_mutation.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module Mutations
+ class LikeCommentMutation < Mutations::BaseMutation
+ argument :id, ID, required: true
+
+ field :comment, Types::CommentType, null: true
+
+ def resolve(id:)
+ comment = Comment.find(id)
+ authorize! comment, to: :like?, context: { user: context[:current_user] }
+ if comment.like!
+ RetrospectiveSchema.subscriptions.trigger('card_updated',
+ { board_slug: comment.card.board.slug },
+ comment.card)
+ { comment: comment }
+ else
+ { errors: { full_messages: comment.errors.full_messages } }
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/move_action_item_mutation.rb b/app/graphql/mutations/move_action_item_mutation.rb
new file mode 100644
index 00000000..d9ffbddb
--- /dev/null
+++ b/app/graphql/mutations/move_action_item_mutation.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module Mutations
+ class MoveActionItemMutation < Mutations::BaseMutation
+ argument :id, ID, required: true
+ argument :board_slug, String, required: true
+
+ field :action_item, Types::ActionItemType, null: true
+
+ # rubocop:disable Metrics/MethodLength
+ def resolve(id:, board_slug:)
+ action_item = ActionItem.find(id)
+ board = Board.find_by!(slug: board_slug)
+ authorize! action_item, to: :move?, context: { user: context[:current_user],
+ board: board }
+
+ if action_item.move!(board)
+ RetrospectiveSchema.subscriptions.trigger('action_item_moved', { board_slug: board.slug },
+ action_item)
+ { action_item: action_item }
+ else
+ { errors: { full_messages: action_item.errors.full_messages } }
+ end
+ end
+ # rubocop:enable Metrics/MethodLength
+ end
+end
diff --git a/app/graphql/mutations/reopen_action_item_mutation.rb b/app/graphql/mutations/reopen_action_item_mutation.rb
new file mode 100644
index 00000000..2088b936
--- /dev/null
+++ b/app/graphql/mutations/reopen_action_item_mutation.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module Mutations
+ class ReopenActionItemMutation < Mutations::BaseMutation
+ argument :id, ID, required: true
+ argument :board_slug, String, required: true
+
+ field :action_item, Types::ActionItemType, null: true
+
+ # rubocop:disable Metrics/MethodLength
+ def resolve(id:, board_slug:)
+ action_item = ActionItem.find(id)
+ board = Board.find_by!(slug: board_slug)
+ authorize! action_item, to: :reopen?, context: { user: context[:current_user],
+ board: board }
+
+ if action_item.reopen!
+ RetrospectiveSchema.subscriptions.trigger('action_item_updated', { board_slug: board.slug },
+ action_item)
+ { action_item: action_item }
+ else
+ { errors: { full_messages: action_item.errors.full_messages } }
+ end
+ end
+ # rubocop:enable Metrics/MethodLength
+ end
+end
diff --git a/app/graphql/mutations/toggle_ready_status_mutation.rb b/app/graphql/mutations/toggle_ready_status_mutation.rb
new file mode 100644
index 00000000..4a9f1fd7
--- /dev/null
+++ b/app/graphql/mutations/toggle_ready_status_mutation.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Mutations
+ class ToggleReadyStatusMutation < Mutations::BaseMutation
+ argument :id, ID, required: true
+
+ field :membership, Types::MembershipType, null: true
+ # rubocop:disable Metrics/MethodLength
+ def resolve(id:)
+ membership = Membership.find(id)
+ authorize! membership, to: :ready_toggle?,
+ context: { membership: membership }
+
+ if membership.update(ready: !membership.ready)
+ RetrospectiveSchema.subscriptions.trigger('membership_updated',
+ { board_slug: membership.board.slug },
+ membership)
+ { membership: membership }
+ else
+ { errors: { full_messages: membership.errors.full_messages } }
+ end
+ end
+ # rubocop:enable Metrics/MethodLength
+ end
+end
diff --git a/app/graphql/mutations/update_action_item_mutation.rb b/app/graphql/mutations/update_action_item_mutation.rb
new file mode 100644
index 00000000..8eae4e88
--- /dev/null
+++ b/app/graphql/mutations/update_action_item_mutation.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+module Mutations
+ class UpdateActionItemMutation < Mutations::BaseMutation
+ argument :id, ID, required: true
+ argument :attributes, Types::ActionItemAttributes, required: true
+
+ field :action_item, Types::ActionItemType, null: true
+
+ # rubocop:disable Metrics/MethodLength
+ def resolve(id:, attributes:)
+ action_item = ActionItem.find(id)
+
+ authorize! action_item, to: :update?, context: { user: context[:current_user],
+ board: action_item.board }
+
+ if action_item.update(attributes.to_h)
+ RetrospectiveSchema.subscriptions.trigger('action_item_updated',
+ { board_slug: action_item.board.slug },
+ action_item)
+ { action_item: action_item }
+ else
+ { errors: action_item.errors }
+ end
+ end
+ # rubocop:enable Metrics/MethodLength
+ end
+end
diff --git a/app/graphql/mutations/update_card_mutation.rb b/app/graphql/mutations/update_card_mutation.rb
new file mode 100644
index 00000000..2aafc2e8
--- /dev/null
+++ b/app/graphql/mutations/update_card_mutation.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module Mutations
+ class UpdateCardMutation < Mutations::BaseMutation
+ argument :id, ID, required: true
+ argument :attributes, Types::CardAttributes, required: true
+
+ field :card, Types::CardType, null: true
+ def resolve(id:, attributes:)
+ card = Card.find(id)
+ authorize! card, to: :update?, context: { user: context[:current_user] }
+
+ if card.update(attributes.to_h)
+ RetrospectiveSchema.subscriptions.trigger('card_updated',
+ { board_slug: card.board.slug }, card)
+ { card: card }
+ else
+ { errors: { full_messages: card.errors.full_messages } }
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/update_comment_mutation.rb b/app/graphql/mutations/update_comment_mutation.rb
new file mode 100644
index 00000000..e81a658c
--- /dev/null
+++ b/app/graphql/mutations/update_comment_mutation.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module Mutations
+ class UpdateCommentMutation < Mutations::BaseMutation
+ argument :id, ID, required: true
+ argument :attributes, Types::CommentAttributes, required: true
+
+ field :comment, Types::CommentType, null: true
+ def resolve(id:, attributes:)
+ comment = Comment.find(id)
+
+ authorize! comment, to: :update?, context: { user: context[:current_user] }
+
+ if comment.update(attributes.to_h)
+ card = comment.card
+ RetrospectiveSchema.subscriptions.trigger('card_updated',
+ { board_slug: card.board.slug }, card)
+ { comment: comment }
+ else
+ { errors: { full_messages: comment.errors.full_messages } }
+ end
+ end
+ end
+end
diff --git a/app/graphql/queries/base_query.rb b/app/graphql/queries/base_query.rb
new file mode 100644
index 00000000..2bda3c86
--- /dev/null
+++ b/app/graphql/queries/base_query.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module Queries
+ class BaseQuery < GraphQL::Schema::Resolver
+ include ActionPolicy::GraphQL::Behaviour
+ end
+end
diff --git a/app/graphql/queries/board.rb b/app/graphql/queries/board.rb
new file mode 100644
index 00000000..c49f03f6
--- /dev/null
+++ b/app/graphql/queries/board.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module Queries
+ class Board < Queries::BaseQuery
+ description 'Returns board by id'
+
+ argument :id, ID, required: true
+
+ type Types::BoardType, null: false
+
+ def resolve(id:)
+ ::Board.find(id)
+ end
+ end
+end
diff --git a/app/graphql/queries/boards.rb b/app/graphql/queries/boards.rb
new file mode 100644
index 00000000..ea1e51e1
--- /dev/null
+++ b/app/graphql/queries/boards.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module Queries
+ class Boards < Queries::BaseQuery
+ description 'Returns a list of boards'
+
+ type [Types::BoardType], null: false
+
+ def resolve
+ ::Board.limit(10)
+ end
+ end
+end
diff --git a/app/graphql/queries/membership.rb b/app/graphql/queries/membership.rb
new file mode 100644
index 00000000..caa88ebd
--- /dev/null
+++ b/app/graphql/queries/membership.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module Queries
+ class Membership < Queries::BaseQuery
+ description 'Returns membership by id'
+
+ argument :board_slug, String, required: true
+
+ type Types::MembershipType, null: false
+
+ def resolve(board_slug:)
+ ::Board.find_by!(slug: board_slug).memberships.find_by(user: context[:current_user])
+ end
+ end
+end
diff --git a/app/graphql/queries/memberships.rb b/app/graphql/queries/memberships.rb
new file mode 100644
index 00000000..3087207f
--- /dev/null
+++ b/app/graphql/queries/memberships.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module Queries
+ class Memberships < Queries::BaseQuery
+ description 'Returns memberships by board slug'
+
+ argument :board_slug, String, required: true
+
+ type [Types::MembershipType], null: false
+
+ def resolve(board_slug:)
+ ::Board.find_by!(slug: board_slug).memberships.includes([:user])
+ end
+ end
+end
diff --git a/app/graphql/queries/suggestions.rb b/app/graphql/queries/suggestions.rb
new file mode 100644
index 00000000..ba8eea56
--- /dev/null
+++ b/app/graphql/queries/suggestions.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module Queries
+ class Suggestions < Queries::BaseQuery
+ description 'Returns suggestions for given input'
+
+ argument :autocomplete, String, required: true
+
+ type Types::SuggestionType, null: false
+
+ def resolve(autocomplete:)
+ ::Boards::Suggestions.new(autocomplete).call
+ end
+ end
+end
diff --git a/app/graphql/retrospective_schema.rb b/app/graphql/retrospective_schema.rb
new file mode 100644
index 00000000..114329fb
--- /dev/null
+++ b/app/graphql/retrospective_schema.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+class RetrospectiveSchema < GraphQL::Schema
+ use GraphQL::Subscriptions::ActionCableSubscriptions, redis: Redis.new
+
+ mutation(Types::MutationType)
+ query(Types::QueryType)
+ subscription(Types::SubscriptionType)
+
+ rescue_from(ActionPolicy::Unauthorized) do |exp|
+ raise GraphQL::ExecutionError.new(
+ # use result.message (backed by i18n) as an error message
+ exp.result.message,
+ # use GraphQL error extensions to provide more context
+ extensions: {
+ code: :unauthorized,
+ fullMessages: exp.result.reasons.full_messages,
+ details: exp.result.reasons.details
+ }
+ )
+ end
+end
diff --git a/app/graphql/types/.keep b/app/graphql/types/.keep
new file mode 100644
index 00000000..e69de29b
diff --git a/app/graphql/types/action_item_attributes.rb b/app/graphql/types/action_item_attributes.rb
new file mode 100644
index 00000000..c7eab3eb
--- /dev/null
+++ b/app/graphql/types/action_item_attributes.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+module Types
+ class ActionItemAttributes < Types::BaseInputObject
+ description 'Attributes for creating or updating action item'
+
+ argument :status, String, required: false
+ argument :body, String, required: false
+ argument :assignee_id, ID, required: false
+ argument :board_slug, String, required: false
+ end
+end
diff --git a/app/graphql/types/action_item_type.rb b/app/graphql/types/action_item_type.rb
new file mode 100644
index 00000000..d7eace63
--- /dev/null
+++ b/app/graphql/types/action_item_type.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Types
+ class ActionItemType < Types::BaseObject
+ field :id, Int, null: false
+ field :body, String, null: false
+ field :times_moved, Int, null: false, camelize: false
+ field :assignee, Types::UserType, null: true
+ field :assignee_avatar_url, String, null: true, camelize: false
+ field :status, String, null: true
+ field :author, Types::UserType, null: false
+
+ def assignee_avatar_url
+ object.assignee.avatar.thumb.url if object.assignee
+ end
+ end
+end
diff --git a/app/graphql/types/avatar_type.rb b/app/graphql/types/avatar_type.rb
new file mode 100644
index 00000000..bd79572e
--- /dev/null
+++ b/app/graphql/types/avatar_type.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module Types
+ class AvatarType < Types::BaseObject
+ field :thumb, Types::ThumbType, null: true
+ end
+end
diff --git a/app/graphql/types/base_argument.rb b/app/graphql/types/base_argument.rb
new file mode 100644
index 00000000..2e2278c5
--- /dev/null
+++ b/app/graphql/types/base_argument.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+module Types
+ class BaseArgument < GraphQL::Schema::Argument
+ end
+end
diff --git a/app/graphql/types/base_enum.rb b/app/graphql/types/base_enum.rb
new file mode 100644
index 00000000..cf43fea4
--- /dev/null
+++ b/app/graphql/types/base_enum.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+module Types
+ class BaseEnum < GraphQL::Schema::Enum
+ end
+end
diff --git a/app/graphql/types/base_field.rb b/app/graphql/types/base_field.rb
new file mode 100644
index 00000000..611eb056
--- /dev/null
+++ b/app/graphql/types/base_field.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module Types
+ class BaseField < GraphQL::Schema::Field
+ argument_class Types::BaseArgument
+ end
+end
diff --git a/app/graphql/types/base_input_object.rb b/app/graphql/types/base_input_object.rb
new file mode 100644
index 00000000..27951132
--- /dev/null
+++ b/app/graphql/types/base_input_object.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module Types
+ class BaseInputObject < GraphQL::Schema::InputObject
+ argument_class Types::BaseArgument
+ end
+end
diff --git a/app/graphql/types/base_interface.rb b/app/graphql/types/base_interface.rb
new file mode 100644
index 00000000..256779bd
--- /dev/null
+++ b/app/graphql/types/base_interface.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module Types
+ module BaseInterface
+ include GraphQL::Schema::Interface
+
+ field_class Types::BaseField
+ end
+end
diff --git a/app/graphql/types/base_object.rb b/app/graphql/types/base_object.rb
new file mode 100644
index 00000000..421cabc6
--- /dev/null
+++ b/app/graphql/types/base_object.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+module Types
+ class BaseObject < GraphQL::Schema::Object
+ include ActionPolicy::GraphQL::Behaviour
+ field_class Types::BaseField
+ end
+end
diff --git a/app/graphql/types/base_scalar.rb b/app/graphql/types/base_scalar.rb
new file mode 100644
index 00000000..719bc808
--- /dev/null
+++ b/app/graphql/types/base_scalar.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+module Types
+ class BaseScalar < GraphQL::Schema::Scalar
+ end
+end
diff --git a/app/graphql/types/base_union.rb b/app/graphql/types/base_union.rb
new file mode 100644
index 00000000..30a5668c
--- /dev/null
+++ b/app/graphql/types/base_union.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+module Types
+ class BaseUnion < GraphQL::Schema::Union
+ end
+end
diff --git a/app/graphql/types/board_type.rb b/app/graphql/types/board_type.rb
new file mode 100644
index 00000000..00a320bf
--- /dev/null
+++ b/app/graphql/types/board_type.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+module Types
+ class BoardType < Types::BaseObject
+ field :id, ID, null: false
+ field :title, String, null: false
+ field :slug, String, null: false
+ field :cards, [Types::CardType], null: false
+ field :created_at, GraphQL::Types::ISO8601DateTime, null: false
+ field :updated_at, GraphQL::Types::ISO8601DateTime, null: false
+ end
+end
diff --git a/app/graphql/types/card_attributes.rb b/app/graphql/types/card_attributes.rb
new file mode 100644
index 00000000..5695e61a
--- /dev/null
+++ b/app/graphql/types/card_attributes.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module Types
+ class CardAttributes < Types::BaseInputObject
+ description 'Attributes for creating or updating card'
+
+ argument :kind, String, required: false
+ argument :body, String, required: false
+ argument :board_slug, String, required: false
+ end
+end
diff --git a/app/graphql/types/card_type.rb b/app/graphql/types/card_type.rb
new file mode 100644
index 00000000..56c5ed7f
--- /dev/null
+++ b/app/graphql/types/card_type.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module Types
+ class CardType < Types::BaseObject
+ field :id, Int, null: false
+ field :kind, String, null: false
+ field :body, String, null: false
+ field :likes, Int, null: false
+ field :author_id, ID, null: false
+ field :author, Types::UserType, null: false
+ field :board_id, ID, null: false
+ field :created_at, GraphQL::Types::ISO8601DateTime, null: false
+ field :updated_at, GraphQL::Types::ISO8601DateTime, null: false
+ field :comments, [Types::CommentType], null: false
+ end
+end
diff --git a/app/graphql/types/comment_attributes.rb b/app/graphql/types/comment_attributes.rb
new file mode 100644
index 00000000..205ae1fb
--- /dev/null
+++ b/app/graphql/types/comment_attributes.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+module Types
+ class CommentAttributes < Types::BaseInputObject
+ description 'Attributes for creating or updating comment'
+
+ argument :content, String, required: false
+ argument :card_id, ID, required: false
+ end
+end
diff --git a/app/graphql/types/comment_type.rb b/app/graphql/types/comment_type.rb
new file mode 100644
index 00000000..9cd1dc05
--- /dev/null
+++ b/app/graphql/types/comment_type.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module Types
+ class CommentType < Types::BaseObject
+ field :id, Int, null: false
+ field :author, Types::UserType, null: false
+ field :card_id, Int, null: false
+ field :content, String, null: false
+ field :likes, Int, null: false
+ end
+end
diff --git a/app/graphql/types/deleted_card_type.rb b/app/graphql/types/deleted_card_type.rb
new file mode 100644
index 00000000..ddec2da1
--- /dev/null
+++ b/app/graphql/types/deleted_card_type.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+module Types
+ class DeletedCardType < Types::BaseObject
+ field :id, Int, null: false
+ field :kind, String, null: false
+ end
+end
diff --git a/app/graphql/types/membership_type.rb b/app/graphql/types/membership_type.rb
new file mode 100644
index 00000000..44c3ef6e
--- /dev/null
+++ b/app/graphql/types/membership_type.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module Types
+ class MembershipType < Types::BaseObject
+ field :id, Int, null: false
+ field :role, String, null: false
+ field :ready, Boolean, null: false
+ field :user, Types::UserType, null: false
+ field :board, Types::BoardType, null: false
+ end
+end
diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb
new file mode 100644
index 00000000..6ef90cf5
--- /dev/null
+++ b/app/graphql/types/mutation_type.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module Types
+ class MutationType < Types::BaseObject
+ field :add_action_item, mutation: Mutations::AddActionItemMutation
+ field :add_card, mutation: Mutations::AddCardMutation
+ field :add_comment, mutation: Mutations::AddCommentMutation
+ field :close_action_item, mutation: Mutations::CloseActionItemMutation
+ field :complete_action_item, mutation: Mutations::CompleteActionItemMutation
+ field :destroy_action_item, mutation: Mutations::DestroyActionItemMutation
+ field :destroy_card, mutation: Mutations::DestroyCardMutation
+ field :destroy_membership, mutation: Mutations::DestroyMembershipMutation
+ field :destroy_comment, mutation: Mutations::DestroyCommentMutation
+ field :invite_members, mutation: Mutations::InviteMembersMutation
+ field :like_card, mutation: Mutations::LikeCardMutation
+ field :like_comment, mutation: Mutations::LikeCommentMutation
+ field :move_action_item, mutation: Mutations::MoveActionItemMutation
+ field :reopen_action_item, mutation: Mutations::ReopenActionItemMutation
+ field :toggle_ready_status, mutation: Mutations::ToggleReadyStatusMutation
+ field :update_action_item, mutation: Mutations::UpdateActionItemMutation
+ field :update_card, mutation: Mutations::UpdateCardMutation
+ field :update_comment, mutation: Mutations::UpdateCommentMutation
+ end
+end
diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb
new file mode 100644
index 00000000..672ac8d8
--- /dev/null
+++ b/app/graphql/types/query_type.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module Types
+ class QueryType < Types::BaseObject
+ field :boards, resolver: Queries::Boards
+ field :board, resolver: Queries::Board
+ field :membership, resolver: Queries::Membership
+ field :memberships, resolver: Queries::Memberships
+ field :suggestions, resolver: Queries::Suggestions
+ end
+end
diff --git a/app/graphql/types/subscription_type.rb b/app/graphql/types/subscription_type.rb
new file mode 100644
index 00000000..1c0ec25b
--- /dev/null
+++ b/app/graphql/types/subscription_type.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+module Types
+ class SubscriptionType < BaseObject
+ # rubocop:disable Metrics/LineLength
+ field :action_item_added, Types::ActionItemType, null: false, description: 'An action item was added' do
+ argument :boardSlug, String, required: true
+ end
+
+ field :action_item_moved, Types::ActionItemType, null: false, description: 'An action item was moved' do
+ argument :boardSlug, String, required: true
+ end
+
+ field :action_item_destroyed, Types::ActionItemType, null: false, description: 'An action item was destroyed' do
+ argument :boardSlug, String, required: true
+ end
+
+ field :action_item_updated, Types::ActionItemType, null: false, description: 'An action item was updated' do
+ argument :boardSlug, String, required: true
+ end
+
+ field :card_added, Types::CardType, null: false, description: 'A card was added' do
+ argument :boardSlug, String, required: true
+ end
+ field :card_updated, Types::CardType, null: false, description: 'A card wad updated' do
+ argument :boardSlug, String, required: true
+ end
+
+ field :card_destroyed, Types::DeletedCardType, null: false, description: 'A card wad destroyed' do
+ argument :boardSlug, String, required: true
+ end
+
+ field :membership_destroyed, Types::MembershipType, null: false, description: 'A membership record was destroyed' do
+ argument :boardSlug, String, required: true
+ end
+
+ field :membership_list_updated, [Types::MembershipType], null: false, description: 'List of board members was updated' do
+ argument :boardSlug, String, required: true
+ end
+
+ field :membership_updated, Types::MembershipType, null: false, description: 'A membership record was updated' do
+ argument :boardSlug, String, required: true
+ end
+
+ # rubocop:enable Metrics/LineLength
+
+ def action_item_added(*); end
+
+ def action_item_moved(*); end
+
+ def action_item_destroyed(*); end
+
+ def action_item_updated(*); end
+
+ def card_added(*); end
+
+ def card_updated(*); end
+
+ def card_destroyed(*); end
+
+ def membership_destroyed(*); end
+
+ def membership_list_updated(*); end
+
+ def membership_updated(*); end
+ end
+end
diff --git a/app/graphql/types/suggestion_type.rb b/app/graphql/types/suggestion_type.rb
new file mode 100644
index 00000000..430ee4ab
--- /dev/null
+++ b/app/graphql/types/suggestion_type.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+module Types
+ class SuggestionType < Types::BaseObject
+ field :users, [String], null: false
+ field :teams, [String], null: false
+ end
+end
diff --git a/app/graphql/types/thumb_type.rb b/app/graphql/types/thumb_type.rb
new file mode 100644
index 00000000..0996ed34
--- /dev/null
+++ b/app/graphql/types/thumb_type.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module Types
+ class ThumbType < Types::BaseObject
+ field :url, String, null: true
+ end
+end
diff --git a/app/graphql/types/user_type.rb b/app/graphql/types/user_type.rb
new file mode 100644
index 00000000..3adea17d
--- /dev/null
+++ b/app/graphql/types/user_type.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module Types
+ class UserType < Types::BaseObject
+ field :id, ID, null: false
+ field :email, String, null: false
+ field :nickname, String, null: true
+ field :last_name, String, null: true
+ field :first_name, String, null: true
+ field :avatar, Types::AvatarType, null: true
+ field :created_at, GraphQL::Types::ISO8601DateTime, null: false
+ field :updated_at, GraphQL::Types::ISO8601DateTime, null: false
+ end
+end
diff --git a/app/graphql/types/validation_errors_type.rb b/app/graphql/types/validation_errors_type.rb
new file mode 100644
index 00000000..937ea530
--- /dev/null
+++ b/app/graphql/types/validation_errors_type.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+module Types
+ class ValidationErrorsType < Types::BaseObject
+ field :details, String, null: false
+ field :full_messages, [String], null: false
+
+ def details
+ object.details.to_json
+ end
+ end
+end
diff --git a/app/helpers/authorization_helper.rb b/app/helpers/authorization_helper.rb
new file mode 100644
index 00000000..776ccb18
--- /dev/null
+++ b/app/helpers/authorization_helper.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module AuthorizationHelper
+ def providers
+ names = %w[alfred facebook google github]
+
+ names.select { |name| ENV["#{name.upcase}_KEY"].present? }
+ end
+end
diff --git a/app/javascript/channels/consumer.js b/app/javascript/channels/consumer.js
new file mode 100644
index 00000000..3557bff6
--- /dev/null
+++ b/app/javascript/channels/consumer.js
@@ -0,0 +1,6 @@
+// Action Cable provides the framework to deal with WebSockets in Rails.
+// You can generate new channels where WebSocket features live using the `rails generate channel` command.
+
+import {createConsumer} from '@rails/actioncable';
+
+export default createConsumer();
diff --git a/app/javascript/channels/index.js b/app/javascript/channels/index.js
new file mode 100644
index 00000000..298a304d
--- /dev/null
+++ b/app/javascript/channels/index.js
@@ -0,0 +1,5 @@
+// Load all the channels within this directory and all subdirectories.
+// Channel files must be named *_channel.js.
+
+const channels = require.context('.', true, /_channel\.js$/);
+channels.keys().forEach(channels);
diff --git a/app/javascript/components/ActionItem/ActionItem.css b/app/javascript/components/ActionItem/ActionItem.css
deleted file mode 100644
index 10687090..00000000
--- a/app/javascript/components/ActionItem/ActionItem.css
+++ /dev/null
@@ -1,9 +0,0 @@
-.box { margin-bottom: 1.5rem; }
-
-.green_font {color: hsl(141, 71%, 48%)}
-.yellow_font {color: hsl(48, 100%, 67%)}
-.red_font {color: hsl(348, 100%, 61%)}
-
-.green_bg {background-color: hsl(141, 71%, 48%)}
-.yellow_bg {background-color: hsl(48, 100%, 67%)}
-.red_bg {background-color: hsl(348, 100%, 61%)}
diff --git a/app/javascript/components/ActionItem/ActionItemBody/ActionItemBody.css b/app/javascript/components/ActionItem/ActionItemBody/ActionItemBody.css
deleted file mode 100644
index 350b138e..00000000
--- a/app/javascript/components/ActionItem/ActionItemBody/ActionItemBody.css
+++ /dev/null
@@ -1,11 +0,0 @@
-textarea {
- overflow: hidden;
- resize: none;
- width: 100%;
- font-size: 1rem;
- font-weight: 400;
- line-height: 1.5;
- color: hsl(0, 0%, 29%);
-}
-
-.text { overflow-wrap: break-word; }
diff --git a/app/javascript/components/ActionItem/ActionItemBody/index.js b/app/javascript/components/ActionItem/ActionItemBody/index.js
deleted file mode 100644
index eef92357..00000000
--- a/app/javascript/components/ActionItem/ActionItemBody/index.js
+++ /dev/null
@@ -1,80 +0,0 @@
-import React from "react"
-import Textarea from "react-textarea-autosize"
-import "./ActionItemBody.css"
-
-class ActionItemBody extends React.Component {
- constructor(props) {
- super(props);
- this.state = {
- dbValue: this.props.body,
- inputValue: this.props.body,
- editMode : false
- };
- }
-
- editModeToggle = () => {
- this.setState({editMode: !this.state.editMode});
- }
-
- handleChange = (e) => {
- this.setState({inputValue: e.target.value});
- }
-
- handleKeyPress = (e) => {
- if(e.key === 'Enter'){
- this.editModeToggle()
- this.submitRequest()
- e.preventDefault()
- }
- }
-
- submitRequest() {
- fetch(`/api/${window.location.pathname}/action_items/${this.props.id}`, {
- method: 'PATCH',
- headers: {
- Accept: 'application/json',
- 'Content-Type': 'application/json',
- 'X-CSRF-Token': document.querySelector("meta[name='csrf-token']").getAttribute('content')
- },
- body: JSON.stringify({
- edited_body: this.state.inputValue
- })
- }).then((result) => {
- if (result.status == 200) {
- result.json()
- .then((resultHash) => {
- this.setState({dbValue: resultHash.updated_body});
- })
- }
- else {
- this.setState({inputValue: this.state.dbValue});
- throw result
- }
- }).catch((error) => {
- error.json()
- .then((errorHash) => {
- console.log(errorHash.error)
- })
- });
- }
-
- render () {
- const { inputValue, editMode } = this.state;
- const { editable } = this.props;
-
- return (
-
- );
- }
-}
-
-export default ActionItemBody
diff --git a/app/javascript/components/ActionItem/ActionItemFooter/ActionItemFooter.css b/app/javascript/components/ActionItem/ActionItemFooter/ActionItemFooter.css
deleted file mode 100644
index 44fdd240..00000000
--- a/app/javascript/components/ActionItem/ActionItemFooter/ActionItemFooter.css
+++ /dev/null
@@ -1,7 +0,0 @@
-.fa-chevron-right {
- margin-right: -2px;
- margin-left: -2px;
- font-size: 16px;
- stroke: white;
- stroke-width: 10;
-}
diff --git a/app/javascript/components/ActionItem/ActionItemFooter/index.js b/app/javascript/components/ActionItem/ActionItemFooter/index.js
deleted file mode 100644
index cdb0c078..00000000
--- a/app/javascript/components/ActionItem/ActionItemFooter/index.js
+++ /dev/null
@@ -1,97 +0,0 @@
-import React from "react"
-
-import TransitionButton from "../TransitionButton"
-import "./ActionItemFooter.css"
-
-class ActionItemFooter extends React.Component {
- constructor(props) {
- super(props);
- }
-
- handleDeleteClick = () => {
- fetch(`/api/${window.location.pathname}/action_items/${this.props.id}`, {
- method: 'DELETE',
- headers: {
- Accept: 'application/json',
- 'Content-Type': 'application/json',
- 'X-CSRF-Token': document.querySelector("meta[name='csrf-token']").getAttribute('content')
- }
- }).then((result) => {
- if (result.status == 204) {
- this.props.hideActionItem()
- }
- else { throw result }
- }).catch((error) => {
- error.json().then( errorHash => {
- console.log(errorHash.error)
- })
- });
- }
-
- handleMoveClick = () => {
- fetch(`/api/${window.location.pathname}/action_items/${this.props.id}/move`, {
- method: 'POST',
- headers: {
- Accept: 'application/json',
- 'Content-Type': 'application/json',
- 'X-CSRF-Token': document.querySelector("meta[name='csrf-token']").getAttribute('content')
- }
- }).then((result) => {
- if (result.status == 200) {
- window.location.reload();
- }
- else { throw result }
- }).catch((error) => {
- error.json().then( errorHash => {
- console.log(errorHash.error)
- })
- });
- }
-
- pickColor(num) {
- switch(true) {
- case [1,2].includes(num):
- return 'green';
- case [3].includes(num):
- return 'yellow';
- default:
- return 'red';
- }
- }
-
- generateChevrons = () => {
- const times_moved = this.props.times_moved;
- const icon = ;
- const chevrons = Array.from({ length: times_moved }, () => icon)
- return chevrons
- };
-
- render () {
- const { id, deletable, movable, transitionable } = this.props;
- const confirmDeleteMessage = 'Are you sure you want to delete this ActionItem?';
- const confirmMoveMessage = 'Are you sure you want to move this ActionItem?';
-
- return (
-
-
-
{this.generateChevrons()}
-
- {transitionable && transitionable.can_close &&
}
- {transitionable && transitionable.can_complete &&
}
- {transitionable && transitionable.can_reopen &&
}
- {movable &&
{window.confirm(confirmMoveMessage) && this.handleMoveClick()}}>
- move
- }
-
-
-
-
- );
- }
-}
-
-export default ActionItemFooter
diff --git a/app/javascript/components/ActionItem/TransitionButton/index.js b/app/javascript/components/ActionItem/TransitionButton/index.js
deleted file mode 100644
index 7f2aa765..00000000
--- a/app/javascript/components/ActionItem/TransitionButton/index.js
+++ /dev/null
@@ -1,38 +0,0 @@
-import React from "react"
-
-class TransitionButton extends React.Component {
- constructor(props) {
- super(props)
- this.state = {}
- }
-
- handleClick = () => {
- fetch(`/api/${window.location.pathname}/action_items/${this.props.id}/${this.props.action}`, {
- method: 'PUT',
- headers: {
- Accept: 'application/json',
- 'Content-Type': 'application/json',
- 'X-CSRF-Token': document.querySelector("meta[name='csrf-token']").getAttribute('content')
- }
- }).then((result) => {
- if (result.status == 200) {
- window.location.reload();
- }
- else { throw result }
- }).catch((error) => {
- error.json().then( errorHash => {
- console.log(errorHash.error)
- })
- });
- }
-
- render () {
- return (
- {this.handleClick()}}>
- {this.props.action}
-
- );
- }
-}
-
-export default TransitionButton
diff --git a/app/javascript/components/ActionItem/index.js b/app/javascript/components/ActionItem/index.js
deleted file mode 100644
index d8c2b003..00000000
--- a/app/javascript/components/ActionItem/index.js
+++ /dev/null
@@ -1,49 +0,0 @@
-import React from "react"
-
-import ActionItemBody from "./ActionItemBody"
-import ActionItemFooter from "./ActionItemFooter"
-import "./ActionItem.css"
-
-class ActionItem extends React.Component {
- constructor(props) {
- super(props)
- this.state = {}
- }
-
- hideActionItem = () => {
- this.setState({ActionItemStyle: {display: 'none'}});
- }
-
- pickColor = () => {
- switch(this.props.status) {
- case 'done':
- return 'green';
- case 'closed':
- return 'red';
- default:
- return null;
- }
- }
-
- render () {
- const { id, body, times_moved, deletable, editable, movable, transitionable } = this.props;
- const footerNotEmpty = deletable || movable || transitionable || (times_moved != 0);
-
- return (
-
-
- {footerNotEmpty && }
-
- );
- }
-}
-
-export default ActionItem
diff --git a/app/javascript/components/Card/Card.css b/app/javascript/components/Card/Card.css
deleted file mode 100644
index c3a71430..00000000
--- a/app/javascript/components/Card/Card.css
+++ /dev/null
@@ -1,6 +0,0 @@
-.box { margin-bottom: 1.5rem; }
-
-.avatar {
- width: 1.5rem;
- border-radius: 1rem;
-}
diff --git a/app/javascript/components/Card/CardBody/CardBody.css b/app/javascript/components/Card/CardBody/CardBody.css
deleted file mode 100644
index 361fdd99..00000000
--- a/app/javascript/components/Card/CardBody/CardBody.css
+++ /dev/null
@@ -1,11 +0,0 @@
-textarea {
- overflow: hidden;
- resize: none;
- width: 100%;
- font-size: 1rem;
- font-weight: 400;
- line-height: 1.5;
- color: hsl(0, 0%, 29%);
-}
-
-.text { overflow-wrap: break-word; }
diff --git a/app/javascript/components/Card/CardBody/index.js b/app/javascript/components/Card/CardBody/index.js
deleted file mode 100644
index 938befb4..00000000
--- a/app/javascript/components/Card/CardBody/index.js
+++ /dev/null
@@ -1,79 +0,0 @@
-import React from "react"
-import Textarea from "react-textarea-autosize"
-import "./CardBody.css"
-
-class CardBody extends React.Component {
- constructor(props) {
- super(props);
- this.state = {
- dbValue: this.props.body,
- inputValue: this.props.body,
- editMode : false
- };
- }
-
- editModeToggle = () => {
- this.setState({editMode: !this.state.editMode});
- }
-
- handleChange = (e) => {
- this.setState({inputValue: e.target.value});
- }
-
- handleKeyPress = (e) => {
- if(e.key === 'Enter'){
- this.editModeToggle()
- this.submitRequest()
- e.preventDefault()
- }
- }
-
- submitRequest() {
- fetch(`/api/${window.location.pathname}/cards/${this.props.id}`, {
- method: 'PATCH',
- headers: {
- Accept: 'application/json',
- 'Content-Type': 'application/json',
- 'X-CSRF-Token': document.querySelector("meta[name='csrf-token']").getAttribute('content')
- },
- body: JSON.stringify({
- edited_body: this.state.inputValue
- })
- }).then((result) => {
- if (result.status == 200) {
- result.json()
- .then((resultHash) => {
- this.setState({dbValue: resultHash.updated_body});
- })
- }
- else {
- this.setState({inputValue: this.state.dbValue});
- throw result
- }
- }).catch((error) => {
- error.json()
- .then((errorHash) => {
- console.log(errorHash.error)
- })
- });
- }
-
- render () {
- const { inputValue, editMode } = this.state;
- const { editable } = this.props;
-
- return (
-
- );
- }
-}
-
-export default CardBody
diff --git a/app/javascript/components/Card/CardFooter/index.js b/app/javascript/components/Card/CardFooter/index.js
deleted file mode 100644
index b968d13c..00000000
--- a/app/javascript/components/Card/CardFooter/index.js
+++ /dev/null
@@ -1,55 +0,0 @@
-import React from "react"
-import Likes from "../Likes"
-
-class CardFooter extends React.Component {
- constructor(props) {
- super(props);
- }
-
- handleClick = () => {
- fetch(`/api/${window.location.pathname}/cards/${this.props.id}`, {
- method: 'DELETE',
- headers: {
- Accept: 'application/json',
- 'Content-Type': 'application/json',
- 'X-CSRF-Token': document.querySelector("meta[name='csrf-token']").getAttribute('content')
- }
- }).then((result) => {
- if (result.status == 204) {
- this.props.hideCard()
- }
- else { throw result }
- }).catch((error) => {
- error.json().then( errorHash => {
- console.log(errorHash.error)
- })
- });
- }
-
- render () {
- const { author, deletable, avatar } = this.props;
- const confirmMessage = 'Are you sure you want to delete this card?';
-
- return (
-
-
-
-
-
-
-
-
-
by {author}
-
-
-
-
- );
- }
-}
-
-export default CardFooter
diff --git a/app/javascript/components/Card/Likes/index.js b/app/javascript/components/Card/Likes/index.js
deleted file mode 100644
index 9a9a9671..00000000
--- a/app/javascript/components/Card/Likes/index.js
+++ /dev/null
@@ -1,73 +0,0 @@
-import React from "react"
-
-class Likes extends React.Component {
- constructor(props) {
- super(props);
- this.state = {
- likes: this.props.likes,
- style: 'has-text-info',
- timer: null
- };
- }
-
- addLike () {
- fetch(`/api/${window.location.pathname}/cards/${this.props.id}/like`, {
- method: 'PUT',
- headers: {
- Accept: 'application/json',
- 'Content-Type': 'application/json',
- 'X-CSRF-Token': document.querySelector("meta[name='csrf-token']").getAttribute('content')
- }
- }).then((result) => {
- if (result.status == 200) {
- result.json()
- .then((resultHash) => {
- this.setState({likes: resultHash.likes});
- })
- }
- else {
- throw result
- }
- }).catch((error) => {
- error.json()
- .then((errorHash) => {
- console.log(errorHash.error)
- })
- });
- };
-
- handleMouseDown = () => {
- this.setState({style: 'has-text-success is-size-4'});
- this.addLike();
- this.state.timer = setInterval(() =>
- this.addLike()
- , 300);
- }
-
- handleMouseUp = () => {
- this.setState({style: 'has-text-info'});
- clearInterval(this.state.timer);
- }
-
- handleMouseLeave = () => {
- this.setState({style: 'has-text-info'});
- clearInterval(this.state.timer);
- }
-
- render () {
- const likes = this.state.likes
- return (
-
-
-
- {likes}
-
-
- );
- }
-}
-
-export default Likes
diff --git a/app/javascript/components/Card/index.js b/app/javascript/components/Card/index.js
deleted file mode 100644
index e961728f..00000000
--- a/app/javascript/components/Card/index.js
+++ /dev/null
@@ -1,36 +0,0 @@
-import React from "react"
-
-import CardBody from "./CardBody"
-import CardFooter from "./CardFooter"
-import "./Card.css"
-
-class Card extends React.Component {
- constructor(props) {
- super(props)
- this.state = {}
- }
-
- hideCard = () => {
- this.setState({cardStyle: {display: 'none'}});
- }
-
- render () {
- const { id, body, deletable, editable, author, avatar, likes } = this.props;
-
- return (
-
-
-
-
- );
- }
-}
-
-export default Card
diff --git a/app/javascript/components/action-item-body/action-item-body.jsx b/app/javascript/components/action-item-body/action-item-body.jsx
new file mode 100644
index 00000000..1a849acb
--- /dev/null
+++ b/app/javascript/components/action-item-body/action-item-body.jsx
@@ -0,0 +1,219 @@
+import React, {useState, useEffect} from 'react';
+import Textarea from 'react-textarea-autosize';
+import {
+ destroyActionItemMutation,
+ updateActionItemMutation
+} from './operations.gql';
+import {useMutation} from '@apollo/react-hooks';
+import {Linkify, linkifyOptions} from '../../utils/linkify';
+import {CardUser} from '../card-user';
+import style from '../card-body/style.module.less';
+
+const ActionItemBody = (props) => {
+ const {assignee, editable, deletable, body, users, timesMoved} = props;
+ const [inputValue, setInputValue] = useState(body);
+ const [editMode, setEditMode] = useState(false);
+ const [showDropdown, setShowDropdown] = useState(false);
+ const [actionItemAssignee, setActionItemAssignee] = useState(assignee?.id);
+ const [destroyActionItem] = useMutation(destroyActionItemMutation);
+ const [updateActionItem] = useMutation(updateActionItemMutation);
+
+ useEffect(() => {
+ const {body} = props;
+ if (!editMode) {
+ setInputValue(body);
+ }
+ }, [props, editMode]);
+
+ const handleDeleteClick = () => {
+ const {id} = props;
+ hideDropdown();
+ destroyActionItem({
+ variables: {
+ id
+ }
+ }).then(({data}) => {
+ if (!data.destroyActionItem.id) {
+ console.log(data.destroyActionItem.errors.fullMessages.join(' '));
+ }
+ });
+ };
+
+ const handleEditClick = () => {
+ editModeToggle();
+ hideDropdown();
+ };
+
+ const editModeToggle = () => {
+ setEditMode(!editMode);
+ };
+
+ const handleChange = (evt) => {
+ setInputValue(evt.target.value);
+ };
+
+ const resetTextChanges = () => {
+ setInputValue(props.body);
+ };
+
+ const handleKeyPress = (evt) => {
+ if (navigator.platform.includes('Mac')) {
+ if (evt.key === 'Enter' && evt.metaKey) {
+ editModeToggle();
+ handleItemEdit(props.id, inputValue);
+ }
+ } else if (evt.key === 'Enter' && evt.ctrlKey) {
+ editModeToggle();
+ handleItemEdit(props.id, inputValue);
+ }
+ };
+
+ const handleItemEdit = (id, body) => {
+ updateActionItem({
+ variables: {
+ id,
+ body,
+ assigneeId: actionItemAssignee
+ }
+ }).then(({data}) => {
+ if (!data.updateActionItem.actionItem) {
+ resetTextChanges();
+ console.log(data.updateActionItem.errors.fullMessages.join(' '));
+ }
+ });
+ };
+
+ const toggleDropdown = () => {
+ setShowDropdown(!showDropdown);
+ };
+
+ const hideDropdown = () => {
+ setShowDropdown(false);
+ };
+
+ const handleSaveClick = () => {
+ editModeToggle();
+ handleItemEdit(props.id, inputValue);
+ };
+
+ const pickColor = (number) => {
+ switch (true) {
+ case [1, 2].includes(number):
+ return 'green';
+ case [3].includes(number):
+ return 'yellow';
+ default:
+ return 'red';
+ }
+ };
+
+ const generateChevrons = () => {
+ const chevrons = Array.from({length: timesMoved}, (_, index) => (
+
+ ));
+ return chevrons;
+ };
+
+ return (
+
+
+ {
+ assignee &&
+ // Заменить на author после того как он появится
+ }
+
+
{generateChevrons()}
+
+ {editable && deletable && (
+
+
+ …
+
+
+ {!editMode && (
+
{
+ evt.preventDefault();
+ }}
+ >
+ Edit
+
+ )}
+
{
+ window.confirm(
+ 'Are you sure you want to delete this ActionItem?'
+ ) && handleDeleteClick();
+ }}
+ onMouseDown={(evt) => {
+ evt.preventDefault();
+ }}
+ >
+ Delete
+
+
+
+ )}
+
+
+
+ {body}
+
+ {editable && (
+
+
+
+
+ setActionItemAssignee(evt.target.value)}
+ >
+ Assigned to ...
+ {users.map((user) => {
+ return (
+
+ {user.nickname}
+
+ );
+ })}
+
+
+
+
+ post
+
+
+
+
+ )}
+
+ );
+};
+
+export default ActionItemBody;
diff --git a/app/javascript/components/action-item-body/index.js b/app/javascript/components/action-item-body/index.js
new file mode 100644
index 00000000..37e97064
--- /dev/null
+++ b/app/javascript/components/action-item-body/index.js
@@ -0,0 +1 @@
+export {default as ActionItemBody} from './action-item-body';
diff --git a/app/javascript/components/action-item-body/operations.gql b/app/javascript/components/action-item-body/operations.gql
new file mode 100644
index 00000000..2f855fc3
--- /dev/null
+++ b/app/javascript/components/action-item-body/operations.gql
@@ -0,0 +1,21 @@
+mutation destroyActionItemMutation($id: ID!) {
+ destroyActionItem(input: { id: $id }) {
+ id
+ errors {
+ fullMessages
+ }
+ }
+}
+
+mutation updateActionItemMutation($id: ID!, $body: String!, $assigneeId: ID) {
+ updateActionItem(
+ input: { id: $id, attributes: { body: $body, assigneeId: $assigneeId } }
+ ) {
+ actionItem {
+ id
+ }
+ errors {
+ fullMessages
+ }
+ }
+}
diff --git a/app/javascript/components/action-item-column/action-item-column.jsx b/app/javascript/components/action-item-column/action-item-column.jsx
new file mode 100644
index 00000000..fc7b150f
--- /dev/null
+++ b/app/javascript/components/action-item-column/action-item-column.jsx
@@ -0,0 +1,99 @@
+import React, {useState, useContext, useEffect} from 'react';
+import {useSubscription} from '@apollo/react-hooks';
+import {NewActionItemBody} from '../new-action-item-body';
+import {ActionItem} from '../action-item';
+import BoardSlugContext from '../../utils/board-slug-context';
+import {
+ actionItemAddedSubscription,
+ actionItemMovedSubscription,
+ actionItemDestroyedSubscription,
+ actionItemUpdatedSubscription
+} from './operations.gql';
+
+const ActionItemColumn = ({users, initItems}) => {
+ const boardSlug = useContext(BoardSlugContext);
+ const [items, setItems] = useState(initItems);
+ const [skip, setSkip] = useState(true); // Workaround for https://github.com/apollographql/react-apollo/issues/3802
+
+ useSubscription(actionItemUpdatedSubscription, {
+ skip,
+ onSubscriptionData: (options) => {
+ const {data} = options.subscriptionData;
+ const {actionItemUpdated} = data;
+ if (actionItemUpdated) {
+ updateItem(actionItemUpdated);
+ }
+ },
+ variables: {boardSlug}
+ });
+
+ useSubscription(actionItemAddedSubscription, {
+ skip,
+ onSubscriptionData: (options) => {
+ const {data} = options.subscriptionData;
+ const {actionItemAdded} = data;
+ if (actionItemAdded) {
+ setItems((oldItems) => [actionItemAdded, ...oldItems]);
+ }
+ },
+ variables: {boardSlug}
+ });
+
+ useSubscription(actionItemMovedSubscription, {
+ skip,
+ onSubscriptionData: (options) => {
+ const {data} = options.subscriptionData;
+ const {actionItemMoved} = data;
+ if (actionItemMoved) {
+ setItems((oldItems) => [actionItemMoved, ...oldItems]);
+ }
+ },
+ variables: {boardSlug}
+ });
+
+ useSubscription(actionItemDestroyedSubscription, {
+ skip,
+ onSubscriptionData: (options) => {
+ const {data} = options.subscriptionData;
+ const {actionItemDestroyed} = data;
+ if (actionItemDestroyed) {
+ setItems((oldItems) =>
+ oldItems.filter((element) => element.id !== actionItemDestroyed.id)
+ );
+ }
+ },
+ variables: {boardSlug}
+ });
+
+ useEffect(() => {
+ setSkip(false);
+ }, []);
+
+ const updateItem = (item) => {
+ setItems((oldItems) => {
+ const cardIdIndex = oldItems.findIndex(
+ (element) => element.id === item.id
+ );
+ if (cardIdIndex >= 0) {
+ return [
+ ...oldItems.slice(0, cardIdIndex),
+ item,
+ ...oldItems.slice(cardIdIndex + 1)
+ ];
+ }
+
+ return oldItems;
+ });
+ };
+
+ return (
+ <>
+
+ {items.map((item) => {
+ return ;
+ })}
+ >
+ );
+};
+
+export default ActionItemColumn;
diff --git a/app/javascript/components/action-item-column/index.js b/app/javascript/components/action-item-column/index.js
new file mode 100644
index 00000000..27ee131e
--- /dev/null
+++ b/app/javascript/components/action-item-column/index.js
@@ -0,0 +1 @@
+export {default as ActionItemColumn} from './action-item-column';
diff --git a/app/javascript/components/action-item-column/operations.gql b/app/javascript/components/action-item-column/operations.gql
new file mode 100644
index 00000000..30ba3b15
--- /dev/null
+++ b/app/javascript/components/action-item-column/operations.gql
@@ -0,0 +1,24 @@
+#import '../../fragments/action_item.graphql'
+subscription actionItemAddedSubscription($boardSlug: String!) {
+ actionItemAdded(boardSlug: $boardSlug) {
+ ...ActionItem
+ }
+}
+
+subscription actionItemMovedSubscription($boardSlug: String!) {
+ actionItemMoved(boardSlug: $boardSlug) {
+ ...ActionItem
+ }
+}
+
+subscription actionItemDestroyedSubscription($boardSlug: String!) {
+ actionItemDestroyed(boardSlug: $boardSlug) {
+ id
+ }
+}
+
+subscription actionItemUpdatedSubscription($boardSlug: String!) {
+ actionItemUpdated(boardSlug: $boardSlug) {
+ ...ActionItem
+ }
+}
diff --git a/app/javascript/components/action-item-footer/action-item-footer.jsx b/app/javascript/components/action-item-footer/action-item-footer.jsx
new file mode 100644
index 00000000..34aaca70
--- /dev/null
+++ b/app/javascript/components/action-item-footer/action-item-footer.jsx
@@ -0,0 +1,61 @@
+import React, {useContext} from 'react';
+import {moveActionItemMutation} from './operations.gql';
+import {useMutation} from '@apollo/react-hooks';
+import {TransitionButton} from '../transition-button';
+import BoardSlugContext from '../../utils/board-slug-context';
+import './style.less';
+const ActionItemFooter = ({id, isReopanable, isCompletable}) => {
+ const boardSlug = useContext(BoardSlugContext);
+ const [moveActionItem] = useMutation(moveActionItemMutation);
+ const handleMoveClick = () => {
+ moveActionItem({
+ variables: {
+ id,
+ boardSlug
+ }
+ }).then(({data}) => {
+ if (!data.moveActionItem.actionItem) {
+ console.log(data.moveActionItem.errors.fullMessages.join(' '));
+ }
+ });
+ };
+
+ return (
+
+ {isCompletable && (
+ <>
+
+
+ >
+ )}
+ {isReopanable && (
+
+ )}
+ {isCompletable && (
+ {
+ window.confirm('Are you sure you want to move this ActionItem?') &&
+ handleMoveClick();
+ }}
+ >
+ move
+
+ )}
+
+ );
+};
+
+export default ActionItemFooter;
diff --git a/app/javascript/components/action-item-footer/index.js b/app/javascript/components/action-item-footer/index.js
new file mode 100644
index 00000000..5c0bd9c7
--- /dev/null
+++ b/app/javascript/components/action-item-footer/index.js
@@ -0,0 +1 @@
+export {default as ActionItemFooter} from './action-item-footer';
diff --git a/app/javascript/components/action-item-footer/operations.gql b/app/javascript/components/action-item-footer/operations.gql
new file mode 100644
index 00000000..a4cd2568
--- /dev/null
+++ b/app/javascript/components/action-item-footer/operations.gql
@@ -0,0 +1,10 @@
+mutation moveActionItemMutation($id: ID!, $boardSlug: String!) {
+ moveActionItem(input: { id: $id, boardSlug: $boardSlug}) {
+ actionItem {
+ id
+ }
+ errors {
+ fullMessages
+ }
+ }
+}
diff --git a/app/javascript/components/action-item-footer/style.less b/app/javascript/components/action-item-footer/style.less
new file mode 100644
index 00000000..20e02db9
--- /dev/null
+++ b/app/javascript/components/action-item-footer/style.less
@@ -0,0 +1,28 @@
+.fa-chevron-right {
+ margin-right: -2px;
+ margin-left: -2px;
+ font-size: 16px;
+ stroke: white;
+ stroke-width: 10;
+}
+
+// .column-assignee {
+// align-self: center;
+// width: auto !important;
+// }
+
+.action-item__footer {
+ display: flex;
+ flex-direction: column;
+}
+
+.action-item__footer button {
+ padding: 0;
+ margin: 0 8px 20px 8px;
+ height: 37px;
+ color: #54554F;
+ background-color: transparent;
+ border: 1px solid #8D8E8A;
+ box-sizing: border-box;
+ border-radius: 4px;
+}
diff --git a/app/javascript/components/action-item-user/action-item-user.jsx b/app/javascript/components/action-item-user/action-item-user.jsx
new file mode 100644
index 00000000..e69de29b
diff --git a/app/javascript/components/action-item/action-item.jsx b/app/javascript/components/action-item/action-item.jsx
new file mode 100644
index 00000000..45421eb3
--- /dev/null
+++ b/app/javascript/components/action-item/action-item.jsx
@@ -0,0 +1,60 @@
+import React, {useContext} from 'react';
+import {ActionItemBody} from '../action-item-body';
+import {ActionItemFooter} from '../action-item-footer';
+import UserContext from '../../utils/user-context';
+import style from './style.module.less';
+import styleCard from '../card/style.module.less';
+
+const ActionItem = ({
+ id,
+ body,
+ status,
+ times_moved,
+ assignee,
+ users,
+ isPrevious
+}) => {
+ const pickColor = (number, isColor) => {
+ if (isColor) {
+ switch (number) {
+ case 0:
+ return style.green;
+ case 1:
+ return style.green;
+ case 2:
+ return style.green;
+ case 3:
+ return style.yellow;
+ default:
+ return style.red;
+ }
+ } else {
+ return ``;
+ }
+ };
+
+ const currentUser = useContext(UserContext);
+ const isStatusPending = status === 'pending';
+ return (
+
+
+ {isPrevious && (
+
+ )}
+
+ );
+};
+
+export default ActionItem;
diff --git a/app/javascript/components/action-item/index.js b/app/javascript/components/action-item/index.js
new file mode 100644
index 00000000..7ecff0b5
--- /dev/null
+++ b/app/javascript/components/action-item/index.js
@@ -0,0 +1 @@
+export {default as ActionItem} from './action-item';
diff --git a/app/javascript/components/action-item/style.module.less b/app/javascript/components/action-item/style.module.less
new file mode 100644
index 00000000..ebf21ac7
--- /dev/null
+++ b/app/javascript/components/action-item/style.module.less
@@ -0,0 +1,11 @@
+.green {
+ background-color: #E9FBF5;
+}
+
+.yellow {
+ background-color: #FDFAF4;
+}
+
+.red {
+ background-color: #FDF5F4;
+}
diff --git a/app/javascript/components/card-body/card-body.jsx b/app/javascript/components/card-body/card-body.jsx
new file mode 100644
index 00000000..77b987be
--- /dev/null
+++ b/app/javascript/components/card-body/card-body.jsx
@@ -0,0 +1,157 @@
+import React, {useEffect, useState} from 'react';
+import Textarea from 'react-textarea-autosize';
+import {useMutation} from '@apollo/react-hooks';
+import {updateCardMutation, destroyCardMutation} from './operations.gql';
+import {CardUser} from '../card-user';
+import style from './style.module.less';
+import styleButton from '../../less/button.module.less';
+import {Linkify, linkifyOptions} from '../../utils/linkify';
+
+const CardBody = ({author, id, editable, body, deletable}) => {
+ const [inputValue, setInputValue] = useState(body);
+ const [editMode, setEditMode] = useState(false);
+ const [showDropdown, setShowDropdown] = useState(false);
+ const [editCard] = useMutation(updateCardMutation);
+ const [destroyCard] = useMutation(destroyCardMutation);
+
+ useEffect(() => {
+ setInputValue(body);
+ }, [body]);
+
+ const handleEditClick = () => {
+ editModeToggle();
+ setShowDropdown(false);
+ };
+
+ const editModeToggle = () => {
+ setEditMode((isEdited) => !isEdited);
+ };
+
+ const handleChange = (evt) => {
+ setInputValue(evt.target.value);
+ };
+
+ const handleKeyPress = (evt) => {
+ if (navigator.platform.includes('Mac')) {
+ if (evt.key === 'Enter' && evt.metaKey) {
+ handleSaveClick();
+ }
+ } else if (evt.key === 'Enter' && evt.ctrlKey) {
+ handleSaveClick();
+ }
+ };
+
+ const handleSaveClick = () => {
+ editModeToggle();
+ editCard({
+ variables: {
+ id,
+ body: inputValue
+ }
+ }).then(({data}) => {
+ if (!data.updateCard.card) {
+ console.log(data.updateCard.errors.fullMessages.join(' '));
+ }
+ });
+ };
+
+ const handleCancel = (evt) => {
+ evt.preventDefault();
+ setEditMode(false);
+ setInputValue(body);
+ };
+
+ return (
+
+
+
+
+ {deletable && (
+
+
setShowDropdown(!showDropdown)}
+ onBlur={() => setShowDropdown(false)}
+ >
+ …
+
+
+ {!editMode && editable && (
+
{
+ event.preventDefault();
+ }}
+ >
+ Edit
+
+ )}
+
+ window.confirm(
+ 'Are you sure you want to delete this card?'
+ ) &&
+ destroyCard({
+ variables: {
+ id
+ }
+ }).then(({data}) => {
+ if (!data.destroyCard.id) {
+ console.log(
+ data.destroyCard.errors.fullMessages.join(' ')
+ );
+ }
+ })
+ }
+ onMouseDown={(event) => {
+ event.preventDefault();
+ }}
+ >
+ Delete
+
+
+
+ )}
+
+
+ {body}
+
+ {editMode && (
+ <>
+
+
+
+ cancel
+
+
+ post
+
+
+ >
+ )}
+
+ );
+};
+
+export default CardBody;
diff --git a/app/javascript/components/card-body/index.js b/app/javascript/components/card-body/index.js
new file mode 100644
index 00000000..25f503de
--- /dev/null
+++ b/app/javascript/components/card-body/index.js
@@ -0,0 +1 @@
+export {default as CardBody} from './card-body';
diff --git a/app/javascript/components/card-body/operations.gql b/app/javascript/components/card-body/operations.gql
new file mode 100644
index 00000000..24191aa2
--- /dev/null
+++ b/app/javascript/components/card-body/operations.gql
@@ -0,0 +1,23 @@
+mutation updateCardMutation($id: ID!, $body: String!) {
+ updateCard(
+ input: { id: $id, attributes: { body: $body } }
+ ) {
+ card {
+ id
+ }
+ errors {
+ fullMessages
+ }
+ }
+}
+
+mutation destroyCardMutation($id: ID!) {
+ destroyCard(
+ input: { id: $id }
+ ) {
+ id
+ errors {
+ fullMessages
+ }
+ }
+}
diff --git a/app/javascript/components/card-body/style.module.less b/app/javascript/components/card-body/style.module.less
new file mode 100644
index 00000000..cb10dae4
--- /dev/null
+++ b/app/javascript/components/card-body/style.module.less
@@ -0,0 +1,80 @@
+// .cardBody {
+// display: flex;
+// flex-direction: column;
+// align-items: center;
+// }
+
+.top {
+ display: flex;
+ justify-content: flex-end;
+ height: 24px;
+ margin: 8px 11px 16px 8px;
+}
+
+.cardText {
+ overflow-wrap: break-word;
+ white-space: pre-line;
+ margin: 0 8px 16px 8px;
+}
+
+.cardText a {
+ color: #8D8E8A;
+
+ &:visited {
+ color: #8D8E8A;
+ }
+}
+
+.textarea {
+ overflow: hidden;
+ resize: none;
+ width: calc(100% - 16px);
+ margin: 0 8px;
+ font-size: 14px;
+ line-height: 150%;
+ letter-spacing: -0.01em;
+ color: rgba(27, 29, 21, 0.25);
+ box-sizing: border-box;
+}
+
+.textarea:focus {
+ outline: none;
+ border: none;
+}
+
+.dropdown {
+ position: relative;
+}
+
+.dropdownButton {
+ width: 24px;
+ height: 24px;
+ text-align: center;
+ vertical-align: middle;
+ font-size: 20px;
+ font-weight: bold;
+ outline: none;
+ border-radius: 50%;
+ cursor: pointer;
+}
+
+.dropdownContent {
+ float: left;
+ position: absolute;
+ z-index: 10;
+ top: 20px;
+}
+
+.dropdownItem {
+ width: 100px;
+ height: 30px;
+ margin-bottom: 2px;
+ padding: 4px 8px;
+ background: rgba(27, 29, 21, 0.5);
+ border-radius: 4px;
+ font-size: 14px;
+ color: #FFFFFF;
+ cursor: pointer;
+}
+
+
diff --git a/app/javascript/components/card-column/card-column.jsx b/app/javascript/components/card-column/card-column.jsx
new file mode 100644
index 00000000..bf8a7bc9
--- /dev/null
+++ b/app/javascript/components/card-column/card-column.jsx
@@ -0,0 +1,130 @@
+import React, {useState, useContext, useEffect} from 'react';
+import {useSubscription} from '@apollo/react-hooks';
+import {Card} from '../card';
+// Import {CardPopup} from '../card-popup';
+import {
+ cardAddedSubscription,
+ cardDestroyedSubscription,
+ cardUpdatedSubscription
+} from './operations.gql';
+import UserContext from '../../utils/user-context';
+import BoardSlugContext from '../../utils/board-slug-context';
+import {NewCardBody} from '../new-card-body';
+
+const CardColumn = ({kind, initCards, smile}) => {
+ const currentUser = useContext(UserContext);
+ const boardSlug = useContext(BoardSlugContext);
+ const [cards, setCards] = useState(initCards);
+ const [skip, setSkip] = useState(true); // Workaround for https://github.com/apollographql/react-apollo/issues/3802
+ const [popupShownId, setPopupShownId] = useState(null);
+
+ const handleCommentButtonClick = (id) => () => setPopupShownId(id);
+ const handlePopupClose = () => setPopupShownId(null);
+
+ useSubscription(cardAddedSubscription, {
+ skip,
+ onSubscriptionData: (options) => {
+ const {data} = options.subscriptionData;
+ const {cardAdded} = data;
+ if (cardAdded) {
+ if (
+ cardAdded.kind === kind &&
+ cardAdded.author.id !== currentUser.id.toString()
+ ) {
+ setCards((oldCards) => [cardAdded, ...oldCards]);
+ }
+ }
+ },
+ variables: {boardSlug}
+ });
+
+ useSubscription(cardDestroyedSubscription, {
+ skip,
+ onSubscriptionData: (options) => {
+ const {data} = options.subscriptionData;
+ const {cardDestroyed} = data;
+ if (cardDestroyed && cardDestroyed.kind === kind) {
+ setCards((oldCards) =>
+ oldCards.filter((element) => element.id !== cardDestroyed.id)
+ );
+ }
+ },
+ variables: {boardSlug}
+ });
+
+ useSubscription(cardUpdatedSubscription, {
+ skip,
+ onSubscriptionData: (options) => {
+ const {data} = options.subscriptionData;
+ const {cardUpdated} = data;
+ if (cardUpdated && cardUpdated.kind === kind) {
+ setCards((oldCards) => {
+ const cardIdIndex = oldCards.findIndex(
+ (element) => element.id === cardUpdated.id
+ );
+ if (cardIdIndex >= 0) {
+ return [
+ ...oldCards.slice(0, cardIdIndex),
+ cardUpdated,
+ ...oldCards.slice(cardIdIndex + 1)
+ ];
+ }
+
+ return oldCards;
+ });
+ }
+ },
+ variables: {boardSlug}
+ });
+
+ useEffect(() => {
+ setSkip(false);
+ }, []);
+
+ //
+ // const card = cards.find((it) => it.id === popupShownId);
+
+ return (
+ <>
+ {
+ setCards((oldCards) => [cardAdded, ...oldCards]);
+ }}
+ onGetNewCardID={(cardMockid, cardId) => {
+ setCards((oldCards) => {
+ oldCards[
+ oldCards.findIndex((it) => it.id === cardMockid)
+ ].id = cardId;
+ return oldCards;
+ });
+ }}
+ />
+
+ {cards.map((card) => {
+ return (
+
+ );
+ })}
+
+ {/* {popupShownId && (
+ {}}
+ onClickClosed={handlePopupClose}
+ />
+ )} */}
+ >
+ );
+};
+
+export default CardColumn;
diff --git a/app/javascript/components/card-column/index.js b/app/javascript/components/card-column/index.js
new file mode 100644
index 00000000..878403b7
--- /dev/null
+++ b/app/javascript/components/card-column/index.js
@@ -0,0 +1 @@
+export {default as CardColumn} from './card-column';
diff --git a/app/javascript/components/card-column/operations.gql b/app/javascript/components/card-column/operations.gql
new file mode 100644
index 00000000..89ac7968
--- /dev/null
+++ b/app/javascript/components/card-column/operations.gql
@@ -0,0 +1,20 @@
+#import '../../fragments/card.graphql'
+
+subscription cardAddedSubscription($boardSlug: String!) {
+ cardAdded(boardSlug: $boardSlug) {
+ ...Card
+ }
+}
+
+subscription cardDestroyedSubscription($boardSlug: String!) {
+ cardDestroyed(boardSlug: $boardSlug) {
+ id
+ kind
+ }
+}
+
+subscription cardUpdatedSubscription($boardSlug: String!) {
+ cardUpdated(boardSlug: $boardSlug) {
+ ...Card
+ }
+}
diff --git a/app/javascript/components/card-footer/card-footer.jsx b/app/javascript/components/card-footer/card-footer.jsx
new file mode 100644
index 00000000..db3213cf
--- /dev/null
+++ b/app/javascript/components/card-footer/card-footer.jsx
@@ -0,0 +1,28 @@
+import React from 'react';
+import {Likes} from '../likes';
+import './style.less';
+
+const CardFooter = ({
+ id,
+ likes,
+ type,
+ commentsNumber,
+ onCommentButtonClick
+}) => {
+ return (
+
+ );
+};
+
+export default CardFooter;
diff --git a/app/javascript/components/card-footer/index.js b/app/javascript/components/card-footer/index.js
new file mode 100644
index 00000000..6f7a53e4
--- /dev/null
+++ b/app/javascript/components/card-footer/index.js
@@ -0,0 +1 @@
+export {default as CardFooter} from './card-footer';
diff --git a/app/javascript/components/card-footer/style.less b/app/javascript/components/card-footer/style.less
new file mode 100644
index 00000000..0757b06c
--- /dev/null
+++ b/app/javascript/components/card-footer/style.less
@@ -0,0 +1,86 @@
+.card-footer {
+ border: none;
+ height: 24px;
+ margin: 8px;
+ margin-top: 0;
+ display: flex;
+ justify-content: space-between;
+ font-size: 14px;
+
+ .card-footer__comments {
+ cursor: pointer;
+ }
+
+ a {
+ color: #54554F;
+ }
+}
+
+
+// aside.emoji-picker-react {
+// width: auto
+// }
+
+// .bottom-content {
+// align-self: flex-end;
+// text-align: right;
+// }
+
+// .comments-column {
+// padding: 0;
+// }
+
+// .dropdown-comments-menu {
+// padding-top: 4px;
+// z-index: 20;
+// }
+
+.dropdown-comments-content {
+ background-color: white;
+ border-radius: 4px;
+ box-shadow: 0 2px 3px rgba(10, 10, 10, 0.1), 0 0 0 1px rgba(10, 10, 10, 0.1);
+ padding-bottom: 0.5rem;
+ overflow-y: scroll;
+}
+
+// .dropdown-item {
+// border-top: 1px solid rgba(143, 143, 143, 0.5);
+// }
+
+// .dropdown-item:first-of-type{
+// border-top: 0;
+// }
+
+// .edit-panel-wrapper {
+// display: flex;
+// margin-top: 30px;
+// }
+
+// span {
+// word-break: break-word;
+// }
+
+// .textarea-container {
+// padding: 0;
+// position: relative;
+// }
+
+// .textarea-container textarea {
+// width: 100%;
+// height: 100%;
+// box-sizing: border-box;
+// border: 0;
+// }
+
+// .textarea-container button {
+// position: absolute;
+// bottom: 0;
+// right: 0;
+// background-color: #92B5FA;
+// }
+
+// .textarea-container a {
+// position: absolute;
+// bottom: 0;
+// left: 5px;
+// }
diff --git a/app/javascript/components/card-table.jsx b/app/javascript/components/card-table.jsx
new file mode 100644
index 00000000..773469b3
--- /dev/null
+++ b/app/javascript/components/card-table.jsx
@@ -0,0 +1,134 @@
+import React, {useState} from 'react';
+import {PrevActionItemColumn} from './prev-action-item-column';
+import {CardColumn} from './card-column';
+import {ActionItemColumn} from './action-item-column';
+import BoardSlugContext from '../utils/board-slug-context';
+import UserContext from '../utils/user-context';
+import {Provider} from './provider';
+import './style.less';
+
+const CardTable = ({
+ actionItems,
+ cardsByType,
+ creators,
+ initPrevItems,
+ user,
+ users
+}) => {
+ const EMOJIES = ['😡', '😔', '🤗'];
+
+ const [columnClass, setColumnClass] = useState('board-column');
+
+ const [displayPreviousItems, setDisplayPreviousItems] = useState(
+ initPrevItems.length > 0
+ );
+
+ const togglePreviousItemsOpened = () =>
+ setDisplayPreviousItems(!displayPreviousItems);
+
+ const previousActionsEmptyHandler = () => {
+ setDisplayPreviousItems(false);
+ setColumnClass('column is-one-fourth');
+ };
+
+ const generateColumns = (cardTypePairs) => {
+ const content = [];
+ for (const [index, [columnName, cards]] of Object.entries(
+ cardTypePairs
+ ).entries()) {
+ content.push(
+
+
+
+ );
+ }
+
+ return content;
+ };
+
+ const renderPreviousColumn = () => {
+ if (displayPreviousItems) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ };
+
+ user.isCreator = creators.includes(user.id);
+ return (
+
+
+
+
+ {renderPreviousColumn()}
+ {generateColumns(cardsByType)}
+
+
+
+
+
+ );
+};
+
+export default CardTable;
diff --git a/app/javascript/components/card-user-avatar/card-user-avatar.jsx b/app/javascript/components/card-user-avatar/card-user-avatar.jsx
new file mode 100644
index 00000000..fc62df8b
--- /dev/null
+++ b/app/javascript/components/card-user-avatar/card-user-avatar.jsx
@@ -0,0 +1,17 @@
+import React from 'react';
+import {getUserInitials} from '../../utils/helpers';
+import style from './style.module.less';
+
+const CardUserAvatar = ({avatar, firstName, lastName}) => {
+ if (avatar) {
+ return ;
+ }
+
+ return (
+
+ {getUserInitials(firstName, lastName)}
+
+ );
+};
+
+export default CardUserAvatar;
diff --git a/app/javascript/components/card-user-avatar/index.js b/app/javascript/components/card-user-avatar/index.js
new file mode 100644
index 00000000..ef67892a
--- /dev/null
+++ b/app/javascript/components/card-user-avatar/index.js
@@ -0,0 +1 @@
+export {default as CardUserAvatar} from './card-user-avatar';
diff --git a/app/javascript/components/card-user-avatar/style.module.less b/app/javascript/components/card-user-avatar/style.module.less
new file mode 100644
index 00000000..27c6ef96
--- /dev/null
+++ b/app/javascript/components/card-user-avatar/style.module.less
@@ -0,0 +1,22 @@
+.avatar {
+ width: 24px;
+ height: 24px;
+ box-sizing: border-box;
+ border-radius: 1px;
+ border-radius: 50%;
+}
+
+.avatarText {
+ width: 24px;
+ height: 24px;
+ box-sizing: border-box;
+ border-radius: 1px;
+ border-radius: 50%;
+ border: 2px solid #dbdbdb;
+ font-weight: 700;
+ text-align: center;
+ font-size: 12px;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+}
diff --git a/app/javascript/components/card-user/card-user.jsx b/app/javascript/components/card-user/card-user.jsx
new file mode 100644
index 00000000..c1dc146b
--- /dev/null
+++ b/app/javascript/components/card-user/card-user.jsx
@@ -0,0 +1,19 @@
+import React from 'react';
+import {CardUserAvatar} from '../card-user-avatar';
+import './style.less';
+
+const CardUser = ({first_name, last_name, nickname, avatar}) => {
+ const url = avatar?.thumb?.url;
+ return (
+
+
+ {nickname}
+
+ );
+};
+
+export default CardUser;
diff --git a/app/javascript/components/card-user/index.js b/app/javascript/components/card-user/index.js
new file mode 100644
index 00000000..84fc464f
--- /dev/null
+++ b/app/javascript/components/card-user/index.js
@@ -0,0 +1 @@
+export {default as CardUser} from './card-user';
diff --git a/app/javascript/components/card-user/style.less b/app/javascript/components/card-user/style.less
new file mode 100644
index 00000000..894fe76e
--- /dev/null
+++ b/app/javascript/components/card-user/style.less
@@ -0,0 +1,8 @@
+.avatar__container {
+ display: flex;
+ margin-right: auto;
+}
+
+.avatar__nickname {
+ margin-left: 8px;
+}
diff --git a/app/javascript/components/card/card.jsx b/app/javascript/components/card/card.jsx
new file mode 100644
index 00000000..54a312a6
--- /dev/null
+++ b/app/javascript/components/card/card.jsx
@@ -0,0 +1,66 @@
+import React, {useContext, useMemo} from 'react';
+import UserContext from '../../utils/user-context';
+import {CardBody} from '../card-body';
+import {CardFooter} from '../card-footer';
+import {CommentsDropdown} from '../comments-dropdown';
+import style from './style.module.less';
+
+const Card = ({
+ id,
+ body,
+ author,
+ comments,
+ likes,
+ type,
+ isCommentsShown,
+ onClickClosed,
+ onCommentButtonClick
+}) => {
+ const currentUser = useContext(UserContext);
+
+ const isTemporaryId = (id) => {
+ return id.toString().startsWith('tmp-');
+ };
+
+ const isCurrentUserAuthor =
+ currentUser.id.toString() === author.id.toString(); // Temporary solution for matching data types (after edit card it will still availible to edit)
+
+ const editable = useMemo(() => !isTemporaryId(id) && isCurrentUserAuthor, [
+ id,
+ isCurrentUserAuthor
+ ]);
+
+ const deletable = useMemo(
+ () => !isTemporaryId(id) && (isCurrentUserAuthor || currentUser.isCreator),
+ [id, isCurrentUserAuthor]
+ );
+
+ return (
+
+
+
+
+ {isCommentsShown && (
+
+ )}
+
+ );
+};
+
+export default Card;
diff --git a/app/javascript/components/card/index.js b/app/javascript/components/card/index.js
new file mode 100644
index 00000000..294627fb
--- /dev/null
+++ b/app/javascript/components/card/index.js
@@ -0,0 +1 @@
+export {default as Card} from './card';
diff --git a/app/javascript/components/card/style.module.less b/app/javascript/components/card/style.module.less
new file mode 100644
index 00000000..a5fde99b
--- /dev/null
+++ b/app/javascript/components/card/style.module.less
@@ -0,0 +1,19 @@
+
+.card {
+ border-radius: 4px;
+ font-family: "Commissioner";
+ font-size: 14px;
+ line-height: 150%;
+ letter-spacing: 0.015em;
+ color: #1B1D15;
+ box-shadow: none;
+}
+
+.cardColor {
+ background-color: #fafafa;
+}
+
+.card:not(:last-child) {
+ margin-bottom: 16px;
+}
+
diff --git a/app/javascript/components/comment-likes/comment-likes.jsx b/app/javascript/components/comment-likes/comment-likes.jsx
new file mode 100644
index 00000000..f7937213
--- /dev/null
+++ b/app/javascript/components/comment-likes/comment-likes.jsx
@@ -0,0 +1,61 @@
+import React, {useState, useEffect} from 'react';
+import {useMutation} from '@apollo/react-hooks';
+import {likeCommentMutation} from './operations.gql';
+const EMOJI = '👍';
+
+const CommentLikes = ({likes, id}) => {
+ const [likeComment] = useMutation(likeCommentMutation);
+ const [style, setStyle] = useState('has-text-info');
+ const [timer, setTimer] = useState(null);
+
+ useEffect(() => {
+ return function () {
+ clearInterval(timer);
+ };
+ }, [timer]);
+
+ const addLike = () => {
+ likeComment({
+ variables: {
+ id
+ }
+ }).then(({data}) => {
+ if (!data.likeComment.comment) {
+ console.log(data.likeComment.errors.fullMessages.join(' '));
+ }
+ });
+ };
+
+ const handleMouseDown = () => {
+ setStyle({style: 'has-text-success'});
+ addLike();
+ const timer = setInterval(() => addLike(), 300);
+ setTimer(timer);
+ };
+
+ const handleMouseUp = (currentTimer) => {
+ setStyle('has-text-info');
+ clearInterval(currentTimer);
+ };
+
+ const handleMouseLeave = (currentTimer) => {
+ setStyle('has-text-info');
+ clearInterval(currentTimer);
+ };
+
+ return (
+ <>
+ handleMouseUp(timer)}
+ onMouseLeave={() => handleMouseLeave(timer)}
+ >
+ {EMOJI}
+
+ {likes}
+ >
+ );
+};
+
+export default CommentLikes;
diff --git a/app/javascript/components/comment-likes/index.js b/app/javascript/components/comment-likes/index.js
new file mode 100644
index 00000000..7b2e905e
--- /dev/null
+++ b/app/javascript/components/comment-likes/index.js
@@ -0,0 +1 @@
+export {default as CommentLikes} from './comment-likes';
diff --git a/app/javascript/components/comment-likes/operations.gql b/app/javascript/components/comment-likes/operations.gql
new file mode 100644
index 00000000..5b1a84cd
--- /dev/null
+++ b/app/javascript/components/comment-likes/operations.gql
@@ -0,0 +1,13 @@
+#import '../../fragments/card.graphql'
+mutation likeCommentMutation($id: ID!) {
+ likeComment(
+ input: { id: $id }
+ ) {
+ comment {
+ id
+ }
+ errors {
+ fullMessages
+ }
+ }
+}
diff --git a/app/javascript/components/comment/comment.jsx b/app/javascript/components/comment/comment.jsx
new file mode 100644
index 00000000..48070171
--- /dev/null
+++ b/app/javascript/components/comment/comment.jsx
@@ -0,0 +1,176 @@
+import React, {useState, useEffect} from 'react';
+import TextareaAutosize from 'react-textarea-autosize';
+import Picker from 'emoji-picker-react';
+//
+// import {CommentLikes} from '../comment-likes';
+import {CardUser} from '../card-user';
+import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
+import {faSmile} from '@fortawesome/free-regular-svg-icons';
+//
+// import {useMutation} from '@apollo/react-hooks';
+//
+// import {destroyCommentMutation, updateCommentMutation} from './operations.gql';
+import {Linkify, linkifyOptions} from '../../utils/linkify';
+import './style.less';
+
+const Comment = ({comment, editable}) => {
+ //
+ // id, deletable
+ const [editMode, setEditMode] = useState(false);
+ const [showEmojiPicker, setShowEmojiPicker] = useState(false);
+ //
+ // const [isDropdownShown, setIsDropdownShown] = useState(false);
+ const [inputValue, setInputValue] = useState(comment.content);
+ //
+ // const [destroyComment] = useMutation(destroyCommentMutation);
+ // const [updateComment] = useMutation(updateCommentMutation);
+
+ useEffect(() => {
+ if (inputValue !== comment.content) {
+ setInputValue(comment.content);
+ }
+ }, [comment.content]);
+
+ const editModeToggle = () => {
+ setEditMode(!editMode);
+ };
+
+ const handleChange = (evt) => {
+ setInputValue(evt.target.value);
+ };
+
+ //
+ // const handleEditClick = () => {
+ // editModeToggle();
+ // setIsDropdownShown(false);
+ // };
+
+ const handleSmileClick = () => {
+ setShowEmojiPicker(!showEmojiPicker);
+ };
+
+ const handleEmojiPickerClick = (_, emoji) => {
+ setInputValue((comment) => `${comment}${emoji.emoji}`);
+ };
+
+ //
+ // const handleKeyPress = (evt) => {
+ // if (evt.key === 'Enter' && !evt.shiftKey) {
+ // editModeToggle();
+ // updateComment({
+ // variables: {
+ // id,
+ // content: evt.target.value
+ // }
+ // }).then(({data}) => {
+ // if (!data.updateComment.comment) {
+ // setInputValue(comment.content);
+ // console.log(data.updateComment.errors.fullMessages.join(' '));
+ // }
+ // });
+ // setShowEmojiPicker(false);
+ // }
+ // };
+
+ //
+ // const removeComment = () => {
+ // destroyComment({
+ // variables: {
+ // id
+ // }
+ // }).then(({data}) => {
+ // if (!data.destroyComment.id) {
+ // console.log(data.destroyComment.errors.fullMessages.join(' '));
+ // }
+ // });
+ // };
+
+ return (
+ <>
+ {/* */}
+ {/* {editable && deletable && (
+
+
setIsDropdownShown(!isDropdownShown)}
+ onBlur={() => setIsDropdownShown(false)}
+ >
+ …
+
+
+
+ )} */}
+ {!editMode && (
+ <>
+
+
+
+
+
+ {comment.content}
+
+
+ {/*
+
+
+
+
+
+
by {comment.author.email.split('@')[0]}
+
+
*/}
+ >
+ )}
+ {editMode && (
+ <>
+
+
+
+
+ >
+ )}
+ {/*
*/}
+ {showEmojiPicker && (
+
+ )}
+ >
+ );
+};
+
+export default Comment;
diff --git a/app/javascript/components/comment/index.js b/app/javascript/components/comment/index.js
new file mode 100644
index 00000000..10fa5489
--- /dev/null
+++ b/app/javascript/components/comment/index.js
@@ -0,0 +1 @@
+export {default as Comment} from './comment';
diff --git a/app/javascript/components/comment/operations.gql b/app/javascript/components/comment/operations.gql
new file mode 100644
index 00000000..b0503124
--- /dev/null
+++ b/app/javascript/components/comment/operations.gql
@@ -0,0 +1,19 @@
+mutation destroyCommentMutation($id: ID!) {
+ destroyComment(input: { id: $id }) {
+ id
+ errors {
+ fullMessages
+ }
+ }
+}
+
+mutation updateCommentMutation($id: ID!, $content: String!) {
+ updateComment(input: { id: $id, attributes: { content: $content } }) {
+ comment {
+ id
+ }
+ errors {
+ fullMessages
+ }
+ }
+}
diff --git a/app/javascript/components/comment/style.less b/app/javascript/components/comment/style.less
new file mode 100644
index 00000000..92d9e276
--- /dev/null
+++ b/app/javascript/components/comment/style.less
@@ -0,0 +1,7 @@
+.comment {
+ margin-bottom: 18px;
+}
+
+.comment-text {
+ margin-left: 32px;
+}
diff --git a/app/javascript/components/comments-dropdown/comments-dropdown.jsx b/app/javascript/components/comments-dropdown/comments-dropdown.jsx
new file mode 100644
index 00000000..5781c344
--- /dev/null
+++ b/app/javascript/components/comments-dropdown/comments-dropdown.jsx
@@ -0,0 +1,126 @@
+import React, {useRef, useState, useContext, useEffect} from 'react';
+import Picker from 'emoji-picker-react';
+import Textarea from 'react-textarea-autosize';
+import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
+import {faSmile} from '@fortawesome/free-regular-svg-icons';
+import UserContext from '../../utils/user-context';
+import {Comment} from '../comment';
+import {useMutation} from '@apollo/react-hooks';
+import {addCommentMutation} from './operations.gql';
+import './style.less';
+
+const CommentsDropdown = ({id, comments, onClickClosed}) => {
+ const controlElement = useRef(null);
+ const textInput = useRef();
+ const [showEmojiPicker, setShowEmojiPicker] = useState(false);
+ const [isError, setIsError] = useState(false);
+ const user = useContext(UserContext);
+ const [newComment, setNewComment] = useState('');
+ const [addComment] = useMutation(addCommentMutation);
+
+ useEffect(() => {
+ textInput.current.focus();
+ }, []);
+
+ const handleErrorSubmit = () => {
+ setNewComment('');
+ setIsError(true);
+ };
+
+ const handleSuccessSubmit = () => {
+ setNewComment('');
+ setIsError(false);
+ };
+
+ const handleSubmit = () => {
+ controlElement.current.disabled = true;
+ addComment({
+ variables: {
+ cardId: id,
+ content: newComment
+ }
+ }).then(({data}) => {
+ if (data.addComment.comment) {
+ handleSuccessSubmit();
+ } else {
+ console.log(data.addComment.errors.fullMessages.join(' '));
+ handleErrorSubmit();
+ }
+ });
+ controlElement.current.disabled = false;
+ setShowEmojiPicker(false);
+ };
+
+ const handleSmileClick = () => {
+ setShowEmojiPicker((isShown) => !isShown);
+ };
+
+ const handleEmojiPickerClick = (_, emoji) => {
+ setNewComment((comment) => `${comment}${emoji.emoji}`);
+ };
+
+ const handleKeyPress = (evt) => {
+ if (navigator.platform.includes('Mac')) {
+ if (evt.key === 'Enter' && evt.metaKey) {
+ handleSubmit(evt);
+ }
+ } else if (evt.key === 'Enter' && evt.ctrlKey) {
+ handleSubmit(evt);
+ }
+
+ if (evt.key === 'Escape') {
+ onClickClosed();
+ }
+ };
+
+ return (
+
+
+ {comments.map((item) => (
+
+ ))}
+
+
+
+ onClickClosed()}
+ >
+ hide discussion
+
+ handleSubmit(newComment)}
+ >
+ post
+
+
+ {showEmojiPicker && (
+
+ )}
+
+ );
+};
+
+export default CommentsDropdown;
diff --git a/app/javascript/components/comments-dropdown/index.js b/app/javascript/components/comments-dropdown/index.js
new file mode 100644
index 00000000..1aa022e2
--- /dev/null
+++ b/app/javascript/components/comments-dropdown/index.js
@@ -0,0 +1 @@
+export {default as CommentsDropdown} from './comments-dropdown';
diff --git a/app/javascript/components/comments-dropdown/operations.gql b/app/javascript/components/comments-dropdown/operations.gql
new file mode 100644
index 00000000..f7b597e1
--- /dev/null
+++ b/app/javascript/components/comments-dropdown/operations.gql
@@ -0,0 +1,12 @@
+mutation addCommentMutation($cardId: ID!, $content: String!) {
+ addComment(
+ input: { attributes: { cardId: $cardId, content: $content } }
+ ) {
+ comment {
+ id
+ }
+ errors {
+ fullMessages
+ }
+ }
+}
diff --git a/app/javascript/components/comments-dropdown/style.less b/app/javascript/components/comments-dropdown/style.less
new file mode 100644
index 00000000..0506e036
--- /dev/null
+++ b/app/javascript/components/comments-dropdown/style.less
@@ -0,0 +1,46 @@
+.comments {
+ margin: 0 8px;
+}
+
+// .comments__wrapper {
+// border-bottom: 1px solid #E8E8E8;
+// }
+
+.new-comment {
+ position: relative;
+ margin-top: 16px;
+}
+
+.new-comment__textarea {
+ width: 100%;
+ resize: none;
+ border: none;
+ background: #FFFFFF;
+ border-radius: 4px;
+ margin-bottom: 16px;
+ padding: 2px 2px 2px 25px;
+}
+
+.new-comment__smile {
+ position: absolute;
+ left: 7px;
+ top: 0;
+ color: #209cee;
+}
+
+.new-comment__buttons {
+ display: flex;
+ justify-content: flex-end;
+ margin-bottom: 16px;
+}
+
+.new-comment__buttons__item {
+ border: none;
+ background-color: transparent;
+ cursor: pointer;
+}
+
+.new-comment__buttons__item--hide {
+ color: #8D8E8A;
+ margin-right: 34px;
+}
diff --git a/app/javascript/components/invite-block-container.jsx b/app/javascript/components/invite-block-container.jsx
new file mode 100644
index 00000000..b0ac3bfc
--- /dev/null
+++ b/app/javascript/components/invite-block-container.jsx
@@ -0,0 +1,16 @@
+import React from 'react';
+import {InviteBlock} from './invite-block';
+import {Provider} from './provider';
+import BoardSlugContext from '../utils/board-slug-context';
+
+const InviteBlockContainer = () => {
+ return (
+
+
+
+
+
+ );
+};
+
+export default InviteBlockContainer;
diff --git a/app/javascript/components/invite-block/index.js b/app/javascript/components/invite-block/index.js
new file mode 100644
index 00000000..a7fe57db
--- /dev/null
+++ b/app/javascript/components/invite-block/index.js
@@ -0,0 +1 @@
+export {default as InviteBlock} from './invite-block';
diff --git a/app/javascript/components/invite-block/invite-block.jsx b/app/javascript/components/invite-block/invite-block.jsx
new file mode 100644
index 00000000..fc41d2f2
--- /dev/null
+++ b/app/javascript/components/invite-block/invite-block.jsx
@@ -0,0 +1,115 @@
+import React, {useState, useEffect, useContext} from 'react';
+import Select from 'react-select';
+import {useMutation, useQuery, useLazyQuery} from '@apollo/react-hooks';
+import {
+ getMembershipsQuery,
+ getSuggestionsQuery,
+ inviteMembersMutation
+} from './operations.gql';
+import User from '../user/user';
+import BoardSlugContext from '../../utils/board-slug-context';
+
+const InviteBlock = () => {
+ const boardSlug = useContext(BoardSlugContext);
+ const [memberships, setMemberships] = useState([]);
+ const [selectedOption, setSelectedOption] = useState(null);
+ const [options, setOptions] = useState([]);
+ const [skipQuery, setSkipQuery] = useState(false);
+ const {loading: membershipLoading, data: membershipData} = useQuery(
+ getMembershipsQuery,
+ {
+ variables: {boardSlug},
+ skip: skipQuery
+ }
+ );
+
+ const [getSuggestions] = useLazyQuery(getSuggestionsQuery, {
+ onCompleted: ({suggestions}) => {
+ const suggestionsArray = [
+ ...new Set(suggestions.users.concat(suggestions.teams))
+ ];
+ const optionsArray = suggestionsArray.map((a) => {
+ return {
+ value: a,
+ label: a
+ };
+ });
+ setOptions(optionsArray);
+ }
+ });
+
+ useEffect(() => {
+ if (!membershipLoading && Boolean(membershipData)) {
+ const {memberships} = membershipData;
+ setMemberships(memberships);
+ setSkipQuery(true);
+ }
+ }, [membershipData, membershipLoading]);
+
+ const [inviteMembers] = useMutation(inviteMembersMutation);
+
+ const handleSubmit = (evt) => {
+ evt.preventDefault();
+ inviteMembers({
+ variables: {
+ email: selectedOption.map((a) => a.value).toString(),
+ boardSlug
+ }
+ }).then(({data}) => {
+ if (data.inviteMembers.memberships) {
+ const {memberships} = data.inviteMembers;
+ setMemberships((oldMemberships) => [
+ ...new Set(oldMemberships.concat(memberships))
+ ]);
+ setSelectedOption(null);
+ } else {
+ console.log(data.inviteMembers.errors.fullMessages.join(' '));
+ setSelectedOption(null);
+ }
+ });
+ };
+
+ const handleChange = (selectedOption) => {
+ setSelectedOption(selectedOption);
+ };
+
+ const usersListComponent = memberships.map((membership) => {
+ return (
+
+ );
+ });
+ const components = {
+ DropdownIndicator: null
+ };
+ return (
+ <>
+ users on this board:
+ {usersListComponent}
+
+ >
+ );
+};
+
+export default InviteBlock;
diff --git a/app/javascript/components/invite-block/operations.gql b/app/javascript/components/invite-block/operations.gql
new file mode 100644
index 00000000..7415b99c
--- /dev/null
+++ b/app/javascript/components/invite-block/operations.gql
@@ -0,0 +1,24 @@
+#import '../../fragments/membership.graphql'
+mutation inviteMembersMutation($boardSlug: String!, $email: String!) {
+ inviteMembers(input: { boardSlug: $boardSlug, email: $email }) {
+ memberships {
+ ...Membership
+ }
+ errors {
+ fullMessages
+ }
+ }
+}
+
+query getMembershipsQuery($boardSlug: String!) {
+ memberships(boardSlug: $boardSlug) {
+ ...Membership
+ }
+}
+
+query getSuggestionsQuery($autocomplete: String!) {
+ suggestions(autocomplete: $autocomplete) {
+ users
+ teams
+ }
+}
diff --git a/app/javascript/components/likes/index.js b/app/javascript/components/likes/index.js
new file mode 100644
index 00000000..bf889af1
--- /dev/null
+++ b/app/javascript/components/likes/index.js
@@ -0,0 +1 @@
+export {default as Likes} from './likes';
diff --git a/app/javascript/components/likes/likes.jsx b/app/javascript/components/likes/likes.jsx
new file mode 100644
index 00000000..0f83dc2d
--- /dev/null
+++ b/app/javascript/components/likes/likes.jsx
@@ -0,0 +1,66 @@
+import React, {useState, useEffect} from 'react';
+import {useMutation} from '@apollo/react-hooks';
+import {likeCardMutation} from './operations.gql';
+const EMOJIES = {
+ mad: '😡',
+ sad: '😔',
+ glad: '🤗',
+ universal: '👍'
+};
+
+const Likes = ({type, likes, id}) => {
+ const [likeCard] = useMutation(likeCardMutation);
+ const [style, setStyle] = useState('has-text-info');
+ const [timer, setTimer] = useState(null);
+
+ useEffect(() => {
+ return function () {
+ clearInterval(timer);
+ };
+ }, [timer]);
+
+ const addLike = () => {
+ likeCard({
+ variables: {
+ id
+ }
+ }).then(({data}) => {
+ if (!data.likeCard.card) {
+ console.log(data.likeCard.errors.fullMessages.join(' '));
+ }
+ });
+ };
+
+ const handleMouseDown = () => {
+ setStyle({style: 'has-text-success'});
+ addLike();
+ const timer = setInterval(() => addLike(), 300);
+ setTimer(timer);
+ };
+
+ const handleMouseUp = (currentTimer) => {
+ setStyle('has-text-info');
+ clearInterval(currentTimer);
+ };
+
+ const handleMouseLeave = (currentTimer) => {
+ setStyle('has-text-info');
+ clearInterval(currentTimer);
+ };
+
+ return (
+ <>
+ handleMouseUp(timer)}
+ onMouseLeave={() => handleMouseLeave(timer)}
+ >
+ {EMOJIES[type] || EMOJIES.universal}
+
+ {likes}
+ >
+ );
+};
+
+export default Likes;
diff --git a/app/javascript/components/likes/operations.gql b/app/javascript/components/likes/operations.gql
new file mode 100644
index 00000000..6d5884d2
--- /dev/null
+++ b/app/javascript/components/likes/operations.gql
@@ -0,0 +1,13 @@
+#import '../../fragments/card.graphql'
+mutation likeCardMutation($id: ID!) {
+ likeCard(
+ input: { id: $id }
+ ) {
+ card {
+ id
+ }
+ errors {
+ fullMessages
+ }
+ }
+}
diff --git a/app/javascript/components/membership-list-container.jsx b/app/javascript/components/membership-list-container.jsx
new file mode 100644
index 00000000..ece1d1d0
--- /dev/null
+++ b/app/javascript/components/membership-list-container.jsx
@@ -0,0 +1,16 @@
+import React from 'react';
+import {MembershipList} from './membership-list';
+import {Provider} from './provider';
+import BoardSlugContext from '../utils/board-slug-context';
+
+const MembershipListContainer = () => {
+ return (
+
+
+
+
+
+ );
+};
+
+export default MembershipListContainer;
diff --git a/app/javascript/components/membership-list/index.js b/app/javascript/components/membership-list/index.js
new file mode 100644
index 00000000..5faf322d
--- /dev/null
+++ b/app/javascript/components/membership-list/index.js
@@ -0,0 +1 @@
+export {default as MembershipList} from './membership-list';
diff --git a/app/javascript/components/membership-list/membership-list.jsx b/app/javascript/components/membership-list/membership-list.jsx
new file mode 100644
index 00000000..a04df716
--- /dev/null
+++ b/app/javascript/components/membership-list/membership-list.jsx
@@ -0,0 +1,203 @@
+import React, {useState, useEffect, useContext} from 'react';
+import {useSubscription, useQuery} from '@apollo/react-hooks';
+import {
+ getMembershipsQuery,
+ membershipUpdatedSubscription,
+ membershipListUpdatedSubscription,
+ membershipDestroyedSubscription
+} from './operations.gql';
+import User from '../user/user';
+import BoardSlugContext from '../../utils/board-slug-context';
+import './style.less';
+
+const MembershipList = () => {
+ const boardSlug = useContext(BoardSlugContext);
+ const [memberships, setMemberships] = useState([]);
+ const [skipMutation, setSkipMutation] = useState(true);
+ const [skipQuery, setSkipQuery] = useState(false);
+ const {loading, data} = useQuery(getMembershipsQuery, {
+ variables: {boardSlug},
+ skip: skipQuery
+ });
+
+ //
+ // const calcMembersReady = (users) => {
+ // let readyAmount = 0;
+ // for (const user of users) {
+ // if (user.ready) {
+ // readyAmount++;
+ // }
+ // }
+
+ // return readyAmount;
+ // };
+ // const readyMembers = fillerReadyMembers();
+ // const notReadyMembers = fillerReadyMembers(false);
+
+ // ! const filterNotReadyUsers = (users) => {
+ // return users.filter((it) => it.ready === false);
+ // }
+
+ useEffect(() => {
+ if (!loading && Boolean(data)) {
+ const {memberships} = data;
+ setMemberships(memberships);
+ setSkipQuery(true);
+ }
+ }, [data, loading]);
+
+ useSubscription(membershipDestroyedSubscription, {
+ skip: skipMutation,
+ onSubscriptionData: (options) => {
+ const {data} = options.subscriptionData;
+ const {membershipDestroyed} = data;
+ if (membershipDestroyed) {
+ const {id} = membershipDestroyed;
+ setMemberships(memberships.filter((it) => it.id !== id));
+ }
+ },
+ variables: {boardSlug}
+ });
+
+ useSubscription(membershipUpdatedSubscription, {
+ skip: skipMutation,
+ onSubscriptionData: (options) => {
+ const {data} = options.subscriptionData;
+ const {membershipUpdated} = data;
+ if (membershipUpdated) {
+ const {id} = membershipUpdated;
+ setMemberships((memberships) => {
+ const objectIndex = memberships.findIndex((it) => it.id === id);
+ return [
+ ...memberships.slice(0, objectIndex),
+ membershipUpdated,
+ ...memberships.slice(objectIndex + 1)
+ ];
+ });
+ }
+ },
+ variables: {boardSlug}
+ });
+
+ useSubscription(membershipListUpdatedSubscription, {
+ skip: skipMutation,
+ onSubscriptionData: (options) => {
+ const {data} = options.subscriptionData;
+ const {membershipListUpdated} = data;
+ if (membershipListUpdated) {
+ setMemberships((memberships) =>
+ memberships.concat(membershipListUpdated)
+ );
+ }
+ },
+ variables: {boardSlug}
+ });
+
+ useEffect(() => {
+ setSkipMutation(false);
+ }, []);
+
+ // !
+ // const usersListComponent = memberships.map((membership) => {
+ // return (
+ //
+ // );
+ // });
+
+ const renderMembersList = (users) => {
+ const fillerReadyMembers = (isReady = true) => {
+ return users.filter((it) => it.ready === isReady);
+ };
+
+ const readyMembers = fillerReadyMembers();
+ const notReadyMembers = fillerReadyMembers(false);
+
+ if (readyMembers.length === 0) {
+ return (
+
+
+ {users.length} people here want to share
+
+
+ {users.slice(0, 5).map((user) => {
+ return (
+
+ );
+ })}
+
+
+ );
+ }
+
+ if (readyMembers.length === users.length) {
+ return (
+
+
+ {users.length} ready
+
+
+ {readyMembers.slice(0, 5).map((user) => {
+ return (
+
+ );
+ })}
+
+
+ );
+ }
+
+ return (
+
+
+ {readyMembers.slice(0, 5).map((user) => {
+ return (
+
+ );
+ })}
+
+
+ {readyMembers.length} ready
+
+
+ and waiting for {notReadyMembers.length} more
+
+
+ {notReadyMembers.slice(0, 5).map((user) => {
+ return (
+
+ );
+ })}
+
+
+ );
+ };
+
+ return renderMembersList(memberships);
+};
+
+export default MembershipList;
diff --git a/app/javascript/components/membership-list/operations.gql b/app/javascript/components/membership-list/operations.gql
new file mode 100644
index 00000000..4fec9811
--- /dev/null
+++ b/app/javascript/components/membership-list/operations.gql
@@ -0,0 +1,24 @@
+#import '../../fragments/membership.graphql'
+subscription membershipUpdatedSubscription($boardSlug: String!) {
+ membershipUpdated(boardSlug: $boardSlug) {
+ ...Membership
+ }
+}
+
+subscription membershipListUpdatedSubscription($boardSlug: String!) {
+ membershipListUpdated(boardSlug: $boardSlug) {
+ ...Membership
+ }
+}
+
+subscription membershipDestroyedSubscription($boardSlug: String!) {
+ membershipDestroyed(boardSlug: $boardSlug) {
+ id
+ }
+}
+
+query getMembershipsQuery($boardSlug: String!){
+ memberships(boardSlug: $boardSlug) {
+ ...Membership
+ }
+}
diff --git a/app/javascript/components/membership-list/style.less b/app/javascript/components/membership-list/style.less
new file mode 100644
index 00000000..75bf1803
--- /dev/null
+++ b/app/javascript/components/membership-list/style.less
@@ -0,0 +1,18 @@
+.users {
+ display: flex;
+}
+
+.users__text {
+ display: flex;
+ align-items: center;
+ // margin-right: 16px;
+}
+
+.users__text--ready {
+ margin-right: 5px;
+}
+
+.avatars {
+ display: flex;
+ margin: 0 16px;
+}
diff --git a/app/javascript/components/new-action-item-body/index.js b/app/javascript/components/new-action-item-body/index.js
new file mode 100644
index 00000000..d0209f1d
--- /dev/null
+++ b/app/javascript/components/new-action-item-body/index.js
@@ -0,0 +1 @@
+export {default as NewActionItemBody} from './new-action-item-body';
diff --git a/app/javascript/components/new-action-item-body/new-action-item-body.jsx b/app/javascript/components/new-action-item-body/new-action-item-body.jsx
new file mode 100644
index 00000000..32c2a7bc
--- /dev/null
+++ b/app/javascript/components/new-action-item-body/new-action-item-body.jsx
@@ -0,0 +1,121 @@
+import React, {useState, useContext, useEffect, useRef} from 'react';
+import {useMutation} from '@apollo/react-hooks';
+import Textarea from 'react-textarea-autosize';
+import {addActionItemMutation} from './operations.gql';
+import BoardSlugContext from '../../utils/board-slug-context';
+import style from '../prev-action-item-column/style.module.less';
+
+const NewActionItemBody = ({users}) => {
+ const textInput = useRef();
+ const [isOpened, setOpened] = useState(false);
+ const [newActionItemBody, setNewActionItemBody] = useState('');
+ const [newActionItemAssignee, setNewActionItemAssignee] = useState('');
+ const [addActionItem] = useMutation(addActionItemMutation);
+ const boardSlug = useContext(BoardSlugContext);
+
+ const toggleOpen = () => setOpened(!isOpened);
+
+ useEffect(() => {
+ if (isOpened) {
+ textInput.current.focus();
+ }
+ }, [isOpened]);
+
+ const cancelHandler = (evt) => {
+ evt.preventDefault();
+ setOpened(!isOpened);
+ setNewActionItemBody('');
+ };
+
+ const submitHandler = async (evt) => {
+ evt.preventDefault();
+
+ const {data} = await addActionItem({
+ variables: {
+ boardSlug,
+ assigneeId: newActionItemAssignee,
+ body: newActionItemBody
+ }
+ });
+ if (data.addActionItem.actionItem) {
+ setNewActionItemBody('');
+ } else {
+ console.log(data.addActionItem.errors.fullMessages.join(' '));
+ }
+ };
+
+ const handleKeyPress = (evt) => {
+ if (navigator.platform.includes('Mac')) {
+ if (evt.key === 'Enter' && evt.metaKey) {
+ submitHandler(evt);
+ }
+ } else if (evt.key === 'Enter' && evt.ctrlKey) {
+ submitHandler(evt);
+ }
+
+ if (evt.key === 'Escape') {
+ setOpened(!isOpened);
+ setNewActionItemBody('');
+ }
+ };
+
+ return (
+ <>
+
+ {isOpened && (
+
+ )}
+
+ >
+ );
+};
+
+export default NewActionItemBody;
diff --git a/app/javascript/components/new-action-item-body/operations.gql b/app/javascript/components/new-action-item-body/operations.gql
new file mode 100644
index 00000000..f18dbc19
--- /dev/null
+++ b/app/javascript/components/new-action-item-body/operations.gql
@@ -0,0 +1,12 @@
+mutation addActionItemMutation($boardSlug: String!, $assigneeId: ID!, $body: String!) {
+ addActionItem(
+ input: { attributes: { boardSlug: $boardSlug, assigneeId: $assigneeId, body: $body } }
+ ) {
+ actionItem {
+ id
+ }
+ errors {
+ fullMessages
+ }
+ }
+}
diff --git a/app/javascript/components/new-card-body/index.js b/app/javascript/components/new-card-body/index.js
new file mode 100644
index 00000000..ad436256
--- /dev/null
+++ b/app/javascript/components/new-card-body/index.js
@@ -0,0 +1 @@
+export {default as NewCardBody} from './new-card-body';
diff --git a/app/javascript/components/new-card-body/new-card-body.jsx b/app/javascript/components/new-card-body/new-card-body.jsx
new file mode 100644
index 00000000..11d2dcd0
--- /dev/null
+++ b/app/javascript/components/new-card-body/new-card-body.jsx
@@ -0,0 +1,127 @@
+import {nanoid} from 'nanoid';
+import React, {useState, useContext, useEffect, useRef} from 'react';
+import {useMutation} from '@apollo/react-hooks';
+// ! import Textarea from 'react-textarea-autosize';
+import {addCardMutation} from './operations.gql';
+import BoardSlugContext from '../../utils/board-slug-context';
+import UserContext from '../../utils/user-context';
+import style from './style.module.less';
+import styleButton from '../../less/button.module.less';
+
+const NewCardBody = ({kind, smile, onCardAdded, onGetNewCardID}) => {
+ const textInput = useRef();
+ const [isEdit, setIsEdit] = useState(false);
+ const [newCard, setNewCard] = useState('');
+ const [addCard] = useMutation(addCardMutation);
+
+ const boardSlug = useContext(BoardSlugContext);
+ const currentUser = useContext(UserContext);
+
+ const toggleOpen = () => setIsEdit(!isEdit);
+
+ useEffect(() => {
+ if (isEdit) {
+ textInput.current.focus();
+ }
+ }, [isEdit]);
+
+ const cancelHandler = (evt) => {
+ evt.preventDefault();
+ setIsEdit(!isEdit);
+ setNewCard('');
+ };
+
+ const buildNewCard = () => ({
+ likes: 0,
+ comments: [],
+ kind,
+ author: currentUser,
+ body: newCard,
+ id: `tmp-${nanoid()}`
+ });
+
+ const submitHandler = async (evt) => {
+ const card = buildNewCard();
+ evt.preventDefault();
+
+ onCardAdded(card);
+
+ const {data} = await addCard({
+ variables: {
+ boardSlug,
+ kind,
+ body: newCard
+ }
+ });
+ if (data.addCard.card) {
+ onGetNewCardID(card.id, data.addCard.card.id);
+ setNewCard('');
+ } else {
+ console.log(data.addCard.errors.fullMessages.join(' '));
+ }
+ };
+
+ const handleKeyPress = (evt) => {
+ if (navigator.platform.includes('Mac')) {
+ if (evt.key === 'Enter' && evt.metaKey) {
+ submitHandler(evt);
+ }
+ } else if (evt.key === 'Enter' && evt.ctrlKey) {
+ submitHandler(evt);
+ }
+
+ if (evt.key === 'Escape') {
+ cancelHandler(evt);
+ setIsEdit(!isEdit);
+ setNewCard('');
+ }
+ };
+
+ return (
+
+
{smile}
+
+ {isEdit ? (
+
+ setNewCard(evt.target.value)}
+ onKeyDown={handleKeyPress}
+ />
+
+
+ cancel
+
+
+ post
+
+
+
+ ) : (
+
+ {kind}
+
+ )}
+
+
+ +
+
+
+ );
+};
+
+export default NewCardBody;
diff --git a/app/javascript/components/new-card-body/operations.gql b/app/javascript/components/new-card-body/operations.gql
new file mode 100644
index 00000000..272dbafd
--- /dev/null
+++ b/app/javascript/components/new-card-body/operations.gql
@@ -0,0 +1,12 @@
+mutation addCardMutation($boardSlug: String!, $kind: String!, $body: String!) {
+ addCard(
+ input: { attributes: { boardSlug: $boardSlug, kind: $kind, body: $body } }
+ ) {
+ card {
+ id
+ }
+ errors {
+ fullMessages
+ }
+ }
+}
diff --git a/app/javascript/components/new-card-body/style.module.less b/app/javascript/components/new-card-body/style.module.less
new file mode 100644
index 00000000..3acfa951
--- /dev/null
+++ b/app/javascript/components/new-card-body/style.module.less
@@ -0,0 +1,55 @@
+.header {
+ box-sizing: border-box;
+ display: flex;
+ align-items: center;
+ box-sizing: border-box;
+ border-radius: 4px;
+ text-transform: uppercase;
+ font-weight: bold;
+ font-size: 16px;
+ color: #1B1D15;
+ margin-bottom: 16px;
+ position: relative;
+}
+
+.input {
+ overflow-y: scroll;
+ border: none;
+ // height: 40px;
+ padding: 0 15px 0 25px;
+ width: 100%;
+ margin-top: 5px;
+ font-family: "Commissioner";
+}
+
+.input::placeholder {
+ font-weight: bold;
+ vertical-align: middle;
+}
+
+.wrapper {
+ width: 100%;
+}
+
+.title {
+ height: 32px;
+ font-weight: bold;
+ font-size: 16px;
+ margin-left: 28px;
+ margin-top: 8px;
+}
+
+.smile {
+ // margin: 0 8px;
+ position: absolute;
+ left: 2px;
+ top: 4px;
+}
+
+.buttonPlus {
+ // margin: 0 10px 0 auto;
+ position: absolute;
+ right: 2px;
+ top: 8px;
+}
+
diff --git a/app/javascript/components/prev-action-item-column/index.js b/app/javascript/components/prev-action-item-column/index.js
new file mode 100644
index 00000000..637fda27
--- /dev/null
+++ b/app/javascript/components/prev-action-item-column/index.js
@@ -0,0 +1 @@
+export {default as PrevActionItemColumn} from './prev-action-item-column';
diff --git a/app/javascript/components/prev-action-item-column/operations.gql b/app/javascript/components/prev-action-item-column/operations.gql
new file mode 100644
index 00000000..d2cc3160
--- /dev/null
+++ b/app/javascript/components/prev-action-item-column/operations.gql
@@ -0,0 +1,12 @@
+#import '../../fragments/action_item.graphql'
+subscription actionItemMovedSubscription($boardSlug: String!) {
+ actionItemMoved(boardSlug: $boardSlug) {
+ id
+ }
+}
+
+subscription actionItemUpdatedSubscription($boardSlug: String!) {
+ actionItemUpdated(boardSlug: $boardSlug) {
+ ...ActionItem
+ }
+}
diff --git a/app/javascript/components/prev-action-item-column/prev-action-item-column.jsx b/app/javascript/components/prev-action-item-column/prev-action-item-column.jsx
new file mode 100644
index 00000000..36eea1cd
--- /dev/null
+++ b/app/javascript/components/prev-action-item-column/prev-action-item-column.jsx
@@ -0,0 +1,85 @@
+import React, {useState, useContext, useEffect} from 'react';
+import {ActionItem} from '../action-item';
+import {
+ actionItemMovedSubscription,
+ actionItemUpdatedSubscription
+} from './operations.gql';
+import {useSubscription} from '@apollo/react-hooks';
+import BoardSlugContext from '../../utils/board-slug-context';
+import style from './style.module.less';
+
+const PreviousActionItemColumn = (props) => {
+ const {users, handleEmpty, initItems, onClickToggle} = props;
+
+ const [actionItems, setActionItems] = useState(initItems);
+ const [skip, setSkip] = useState(true); // Workaround for https://github.com/apollographql/react-apollo/issues/3802
+
+ const boardSlug = useContext(BoardSlugContext);
+
+ useSubscription(actionItemMovedSubscription, {
+ skip,
+ onSubscriptionData: (options) => {
+ const {data} = options.subscriptionData;
+ const {actionItemMoved} = data;
+ if (actionItemMoved) {
+ setActionItems((oldItems) => {
+ const newItems = oldItems.filter(
+ (element) => element.id !== actionItemMoved.id
+ );
+ if (newItems.length === 0) {
+ handleEmpty();
+ }
+
+ return newItems;
+ });
+ }
+ },
+ variables: {boardSlug}
+ });
+
+ useSubscription(actionItemUpdatedSubscription, {
+ skip,
+ onSubscriptionData: (options) => {
+ const {data} = options.subscriptionData;
+ const {actionItemUpdated} = data;
+ if (actionItemUpdated) {
+ setActionItems((oldItems) => {
+ const cardIdIndex = oldItems.findIndex(
+ (element) => element.id === actionItemUpdated.id
+ );
+ if (cardIdIndex >= 0) {
+ return [
+ ...oldItems.slice(0, cardIdIndex),
+ actionItemUpdated,
+ ...oldItems.slice(cardIdIndex + 1)
+ ];
+ }
+
+ return oldItems;
+ });
+ }
+ },
+ variables: {boardSlug}
+ });
+
+ useEffect(() => {
+ setSkip(false);
+ }, []);
+
+ return (
+ <>
+
+
PREVIOUS BOARD
+
+ hide
+
+
+
+ {actionItems.map((item) => (
+
+ ))}
+ >
+ );
+};
+
+export default PreviousActionItemColumn;
diff --git a/app/javascript/components/prev-action-item-column/style.module.less b/app/javascript/components/prev-action-item-column/style.module.less
new file mode 100644
index 00000000..f95e7f43
--- /dev/null
+++ b/app/javascript/components/prev-action-item-column/style.module.less
@@ -0,0 +1,70 @@
+.header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ height: 24px;
+ margin-top: 8px;
+ margin-bottom: 6px;
+ position: relative;
+}
+
+.title {
+ font-size: 16px;
+ font-weight: bold;
+ text-transform: uppercase;
+}
+
+.hide {
+ font-size: 14px;
+ color: #8D8E8A;
+ cursor: pointer;
+}
+
+.plus {
+ font-size: 16px;
+ font-weight: bold;
+ position: absolute;
+ right: 2px;
+ top: 2px;
+}
+
+.lineGradient {
+ margin: 0;
+ padding: 0;
+ height: 2px;
+ border: none;
+ border-radius: 2px;
+ background: linear-gradient(to right,
+ transparent 15.33%,
+ #FAFAFA 15.33% 16.93%,
+ transparent 16.93% 32.26%,
+ #FAFAFA 32.26% 33.86%,
+ transparent 33.86% 49.19%,
+ #FAFAFA 49.19% 50.79%,
+ transparent 50.79% 66.12%,
+ #FAFAFA 66.12% 67.72%,
+ transparent 67.72% 83.05%,
+ #FAFAFA 83.05% 84.65%,
+ transparent 84.65%,
+ ), linear-gradient(to right, #DB2D22 15.33%, #DB9122 15.33% 50.79%, #22DB98 50.79%);
+
+ background-size: 100% 2px;
+ margin-bottom: 16px;
+}
+
+.lineYellow {
+ margin: 0;
+ padding: 0;
+ height: 2px;
+ border: none;
+ border-radius: 2px;
+ background: linear-gradient(to right,
+ transparent 32.26%,
+ #FAFAFA 32.26% 33.86%,
+ transparent 33.86% 66.12%,
+ #FAFAFA 66.12% 67.72%,
+ transparent 67.72%,
+ ), #DB9122;
+
+ background-size: 100% 2px;
+}
diff --git a/app/javascript/components/provider/index.js b/app/javascript/components/provider/index.js
new file mode 100644
index 00000000..3d678064
--- /dev/null
+++ b/app/javascript/components/provider/index.js
@@ -0,0 +1 @@
+export {default as Provider} from './provider';
diff --git a/app/javascript/components/provider/provider.jsx b/app/javascript/components/provider/provider.jsx
new file mode 100644
index 00000000..5580f51b
--- /dev/null
+++ b/app/javascript/components/provider/provider.jsx
@@ -0,0 +1,11 @@
+import React from 'react';
+import {ApolloProvider} from 'react-apollo';
+import {createCache, createClient} from '../../utils/apollo';
+
+const Provider = ({children}) => (
+
+ {children}
+
+);
+
+export default Provider;
diff --git a/app/javascript/components/ready-button/index.js b/app/javascript/components/ready-button/index.js
new file mode 100644
index 00000000..8b961e40
--- /dev/null
+++ b/app/javascript/components/ready-button/index.js
@@ -0,0 +1 @@
+export {default as ReadyButton} from './ready-button';
diff --git a/app/javascript/components/ready-button/operations.gql b/app/javascript/components/ready-button/operations.gql
new file mode 100644
index 00000000..f605478e
--- /dev/null
+++ b/app/javascript/components/ready-button/operations.gql
@@ -0,0 +1,26 @@
+mutation toggleReadyStatusMutation($id: ID!) {
+ toggleReadyStatus(
+ input: { id: $id }
+ ) {
+ membership {
+ id
+ }
+ errors {
+ fullMessages
+ }
+ }
+}
+
+query getMembershipQuery($boardSlug: String!){
+ membership(boardSlug: $boardSlug) {
+ id
+ ready
+ }
+}
+
+subscription membershipUpdatedSubscription($boardSlug: String!) {
+ membershipUpdated(boardSlug: $boardSlug) {
+ id
+ ready
+ }
+}
diff --git a/app/javascript/components/ready-button/ready-button.jsx b/app/javascript/components/ready-button/ready-button.jsx
new file mode 100644
index 00000000..4609219c
--- /dev/null
+++ b/app/javascript/components/ready-button/ready-button.jsx
@@ -0,0 +1,69 @@
+import React, {useState, useEffect, useContext} from 'react';
+import {useMutation, useQuery, useSubscription} from '@apollo/react-hooks';
+import {
+ toggleReadyStatusMutation,
+ getMembershipQuery,
+ membershipUpdatedSubscription
+} from './operations.gql';
+import style from './style.module.less';
+import BoardSlugContext from '../../utils/board-slug-context';
+
+const ReadyButton = () => {
+ const boardSlug = useContext(BoardSlugContext);
+ const [isReady, setIsReady] = useState(false);
+ const [id, setId] = useState(0);
+ const [skipQuery, setSkipQuery] = useState(false);
+ const [skipSubscription, setSkipSubscription] = useState(true);
+ const {loading, data} = useQuery(getMembershipQuery, {
+ variables: {boardSlug},
+ skip: skipQuery
+ });
+ const [toggleReadyStatus] = useMutation(toggleReadyStatusMutation);
+
+ useSubscription(membershipUpdatedSubscription, {
+ skip: skipSubscription,
+ onSubscriptionData: (options) => {
+ const {data} = options.subscriptionData;
+ const {membershipUpdated} = data;
+ if (membershipUpdated && membershipUpdated.id === id) {
+ setIsReady(membershipUpdated.ready);
+ }
+ },
+ variables: {boardSlug}
+ });
+
+ useEffect(() => {
+ if (!loading && Boolean(data)) {
+ const {membership} = data;
+ setId(membership.id);
+ setIsReady(membership.ready);
+ setSkipQuery(true);
+ }
+ }, [data, loading]);
+
+ useEffect(() => {
+ setSkipSubscription(false);
+ }, []);
+
+ return (
+ {
+ toggleReadyStatus({
+ variables: {
+ id
+ }
+ }).then(({data}) => {
+ if (!data.toggleReadyStatus.membership) {
+ console.log(data.toggleReadyStatus.errors.fullMessages.join(' '));
+ }
+ });
+ }}
+ >
+ {isReady ? 'Not ready' : 'Click when ready'}
+
+ );
+};
+
+export default ReadyButton;
diff --git a/app/javascript/components/ready-button/style.module.less b/app/javascript/components/ready-button/style.module.less
new file mode 100644
index 00000000..3fec9f94
--- /dev/null
+++ b/app/javascript/components/ready-button/style.module.less
@@ -0,0 +1,8 @@
+.readyButton {
+ width: 150px;
+ color: #22DB98;
+ font-size: 16px;
+ border: none;
+ background: none;
+ cursor: pointer;
+}
diff --git a/app/javascript/components/style.less b/app/javascript/components/style.less
new file mode 100644
index 00000000..11002021
--- /dev/null
+++ b/app/javascript/components/style.less
@@ -0,0 +1,129 @@
+@import "../less/variables.less";
+
+// для erb, вынести отдельно
+
+html, body, p, ol, ul, li, dl, dt, dd, blockquote, figure, fieldset, legend, textarea, pre, iframe, hr, h1, h2, h3, h4, h5, h6 {
+ margin: 0;
+ padding: 0;
+}
+
+a {
+ text-decoration: none;
+ color: #1B1D15;
+}
+
+a:visited {
+ color: #1B1D15;
+}
+
+.header {
+ width: 100%;
+ font-family: "Commissioner";
+ font-size: 16px;
+ font-weight: 600;
+}
+
+.header__wrapper {
+ margin: 32px 37px 74px 32px;
+ height: 48px;
+ width: calc(100% - 69px);
+ display: flex;
+ // justify-content: space-between;
+ align-items: center;
+}
+
+.header-left {
+ display: flex;
+ align-items: center;
+ font-size: 16px;
+ color: #C6C6C4;
+}
+
+.header-left__item {
+ margin-right: 16px;
+}
+
+.header-left__link {
+ color: #C6C6C4;
+
+ &:visited {
+ color: #C6C6C4;
+ }
+}
+
+.header-left__board-title {
+ color: #1B1D15;
+ font-size: 16px;
+ font-weight: 600;
+}
+
+.header-left__settings {
+ margin-top: 3px;
+}
+
+.board-button {
+ color: #22DB98;
+}
+
+.header-middle {
+ margin: 0 auto;
+}
+
+.board-container {
+ display: flex;
+ font-family: "Commissioner";
+}
+
+.board-column {
+ display: flex;
+ flex-direction: column;
+ flex-basis: 0;
+ flex-grow: 1;
+ flex-shrink: 1;
+ margin: 0 16px;
+}
+
+.open-button {
+ width: 24px;
+ height: 40px;
+ padding: 0;
+ margin-left: 16px;
+ margin-bottom: 16px;
+ border: 1px solid #C6C6C4;
+ box-sizing: border-box;
+ border-radius: 4px;
+ background-color: transparent;
+ cursor: pointer;
+ font-size: 8px;
+}
+
+.open-button img {
+ width: 4px;
+ height: 8px;
+}
+
+.dot {
+ width: 8px;
+ margin-left: 16px;
+ display: flex;
+ flex-direction: column;
+}
+
+.dot__item {
+ height: 4px;
+ width: 100%;
+ margin-bottom: 4px;
+ border-radius: 2px;
+
+ &--red {
+ background-color: #ED9690;
+ }
+
+ &--yellow {
+ background-color: #F6E3C8;
+ }
+
+ &--green {
+ background-color: #22DB98;
+ }
+}
diff --git a/app/javascript/components/subscription/index.js b/app/javascript/components/subscription/index.js
new file mode 100644
index 00000000..94236c3c
--- /dev/null
+++ b/app/javascript/components/subscription/index.js
@@ -0,0 +1 @@
+export {default as CardsSubscription} from './subscription';
diff --git a/app/javascript/components/subscription/operations.graphql b/app/javascript/components/subscription/operations.graphql
new file mode 100644
index 00000000..37740ed4
--- /dev/null
+++ b/app/javascript/components/subscription/operations.graphql
@@ -0,0 +1,7 @@
+#import '../../fragments/card.graphql'
+
+subscription CardSubscription {
+ cardAdded {
+ ...Card
+ }
+}
diff --git a/app/javascript/components/subscription/subscription.jsx b/app/javascript/components/subscription/subscription.jsx
new file mode 100644
index 00000000..bff7b8d8
--- /dev/null
+++ b/app/javascript/components/subscription/subscription.jsx
@@ -0,0 +1,85 @@
+import React, {Component} from 'react';
+import gql from 'graphql-tag';
+import {Query} from 'react-apollo';
+
+const GET_BOARDS = gql`
+ query {
+ board(id: 10) {
+ cards {
+ id
+ body
+ likes
+ author {
+ email
+ }
+ }
+ }
+ }
+`;
+
+const NEW_CARD = gql`
+ subscription {
+ cardAdded {
+ author {
+ id
+ email
+ avatar
+ updatedAt
+ createdAt
+ }
+ id
+ authorId
+ boardId
+ body
+ likes
+ updatedAt
+ createdAt
+ kind
+ }
+ }
+`;
+
+class CardsSubscription extends Component {
+ _subscribeToNewCards = (subscribeToMore) => {
+ subscribeToMore({
+ document: NEW_CARD,
+ updateQuery: (previous, {subscriptionData}) => {
+ if (!subscriptionData.data) return previous;
+
+ const newCard = subscriptionData.data.cardAdded;
+
+ return {
+ ...previous,
+ cards: [newCard, ...previous.board.cards],
+ __typename: previous.board.cards__typename
+ };
+ }
+ });
+ };
+
+ render() {
+ return (
+
+ {({loading, error, data, subscribeToMore}) => {
+ if (loading) return Fetching
;
+ if (error) return Error
;
+
+ this._subscribeToNewCards(subscribeToMore);
+
+ const cardsToRender = data.board.cards;
+ return (
+
+ {cardsToRender.map((card) => (
+
+ {card.body} {card.author.email} {card.likes}
+
+ ))}
+
+ );
+ }}
+
+ );
+ }
+}
+
+export default CardsSubscription;
diff --git a/app/javascript/components/transition-button/index.js b/app/javascript/components/transition-button/index.js
new file mode 100644
index 00000000..df6800b1
--- /dev/null
+++ b/app/javascript/components/transition-button/index.js
@@ -0,0 +1 @@
+export {default as TransitionButton} from './transition-button';
diff --git a/app/javascript/components/transition-button/operations.gql b/app/javascript/components/transition-button/operations.gql
new file mode 100644
index 00000000..5eb9dd93
--- /dev/null
+++ b/app/javascript/components/transition-button/operations.gql
@@ -0,0 +1,34 @@
+mutation closeActionItemMutation($id: ID!, $boardSlug: String!) {
+ closeActionItem(input: { id: $id, boardSlug: $boardSlug}) {
+ actionItem {
+ id
+ }
+ errors {
+ fullMessages
+ }
+ }
+}
+
+mutation completeActionItemMutation($id: ID!, $boardSlug: String!) {
+ completeActionItem(input: { id: $id, boardSlug: $boardSlug}) {
+ actionItem {
+ id
+ }
+ errors {
+ fullMessages
+ }
+ }
+}
+
+
+mutation reopenActionItemMutation($id: ID!, $boardSlug: String!) {
+ reopenActionItem(input: { id: $id, boardSlug: $boardSlug}) {
+ actionItem {
+ id
+ }
+ errors {
+ fullMessages
+ }
+ }
+}
+
diff --git a/app/javascript/components/transition-button/transition-button.jsx b/app/javascript/components/transition-button/transition-button.jsx
new file mode 100644
index 00000000..a227af79
--- /dev/null
+++ b/app/javascript/components/transition-button/transition-button.jsx
@@ -0,0 +1,68 @@
+import React, {useContext} from 'react';
+import {useMutation} from '@apollo/react-hooks';
+import BoardSlugContext from '../../utils/board-slug-context';
+import {
+ closeActionItemMutation,
+ completeActionItemMutation,
+ reopenActionItemMutation
+} from './operations.gql';
+
+const TransitionButton = (props) => {
+ const {id, action} = props;
+ const boardSlug = useContext(BoardSlugContext);
+
+ const [closeActionItem] = useMutation(closeActionItemMutation);
+ const [completeActionItem] = useMutation(completeActionItemMutation);
+ const [reopenActionItem] = useMutation(reopenActionItemMutation);
+
+ const handleClick = () => {
+ switch (action) {
+ case 'close':
+ closeActionItem({
+ variables: {
+ id,
+ boardSlug
+ }
+ }).then(({data}) => {
+ if (!data.closeActionItem.actionItem) {
+ console.log(data.closeActionItem.errors.fullMessages.join(' '));
+ }
+ });
+ break;
+
+ case 'complete':
+ completeActionItem({
+ variables: {
+ id,
+ boardSlug
+ }
+ }).then(({data}) => {
+ if (!data.completeActionItem.actionItem) {
+ console.log(data.completeActionItem.errors.fullMessages.join(' '));
+ }
+ });
+ break;
+
+ case 'reopen':
+ reopenActionItem({
+ variables: {
+ id,
+ boardSlug
+ }
+ }).then(({data}) => {
+ if (!data.reopenActionItem.actionItem) {
+ console.log(data.reopenActionItem.errors.fullMessages.join(' '));
+ }
+ });
+ break;
+ }
+ };
+
+ return (
+
+ {action}
+
+ );
+};
+
+export default TransitionButton;
diff --git a/app/javascript/components/user/index.js b/app/javascript/components/user/index.js
new file mode 100644
index 00000000..e5abc856
--- /dev/null
+++ b/app/javascript/components/user/index.js
@@ -0,0 +1 @@
+export * from './user';
diff --git a/app/javascript/components/user/operations.gql b/app/javascript/components/user/operations.gql
new file mode 100644
index 00000000..836a593d
--- /dev/null
+++ b/app/javascript/components/user/operations.gql
@@ -0,0 +1,11 @@
+#import '../../fragments/membership.graphql'
+mutation destroyMembershipMutation($id: ID!) {
+ destroyMembership(
+ input: { id: $id }
+ ) {
+ id
+ errors {
+ fullMessages
+ }
+ }
+}
diff --git a/app/javascript/components/user/style.module.less b/app/javascript/components/user/style.module.less
new file mode 100644
index 00000000..4d4302b8
--- /dev/null
+++ b/app/javascript/components/user/style.module.less
@@ -0,0 +1,95 @@
+.avatar {
+ border-radius: 50%;
+ width: 48px;
+ height: 48px;
+ box-sizing: border-box;
+ font-weight: bold;
+ font-size: 20px;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ margin-right: 8px;
+ position: relative;
+}
+
+.avatarBig {
+ width: 48px;
+ height: 48px;
+}
+
+.avatarSmall {
+ width: 24px;
+ height: 24px;
+}
+
+.avatar-1 {
+ background-color: #DB6622;
+}
+
+.avatar-2 {
+ background-color: #DBAA22;
+}
+
+.avatar-3 {
+ background-color: #85DB22;
+}
+
+.avatar-4 {
+ background-color: #22DB8B;
+}
+
+.avatar-5 {
+ background-color: #22DBCF;
+}
+
+.avatar-6 {
+ background-color: #22A3DB;
+}
+
+.avatar-7 {
+ background-color: #6C22DB;
+}
+
+.avatar-8 {
+ background-color: #B022DB;
+}
+
+.avatar-9 {
+ background-color: #DB22C2;
+}
+
+.avatar-0 {
+ background-color: #DB229D;
+}
+
+.isReady {
+ // background-color: #23d160;
+ border: 3px solid #23d160;
+ // color: #fff;
+}
+
+.avatarText {
+ border-radius: 4px;
+}
+
+.avatarTooltip {
+ display: none;
+}
+
+.avatarWrapper {
+ position: relative;
+}
+
+.avatar:hover ~ .avatarTooltip {
+ display: block;
+ color: black;
+ background-color: white;
+ position: absolute;
+ left: -21px;
+ padding: 5px;
+ border-radius: 5px;
+ width: auto;
+ white-space: nowrap;
+ margin-top: 15px;
+ z-index: 5;
+}
diff --git a/app/javascript/components/user/user.jsx b/app/javascript/components/user/user.jsx
new file mode 100644
index 00000000..ab15d92f
--- /dev/null
+++ b/app/javascript/components/user/user.jsx
@@ -0,0 +1,70 @@
+import React from 'react';
+import {useMutation} from '@apollo/react-hooks';
+import {destroyMembershipMutation} from './operations.gql';
+import {getUserInitials} from '../../utils/helpers';
+import avatarStyle from './style.module.less';
+
+const User = ({
+ membership: {
+ ready,
+ id,
+ user: {email, avatar, lastName, firstName}
+ },
+ shouldDisplayReady,
+ shouldHandleDelete
+}) => {
+ //
+ // const [style, setStyle] = useState({});
+ const [destroyMember] = useMutation(destroyMembershipMutation);
+ const deleteUser = () => {
+ destroyMember({
+ variables: {
+ id
+ }
+ }).then(({data}) => {
+ if (data.destroyMembership.id) {
+ //
+ // if (shouldHandleDelete) {
+ // setStyle({display: 'none'});
+ // }
+ // } else {
+ console.log(data.destroyMembership.errors.fullMessages.join(' '));
+ }
+ });
+ };
+
+ const renderBoardAvatar = (userAvatar, userName, userSurname) => {
+ if (userAvatar) {
+ return (
+
+ );
+ }
+
+ let classes = `${avatarStyle.avatar} ${avatarStyle.avatarText}
+ ${avatarStyle[`avatar-${id % 10}`]}`;
+ shouldDisplayReady && ready && (classes += avatarStyle.isReady);
+
+ return (
+ {getUserInitials(userName, userSurname)}
+ );
+ };
+
+ return (
+
+ {renderBoardAvatar(avatar.thumb.url, firstName, lastName)}
+
+ {firstName} {lastName}
+
+ {shouldHandleDelete && (
+
+ )}
+
+ );
+};
+
+export default User;
diff --git a/app/javascript/fonts/Commissioner-Regular.woff b/app/javascript/fonts/Commissioner-Regular.woff
new file mode 100644
index 00000000..453794d7
Binary files /dev/null and b/app/javascript/fonts/Commissioner-Regular.woff differ
diff --git a/app/javascript/fonts/Commissioner-Regular.woff2 b/app/javascript/fonts/Commissioner-Regular.woff2
new file mode 100644
index 00000000..f2a66802
Binary files /dev/null and b/app/javascript/fonts/Commissioner-Regular.woff2 differ
diff --git a/app/javascript/fragments/action_item.graphql b/app/javascript/fragments/action_item.graphql
new file mode 100644
index 00000000..20386d6b
--- /dev/null
+++ b/app/javascript/fragments/action_item.graphql
@@ -0,0 +1,12 @@
+#import "./user.graphql"
+
+fragment ActionItem on ActionItem {
+ id
+ assignee {
+ ...User
+ }
+ body
+ times_moved
+ assignee_avatar_url
+ status
+}
diff --git a/app/javascript/fragments/card.graphql b/app/javascript/fragments/card.graphql
new file mode 100644
index 00000000..fe02c283
--- /dev/null
+++ b/app/javascript/fragments/card.graphql
@@ -0,0 +1,15 @@
+#import "./user.graphql"
+#import "./comment.graphql"
+
+fragment Card on Card {
+ author {
+ ...User
+ }
+ comments {
+ ...Comment
+ }
+ id
+ kind
+ body
+ likes
+}
diff --git a/app/javascript/fragments/comment.graphql b/app/javascript/fragments/comment.graphql
new file mode 100644
index 00000000..c5dcda34
--- /dev/null
+++ b/app/javascript/fragments/comment.graphql
@@ -0,0 +1,10 @@
+#import "./user.graphql"
+
+fragment Comment on Comment {
+ id
+ content
+ author {
+ ...User
+ }
+ likes
+}
diff --git a/app/javascript/fragments/membership.graphql b/app/javascript/fragments/membership.graphql
new file mode 100644
index 00000000..4c2e4c32
--- /dev/null
+++ b/app/javascript/fragments/membership.graphql
@@ -0,0 +1,9 @@
+#import "./user.graphql"
+
+fragment Membership on Membership {
+ id
+ ready
+ user {
+ ...User
+ }
+}
diff --git a/app/javascript/fragments/user.graphql b/app/javascript/fragments/user.graphql
new file mode 100644
index 00000000..a7487b6e
--- /dev/null
+++ b/app/javascript/fragments/user.graphql
@@ -0,0 +1,12 @@
+fragment User on User {
+ id
+ email
+ avatar {
+ thumb {
+ url
+ }
+ }
+ nickname
+ lastName
+ firstName
+}
diff --git a/app/javascript/less/button.module.less b/app/javascript/less/button.module.less
new file mode 100644
index 00000000..c2077ee4
--- /dev/null
+++ b/app/javascript/less/button.module.less
@@ -0,0 +1,31 @@
+.buttons {
+ display: flex;
+ justify-content: space-between;
+ margin: 16px 8px;
+}
+
+.buttonPost {
+ font-size: 14px;
+ padding: 0;
+ background-color: transparent;
+ border: none;
+ color: #54554F;
+ cursor: pointer;
+}
+
+.buttonPost:hover {
+ color: #1B1D15;
+}
+
+.buttonCancel {
+ font-size: 14px;
+ padding: 0;
+ background-color: transparent;
+ border: none;
+ color: #8D8E8A;
+ cursor: pointer;
+}
+
+.buttonCancel:hover {
+ color: #54554F;
+}
diff --git a/app/javascript/less/variables.less b/app/javascript/less/variables.less
new file mode 100644
index 00000000..b1c96c53
--- /dev/null
+++ b/app/javascript/less/variables.less
@@ -0,0 +1,34 @@
+@font-face {
+ font-family: "Commissioner";
+ src: url("../fonts/Commissioner-Regular.woff2") format("woff2"),
+ url("../fonts/Commissioner-Regular.woff") format("woff");
+ font-weight: normal;
+ font-stretch: normal;
+ font-style: normal;
+ letter-spacing: normal;
+ font-display: swap;
+}
+
+/* colors */
+@color-black: #1B1D15;
+@color-darkest-grey: #54554F;
+@color-dark-grey: #8D8E8A;
+@color-grey: #C6C6C4;
+@color-light-grey: #E8E8E8;
+@color-lightest-grey: #FAFAFA;
+
+@color-white: #FFFFFF;
+
+@color-green: #22DB98;
+@color-light-green: #E9FBF5;
+
+@color-dark-yellow: #DB9122;
+@color-yellow: #F6E3C8;
+@color-light-yellow: #FBF4E9;
+@color-lightest-yellow: #FDFAF4;
+
+@color-darkest-red: #DB2D22;
+@color-dark-red: #ED9690;
+@color-red: #F6CAC8;
+@color-light-red: #FBEAE9;
+@color-lightest-red: #FDF5F4;
diff --git a/app/javascript/mocks/all-mocks.js b/app/javascript/mocks/all-mocks.js
new file mode 100644
index 00000000..e0fafea8
--- /dev/null
+++ b/app/javascript/mocks/all-mocks.js
@@ -0,0 +1,98 @@
+// Const ASSIGNEE = {
+// id: 1,
+// email: "tu1@mail.com",
+// firstName: "tu1@mail",
+// lastName: "petroff",
+// nickname: "petr",
+// avatar: null,
+// // avatar: "/assets/default_avatar.jpg",
+// }
+
+export const USER = {
+ id: 1,
+ email: 'tu1@mail.com',
+ firstName: 'tu1@mail',
+ lastName: 'ivanoff',
+ nickname: 'ivan',
+ avatar: null
+ // Avatar: "/assets/default_avatar.jpg",
+};
+
+export const ACTION_ITEMS = [
+ {
+ id: 7,
+ body: `text`,
+ timesMoved: 1,
+ status: `pending`,
+ assignee: USER
+ },
+ {
+ id: 8,
+ body: `text`,
+ timesMoved: 5,
+ status: `pending`,
+ assignee: USER
+ }
+];
+
+export const BOARD = {
+ columnNames: ['mad', 'sad', 'glad'],
+ created: '2020-11-30T11:09:48.327Z',
+ id: 13,
+ previousBoardId: 6,
+ private: false,
+ slug: '8-o_YHkmY4', /// wtf?
+ title: '30-11-2020',
+ updated: '2020-11-30T11:09:48.327Z',
+ usersAmount: 2
+};
+
+export const CARD = {
+ author: USER,
+ body: 'some text',
+ comments: [],
+ id: 89,
+ kind: 'sad',
+ likes: 0
+};
+
+export const CARDS_BY_TYPE = {
+ mad: [CARD, CARD, CARD],
+ sad: [CARD],
+ glad: []
+};
+
+export const CREATORS = [USER.email]; // ????
+
+export const PREVIOUS_ITEMS = [
+ {
+ id: 111,
+ body: `text`,
+ timesMoved: 1,
+ status: `pending`,
+ assignee: USER
+ },
+ {
+ id: 121,
+ body: `text`,
+ timesMoved: 1,
+ status: `pending`,
+ assignee: USER
+ }
+];
+
+export const BOARD_USER = [USER.email];
+
+export const BOARD_USERS = [
+ USER,
+ {
+ id: 3,
+ email: 'tu2@mail.com',
+ firstName: 'tu1@mail',
+ lastName: 'ivanoff',
+ nickname: 'ivan',
+ avatar: null
+ }
+];
+
+// ___________________________\\
diff --git a/app/javascript/packs/application.js b/app/javascript/packs/application.js
index 06bfc871..f725f5f2 100644
--- a/app/javascript/packs/application.js
+++ b/app/javascript/packs/application.js
@@ -7,7 +7,6 @@
// To reference this file, add <%= javascript_pack_tag 'application' %> to the appropriate
// layout file, like app/views/layouts/application.html.erb
-
// Uncomment to copy all static images under ../images to the output folder and reference
// them with the image_pack_tag helper in views (e.g <%= image_pack_tag 'rails.png' %>)
// or the `imagePath` JavaScript helper below.
@@ -17,6 +16,7 @@
// console.log('Hello World from Webpacker')
// Support component names relative to this directory:
-var componentRequireContext = require.context("components", true);
-var ReactRailsUJS = require("react_ujs");
+
+const componentRequireContext = require.context('components', true);
+const ReactRailsUJS = require('react_ujs');
ReactRailsUJS.useContext(componentRequireContext);
diff --git a/app/javascript/packs/autocomplete.jsx b/app/javascript/packs/autocomplete.jsx
deleted file mode 100644
index 30f4db45..00000000
--- a/app/javascript/packs/autocomplete.jsx
+++ /dev/null
@@ -1,147 +0,0 @@
-import React, { Component } from 'react';
-import ReactDOM from 'react-dom';
-import Select from 'react-select';
-
-import User from "./user"
-
-export class Autocomplete extends Component {
-
- constructor(props) {
- super(props);
- this.state = {
- suggestions: [],
- memberships: [],
- selectedOption: null,
- options: [],
- };
- };
-
- componentDidMount = (e) => {
- fetch(`/api/${window.location.pathname}/memberships`)
- .then(res => res.json())
- .then(
- (result) => {
- this.setState({
- ...this.state,
- memberships: result
- });
- },
- )
- }
-
- handleSubmit = (e) => {
- e.preventDefault();
- fetch(`/api/${window.location.pathname}/invite`, {
- method: 'POST',
- headers: {
- Accept: 'application/json',
- 'Content-Type': 'application/json',
- 'X-CSRF-Token': document.querySelector("meta[name='csrf-token']").getAttribute("content")
- },
- body: JSON.stringify({
- board: {
- email: this.state.selectedOption.map(a => a.value).toString()
- }
- }),
- }).then((res) => {
- if (res.status == 200) {
- return res.json();
- }
- else { throw res }
- }).then (
- (result) => {
- this.setState({
- ...this.state,
- memberships: [...new Set (this.state.memberships.concat(result))],
- selectedOption: null
- });
- }
- ).catch((error) => {
- console.log(error)
- this.setState({
- ...this.state,
- selectedOption: null
- });
- error.text().then( errorMessage => {
- console.log(errorMessage)
- })
- });
- };
-
- handleChange = selectedOption => {
- this.setState({ selectedOption });
- };
-
- onInputChange = e => {
- if (!e) {
- this.setState({
- ...this.state,
- options: []
- })
- }
-
- else {
- fetch(`/api/${window.location.pathname}/suggestions?autocomplete=${e}`)
- .then(res => res.json())
- .then(
- (result) => {
- this.setState({
- ...this.state,
- suggestions: [...new Set (result.users.concat(result.teams))],
- });
- const optionsArray = this.state.suggestions.map(function (a) {
- return {
- value: a,
- label: a
- };
- });
- this.setState({
- ...this.state,
- options: optionsArray
- })
- },
- )}
- };
-
- render() {
- const {
- suggestions,
- memberships
- } = this.state;
- let usersListComponent;
- usersListComponent =
- memberships.map((membership, index) => {
- return
- })
- const components = {
- DropdownIndicator: null,
- };
- return (
-
- users on this board:
-
- {usersListComponent}
-
-
-
-
-
-
-
- );
- };
-}
-
-document.addEventListener('DOMContentLoaded', () => {
- ReactDOM.render(
- ,
- document.getElementById('autocomplete'));
-})
diff --git a/app/javascript/packs/board.jsx b/app/javascript/packs/board.jsx
new file mode 100644
index 00000000..87010755
--- /dev/null
+++ b/app/javascript/packs/board.jsx
@@ -0,0 +1,17 @@
+import React from 'react';
+import ReactDOM from 'react-dom';
+import {ApolloProvider} from 'react-apollo';
+import {createClient} from '../utils/apollo';
+import {CardsSubscription} from '../components/subscription';
+
+const Board = () => (
+
+);
+
+document.addEventListener('DOMContentLoaded', () => {
+ ReactDOM.render( , document.querySelector('#board'));
+});
diff --git a/app/javascript/packs/boards_index_animation.js b/app/javascript/packs/boards_index_animation.js
new file mode 100644
index 00000000..ce7dcbd7
--- /dev/null
+++ b/app/javascript/packs/boards_index_animation.js
@@ -0,0 +1,10 @@
+document.addEventListener('DOMContentLoaded', function() {
+ document
+ .querySelector('.load-more-boards-button')
+ .addEventListener('click', function(e) {
+ e.target.classList.toggle('clicked');
+ document
+ .querySelectorAll('.animated-board')
+ .forEach(element => element.classList.toggle('active'));
+ });
+});
diff --git a/app/javascript/packs/bulma_components.jsx b/app/javascript/packs/bulma_components.jsx
deleted file mode 100644
index 7ab84c59..00000000
--- a/app/javascript/packs/bulma_components.jsx
+++ /dev/null
@@ -1,7 +0,0 @@
-import React from 'react';
-import 'react-bulma-components/dist/react-bulma-components.min.css';
-import { Button } from 'react-bulma-components';
-
-export default () => (
- My Bulma button
-)
diff --git a/app/javascript/packs/hello_react.jsx b/app/javascript/packs/hello_react.jsx
deleted file mode 100644
index 772fc97e..00000000
--- a/app/javascript/packs/hello_react.jsx
+++ /dev/null
@@ -1,26 +0,0 @@
-// Run this example by adding <%= javascript_pack_tag 'hello_react' %> to the head of your layout file,
-// like app/views/layouts/application.html.erb. All it does is render Hello React
at the bottom
-// of the page.
-
-import React from 'react'
-import ReactDOM from 'react-dom'
-import PropTypes from 'prop-types'
-
-const Hello = props => (
- Hello {props.name}!
-)
-
-Hello.defaultProps = {
- name: 'David'
-}
-
-Hello.propTypes = {
- name: PropTypes.string
-}
-
-document.addEventListener('DOMContentLoaded', () => {
- ReactDOM.render(
- ,
- document.body.appendChild(document.createElement('div')),
- )
-})
diff --git a/app/javascript/packs/provider.js b/app/javascript/packs/provider.js
new file mode 100644
index 00000000..c3f81d62
--- /dev/null
+++ b/app/javascript/packs/provider.js
@@ -0,0 +1,5 @@
+import React from 'react';
+import {render} from 'react-dom';
+import {Provider} from '../components/provider';
+
+render(👻 , document.querySelector('#root'));
diff --git a/app/javascript/packs/ready-button.jsx b/app/javascript/packs/ready-button.jsx
new file mode 100644
index 00000000..ae19f136
--- /dev/null
+++ b/app/javascript/packs/ready-button.jsx
@@ -0,0 +1,16 @@
+import React from 'react';
+import ReactDOM from 'react-dom';
+import {ReadyButton} from '../components/ready-button';
+import {Provider} from '../components/provider';
+import BoardSlugContext from '../utils/board-slug-context';
+
+const element = (
+
+
+
+
+
+);
+document.addEventListener('DOMContentLoaded', () => {
+ ReactDOM.render(element, document.querySelector('#ready-button'));
+});
diff --git a/app/javascript/packs/ready_button_component.jsx b/app/javascript/packs/ready_button_component.jsx
deleted file mode 100644
index 4a5bf01f..00000000
--- a/app/javascript/packs/ready_button_component.jsx
+++ /dev/null
@@ -1,60 +0,0 @@
-import React from 'react'
-import ReactDOM from 'react-dom'
-
-export class ReadyButton extends React.Component {
- constructor(props) {
- super(props);
- this.state = {};
- this.handleClick = this.handleClick.bind(this)
- }
-
- handleClick() {
- fetch(`/api/${window.location.pathname}/memberships/ready_toggle`, {
- method: 'PUT',
- headers: {
- Accept: 'application/json',
- 'Content-Type': 'application/json',
- 'X-CSRF-Token': document.querySelector("meta[name='csrf-token']").getAttribute('content')
- }
- })
- .then(result => result.json())
- .then(
- (result) => {
- this.setState({
- ...this.state,
- ready : result
- });
- },
- )
- }
-
- componentDidMount() {
- fetch(`/api/${window.location.pathname}/memberships/ready_status`, { method: 'GET' })
- .then(result => result.json())
- .then(
- (result) => {
- this.setState({
- ...this.state,
- ready : result
- });
- },
- )
- }
-
- render() {
- if (this.state.ready == true) {
- var className = 'button is-large is-success';
- } else {
- var className = 'button is-large';
- }
- return (
- READY
- );
- }
-};
-
-document.addEventListener('DOMContentLoaded', () => {
- ReactDOM.render(
- ,
- document.getElementById('ready-button'))
-})
diff --git a/app/javascript/packs/server_rendering.js b/app/javascript/packs/server_rendering.js
index a31e683d..487d5bdb 100644
--- a/app/javascript/packs/server_rendering.js
+++ b/app/javascript/packs/server_rendering.js
@@ -1,5 +1,5 @@
// By default, this pack is loaded for server-side rendering.
// It must expose react_ujs as `ReactRailsUJS` and prepare a require context.
-var componentRequireContext = require.context("components", true);
-var ReactRailsUJS = require("react_ujs");
+const componentRequireContext = require.context('components', true);
+const ReactRailsUJS = require('react_ujs');
ReactRailsUJS.useContext(componentRequireContext);
diff --git a/app/javascript/packs/user.jsx b/app/javascript/packs/user.jsx
deleted file mode 100644
index 5a4595ab..00000000
--- a/app/javascript/packs/user.jsx
+++ /dev/null
@@ -1,53 +0,0 @@
-import React, { Component } from 'react';
-
-class User extends Component {
- constructor(props) {
- super(props);
- this.ready = this.props.membership.ready
- this.email = this.props.membership.user.email
- this.id = this.props.membership.id
- this.state = { }
- };
-
- hideUser(e) {
- this.setState({
- ...this.state,
- displayStyle: {display: 'none'}
- })
- };
-
- deleteUser = (e) => {
- fetch(`/api/${window.location.pathname}/memberships/${this.id}`, {
- method: 'DELETE',
- headers: {
- Accept: 'application/json',
- 'Content-Type': 'application/json',
- 'X-CSRF-Token': document.querySelector("meta[name='csrf-token']").getAttribute('content')
- }
- }).then((result) => {
- if (result.status == 204) {
- this.hideUser()
- }
- else {
- throw result
- }
- }).catch((error) => {
- error.json().then( errorHash => {
- console.log(errorHash.error)
- })
- });
- }
-
- render () {
- return (
-
-
-
- );
- }
-};
-
-export default User
diff --git a/app/javascript/utils/apollo.js b/app/javascript/utils/apollo.js
new file mode 100644
index 00000000..d4be61e8
--- /dev/null
+++ b/app/javascript/utils/apollo.js
@@ -0,0 +1,109 @@
+// Client
+import {ApolloClient} from 'apollo-client';
+// Cache
+import {InMemoryCache} from 'apollo-cache-inmemory';
+// Links
+import {HttpLink} from 'apollo-link-http';
+import {onError} from 'apollo-link-error';
+import {ApolloLink, Observable} from 'apollo-link';
+import {ActionCableLink} from 'graphql-ruby-client';
+import {createConsumer} from '@rails/actioncable';
+
+export const createCache = () => {
+ const cache = new InMemoryCache();
+ if (process.env.NODE_ENV === 'development') {
+ window.secretVariableToStoreCache = cache;
+ }
+
+ return cache;
+};
+
+const createActionCableLink = () => {
+ const cable = createConsumer();
+ return new ActionCableLink({cable});
+};
+
+const hasSubscriptionOperation = ({query: {definitions}}) =>
+ definitions.some(
+ ({kind, operation}) =>
+ kind === 'OperationDefinition' && operation === 'subscription'
+ );
+
+const getTokens = () => {
+ const tokens = {
+ 'X-CSRF-Token': document
+ .querySelector('meta[name="csrf-token"]')
+ .getAttribute('content')
+ };
+ const authToken = localStorage.getItem('mlToken');
+ return authToken ? {...tokens, Authorization: authToken} : tokens;
+};
+
+const setTokenForOperation = async operation => {
+ return operation.setContext({
+ headers: {
+ ...getTokens()
+ }
+ });
+};
+
+// Link with token
+const createLinkWithToken = () =>
+ new ApolloLink(
+ (operation, forward) =>
+ new Observable(observer => {
+ let handle;
+ Promise.resolve(operation)
+ .then(setTokenForOperation)
+ .then(() => {
+ handle = forward(operation).subscribe({
+ next: observer.next.bind(observer),
+ error: observer.error.bind(observer),
+ complete: observer.complete.bind(observer)
+ });
+ })
+ .catch(observer.error.bind(observer));
+ return () => {
+ if (handle) handle.unsubscribe();
+ };
+ })
+ );
+
+// Log erors
+const logError = error => console.error(error);
+// Create error link
+const createErrorLink = () =>
+ onError(({graphQLErrors, networkError, operation}) => {
+ if (graphQLErrors) {
+ logError('GraphQL - Error', {
+ errors: graphQLErrors,
+ operationName: operation.operationName,
+ variables: operation.variables
+ });
+ }
+
+ if (networkError) {
+ logError('GraphQL - NetworkError', networkError);
+ }
+ });
+
+const createHttpLink = () =>
+ new HttpLink({
+ uri: '/graphql',
+ credentials: 'include'
+ });
+
+export const createClient = (cache, _) => {
+ return new ApolloClient({
+ link: ApolloLink.from([
+ createErrorLink(),
+ createLinkWithToken(),
+ ApolloLink.split(
+ hasSubscriptionOperation,
+ createActionCableLink(),
+ createHttpLink()
+ )
+ ]),
+ cache
+ });
+};
diff --git a/app/javascript/utils/board-slug-context.js b/app/javascript/utils/board-slug-context.js
new file mode 100644
index 00000000..94e6c336
--- /dev/null
+++ b/app/javascript/utils/board-slug-context.js
@@ -0,0 +1,2 @@
+import React from 'react';
+export default React.createContext('');
diff --git a/app/javascript/utils/helpers.js b/app/javascript/utils/helpers.js
new file mode 100644
index 00000000..fff08628
--- /dev/null
+++ b/app/javascript/utils/helpers.js
@@ -0,0 +1,8 @@
+export const getBigFirstLetter = (string) => string.toUpperCase().charAt(0);
+
+export const getUserInitials = (name, surname) => {
+ return (
+ (name ? getBigFirstLetter(name) : '') +
+ (surname ? getBigFirstLetter(surname) : '')
+ );
+};
diff --git a/app/javascript/utils/linkify.js b/app/javascript/utils/linkify.js
new file mode 100644
index 00000000..4249fa8a
--- /dev/null
+++ b/app/javascript/utils/linkify.js
@@ -0,0 +1,14 @@
+const cutUrl = (url) => {
+ const match = /(?:https?:\/\/)?(?:www\.)?([\w\d-_.]+)/.exec(url);
+ if (match) {
+ return match[1];
+ }
+
+ return url;
+};
+
+export const Linkify = require('linkifyjs/react');
+
+export const linkifyOptions = {
+ format: cutUrl
+};
diff --git a/app/javascript/utils/user-context.js b/app/javascript/utils/user-context.js
new file mode 100644
index 00000000..94e6c336
--- /dev/null
+++ b/app/javascript/utils/user-context.js
@@ -0,0 +1,2 @@
+import React from 'react';
+export default React.createContext('');
diff --git a/app/jobs/daily_action_items_job.rb b/app/jobs/daily_action_items_job.rb
new file mode 100644
index 00000000..f5a76d65
--- /dev/null
+++ b/app/jobs/daily_action_items_job.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class DailyActionItemsJob < ApplicationJob
+ queue_as :default
+
+ def perform(board)
+ DailyActionItemsService.new.send_mails(board)
+ end
+end
diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb
index d84cb6e7..f2e2c6cb 100644
--- a/app/mailers/application_mailer.rb
+++ b/app/mailers/application_mailer.rb
@@ -1,6 +1,5 @@
# frozen_string_literal: true
class ApplicationMailer < ActionMailer::Base
- default from: 'from@example.com'
layout 'mailer'
end
diff --git a/app/mailers/daily_action_items_mailer.rb b/app/mailers/daily_action_items_mailer.rb
new file mode 100644
index 00000000..d6cb3b7d
--- /dev/null
+++ b/app/mailers/daily_action_items_mailer.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class DailyActionItemsMailer < ApplicationMailer
+ def send_action_items(user, board)
+ @action_items = user.action_items.where(board_id: board.id, status: 'pending')
+ @board = board
+ @greeting = "Good day, #{user.nickname}"
+
+ return if @action_items.empty?
+
+ mail to: user.email, subject: "It's your new action items!"
+ end
+end
diff --git a/app/models/action_item.rb b/app/models/action_item.rb
index 266d55c9..09f70c53 100644
--- a/app/models/action_item.rb
+++ b/app/models/action_item.rb
@@ -3,6 +3,8 @@
class ActionItem < ApplicationRecord
include AASM
+ belongs_to :author, class_name: 'User'
+ belongs_to :assignee, class_name: 'User', optional: true
belongs_to :board
validates_presence_of :body, :status
diff --git a/app/models/board.rb b/app/models/board.rb
index e1f0bd60..ebe1f913 100644
--- a/app/models/board.rb
+++ b/app/models/board.rb
@@ -5,8 +5,11 @@ class Board < ApplicationRecord
has_many :cards, dependent: :restrict_with_error
has_many :memberships, dependent: :destroy
has_many :users, through: :memberships
+ has_many :board_permissions_users, dependent: :destroy
validates_presence_of :title
+ after_create :send_action_items
+
belongs_to :previous_board, class_name: 'Board', optional: true
before_create :set_slug
@@ -14,14 +17,6 @@ def to_param
slug
end
- def member?(user, role = %w[member creator])
- memberships.exists?(user_id: user.id, role: role)
- end
-
- def creator?(user)
- member?(user, 'creator')
- end
-
private
def set_slug
@@ -30,4 +25,8 @@ def set_slug
break unless Board.where(slug: slug).exists?
end
end
+
+ def send_action_items
+ DailyActionItemsJob.set(wait: 1.day).perform_later(self)
+ end
end
diff --git a/app/models/board_permissions_user.rb b/app/models/board_permissions_user.rb
new file mode 100644
index 00000000..c71eef2f
--- /dev/null
+++ b/app/models/board_permissions_user.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class BoardPermissionsUser < ApplicationRecord
+ belongs_to :user
+ belongs_to :permission
+ belongs_to :board
+
+ validates_uniqueness_of :permission_id, scope: %i[user_id board_id]
+end
diff --git a/app/models/card.rb b/app/models/card.rb
index 5deb0965..b31caa09 100644
--- a/app/models/card.rb
+++ b/app/models/card.rb
@@ -4,12 +4,10 @@ class Card < ApplicationRecord
belongs_to :author, class_name: 'User'
belongs_to :board
- validates_presence_of :kind, :body
- validates :kind, inclusion: { in: %w[mad sad glad] }
+ has_many :comments, dependent: :destroy
+ has_many :card_permissions_users, dependent: :destroy
- scope :mad, -> { where(kind: :mad) }
- scope :sad, -> { where(kind: :sad) }
- scope :glad, -> { where(kind: :glad) }
+ validates_presence_of :kind, :body
def author?(user)
author == user
diff --git a/app/models/card_permissions_user.rb b/app/models/card_permissions_user.rb
new file mode 100644
index 00000000..a58d6462
--- /dev/null
+++ b/app/models/card_permissions_user.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class CardPermissionsUser < ApplicationRecord
+ belongs_to :user
+ belongs_to :permission
+ belongs_to :card
+
+ validates_uniqueness_of :permission_id, scope: %i[user_id card_id]
+end
diff --git a/app/models/comment.rb b/app/models/comment.rb
new file mode 100644
index 00000000..33774dc9
--- /dev/null
+++ b/app/models/comment.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class Comment < ApplicationRecord
+ belongs_to :card
+ belongs_to :author, class_name: 'User'
+
+ has_many :comment_permissions_users, dependent: :destroy
+ validates_presence_of :content
+
+ def author?(user)
+ author == user
+ end
+
+ def like!
+ increment!(:likes)
+ end
+end
diff --git a/app/models/comment_permissions_user.rb b/app/models/comment_permissions_user.rb
new file mode 100644
index 00000000..29de6a9b
--- /dev/null
+++ b/app/models/comment_permissions_user.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class CommentPermissionsUser < ApplicationRecord
+ belongs_to :user
+ belongs_to :permission
+ belongs_to :comment
+
+ validates_uniqueness_of :permission_id, scope: %i[user_id comment_id]
+end
diff --git a/app/models/membership.rb b/app/models/membership.rb
index e3f4d85b..a3fb7072 100644
--- a/app/models/membership.rb
+++ b/app/models/membership.rb
@@ -2,8 +2,8 @@
class Membership < ApplicationRecord
belongs_to :user
- belongs_to :board
+ belongs_to :board, counter_cache: :users_count
validates_uniqueness_of :user_id, scope: [:board_id]
- enum role: { creator: 'creator', member: 'member' }
+ enum role: { creator: 'creator', member: 'member', admin: 'admin', host: 'host' }
end
diff --git a/app/models/permission.rb b/app/models/permission.rb
new file mode 100644
index 00000000..758838b5
--- /dev/null
+++ b/app/models/permission.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+class Permission < ApplicationRecord
+ CREATOR_IDENTIFIERS = %w[view_private_board edit_board update_board get_suggestions
+ destroy_board continue_board create_cards invite_members
+ toggle_ready_status destroy_membership destroy_any_card
+ like_card create_comments create_action_items update_action_items
+ destroy_action_items move_action_items close_action_items
+ complete_action_items reopen_action_items].freeze
+ MEMBER_IDENTIFIERS = %w[view_private_board create_cards toggle_ready_status like_card
+ create_comments create_action_items].freeze
+ CARD_IDENTIFIERS = %w[update_card destroy_card].freeze
+ COMMENT_IDENTIFIERS = %w[update_comment destroy_comment].freeze
+
+ has_many :board_permissions_users, dependent: :destroy
+ has_many :board_users, through: :board_permissions_users, source: :user
+ has_many :card_permissions_users, dependent: :destroy
+ has_many :card_users, through: :card_permissions_users, source: :user
+ has_many :comment_permissions_users, dependent: :destroy
+ has_many :comment_users, through: :comment_permissions_users, source: :user
+
+ validates_presence_of :description, :identifier
+ validates_uniqueness_of :identifier
+
+ scope :creator_permissions, -> { where(identifier: CREATOR_IDENTIFIERS) }
+ scope :member_permissions, -> { where(identifier: MEMBER_IDENTIFIERS) }
+ scope :card_permissions, -> { where(identifier: CARD_IDENTIFIERS) }
+ scope :comment_permissions, -> { where(identifier: COMMENT_IDENTIFIERS) }
+end
diff --git a/app/models/user.rb b/app/models/user.rb
index 890aa5e2..74394997 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -1,23 +1,59 @@
# frozen_string_literal: true
class User < ApplicationRecord
- # Include default devise modules. Others available are:
- # :confirmable, :lockable, :timeoutable, :trackable
- devise :database_authenticatable, :registerable,
- :recoverable, :rememberable, :validatable, :omniauthable, omniauth_providers: %i[github]
- has_many :cards, foreign_key: :author_id
- has_and_belongs_to_many :teams
+ devise :database_authenticatable, :recoverable, :rememberable, :validatable,
+ :omniauthable, omniauth_providers: %i[alfred google facebook developer github]
+ has_many :cards, foreign_key: :author_id
+ has_many :comments, foreign_key: :author_id
has_many :memberships
has_many :boards, through: :memberships
+ has_many :board_permissions_users, dependent: :destroy
+ has_many :board_permissions, through: :board_permissions_users, source: :permission
+ has_many :card_permissions_users, dependent: :destroy
+ has_many :card_permissions, through: :card_permissions_users, source: :permission
+ has_many :comment_permissions_users, dependent: :destroy
+ has_many :comment_permissions, through: :comment_permissions_users, source: :permission
+ has_many :action_items, foreign_key: 'assignee_id', class_name: 'ActionItem'
+
+ has_and_belongs_to_many :teams
+
+ validates :provider, presence: true
+ validates :uid, presence: true, uniqueness: { scope: :provider }
+ validates :nickname, uniqueness: true, if: :nickname?
mount_uploader :avatar, AvatarUploader
- def self.from_omniauth(auth)
- where(provider: auth.provider, uid: auth.uid).first_or_create do |user|
- user.email = auth.info.email
- user.password = Devise.friendly_token[0, 20]
- user.remote_avatar_url = auth.info.image
+ def self.from_omniauth(provider, uid, info)
+ user = find_by(provider: provider, uid: uid) || find_by(email: info[:email]) || new
+
+ user.tap do |u|
+ u.provider = provider
+ u.uid = uid
+ u.email = info[:email]
+ u.remote_avatar_url = info[:image] || info[:avatar_url] if u.changed?
+
+ u.send :new_user_settings, info
+ u.save
end
end
+
+ def allowed?(identifier, resource)
+ permission = Permission.find_by(identifier: identifier)
+ resource_name = resource.class.to_s.downcase
+
+ public_send("#{resource_name}_permissions_users").where("#{resource_name}": resource,
+ permission: permission).any?
+ end
+
+ private
+
+ def new_user_settings(info)
+ return unless new_record?
+
+ self.password = Devise.friendly_token[0, 20]
+ self.nickname = info[:nickname]
+ self.first_name = info[:first_name] if info[:first_name]
+ self.last_name = info[:last_name] if info[:last_name]
+ end
end
diff --git a/app/operations/boards/build_permissions.rb b/app/operations/boards/build_permissions.rb
new file mode 100644
index 00000000..c1ff41b7
--- /dev/null
+++ b/app/operations/boards/build_permissions.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+module Boards
+ class BuildPermissions
+ IDENTIFIERS_SCOPES = %w[creator member author].freeze
+
+ include Dry::Monads[:result]
+ attr_reader :board, :user
+
+ def initialize(board, user)
+ @board = board
+ @user = user
+ end
+
+ def call(identifiers_scope:)
+ unless IDENTIFIERS_SCOPES.include?(identifiers_scope.to_s)
+ return Failure('Unknown permissions identifiers scope provided')
+ end
+
+ permissions_data = Permission.public_send(
+ "#{identifiers_scope}_permissions"
+ ).map do |permission|
+ { permission_id: permission.id, user_id: user.id }
+ end
+
+ board.board_permissions_users.build(permissions_data)
+ Success()
+ end
+ end
+end
diff --git a/app/operations/boards/cards/build_permissions.rb b/app/operations/boards/cards/build_permissions.rb
new file mode 100644
index 00000000..946506e0
--- /dev/null
+++ b/app/operations/boards/cards/build_permissions.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+module Boards
+ module Cards
+ class BuildPermissions
+ IDENTIFIERS_SCOPES = %w[card].freeze
+
+ include Dry::Monads[:result]
+ attr_reader :card, :user
+
+ def initialize(card, user)
+ @card = card
+ @user = user
+ end
+
+ def call(identifiers_scope:)
+ unless IDENTIFIERS_SCOPES.include?(identifiers_scope.to_s)
+ return Failure('Unknown permissions identifiers scope provided')
+ end
+
+ permissions_data = Permission.public_send(
+ "#{identifiers_scope}_permissions"
+ ).map do |permission|
+ { permission_id: permission.id, user_id: user.id }
+ end
+
+ card.card_permissions_users.build(permissions_data)
+ Success()
+ end
+ end
+ end
+end
diff --git a/app/operations/boards/cards/comments/build_permissions.rb b/app/operations/boards/cards/comments/build_permissions.rb
new file mode 100644
index 00000000..3d93fbd1
--- /dev/null
+++ b/app/operations/boards/cards/comments/build_permissions.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+module Boards
+ module Cards
+ module Comments
+ class BuildPermissions
+ IDENTIFIERS_SCOPES = %w[comment].freeze
+
+ include Dry::Monads[:result]
+ attr_reader :comment, :user
+
+ def initialize(comment, user)
+ @comment = comment
+ @user = user
+ end
+
+ def call(identifiers_scope:)
+ unless IDENTIFIERS_SCOPES.include?(identifiers_scope.to_s)
+ return Failure('Unknown permissions identifiers scope provided')
+ end
+
+ permissions_data = Permission.public_send(
+ "#{identifiers_scope}_permissions"
+ ).map do |permission|
+ { permission_id: permission.id, user_id: user.id }
+ end
+
+ comment.comment_permissions_users.build(permissions_data)
+ Success()
+ end
+ end
+ end
+ end
+end
diff --git a/app/operations/boards/cards/comments/create.rb b/app/operations/boards/cards/comments/create.rb
new file mode 100644
index 00000000..5458aaf8
--- /dev/null
+++ b/app/operations/boards/cards/comments/create.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module Boards
+ module Cards
+ module Comments
+ class Create
+ include Dry::Monads[:result]
+ attr_reader :user
+
+ def initialize(user)
+ @user = user
+ end
+
+ def call(comment_params)
+ comment = Comment.new(comment_params)
+
+ comment.transaction do
+ BuildPermissions.new(comment, user).call(identifiers_scope: 'comment')
+ comment.save!
+ end
+
+ Success(comment)
+ rescue StandardError => e
+ Failure(e)
+ end
+ end
+ end
+ end
+end
diff --git a/app/operations/boards/cards/create.rb b/app/operations/boards/cards/create.rb
new file mode 100644
index 00000000..4f40f13b
--- /dev/null
+++ b/app/operations/boards/cards/create.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module Boards
+ module Cards
+ class Create
+ include Dry::Monads[:result]
+ attr_reader :user
+
+ def initialize(user)
+ @user = user
+ end
+
+ def call(card_params)
+ card = Card.new(card_params)
+
+ card.transaction do
+ BuildPermissions.new(card, user).call(identifiers_scope: 'card')
+ card.save!
+ end
+
+ Success(card)
+ rescue StandardError => e
+ Failure(e)
+ end
+ end
+ end
+end
diff --git a/app/operations/boards/continue.rb b/app/operations/boards/continue.rb
index 136bddaa..a211a27d 100644
--- a/app/operations/boards/continue.rb
+++ b/app/operations/boards/continue.rb
@@ -18,12 +18,14 @@ def call
end
new_board = Board.new(
- title: default_board_name,
- previous_board_id: prev_board.id
+ title: new_board_title,
+ previous_board_id: prev_board.id,
+ column_names: prev_board.column_names,
+ private: prev_board.private
)
new_board.memberships = duplicate_memberships
- new_board.memberships.build(user_id: current_user.id, role: 'creator')
+ new_board.board_permissions_users = duplicate_permissions_users
new_board.save!
Success(new_board)
@@ -34,16 +36,26 @@ def call
private
+ def new_board_title
+ prev_board.title.split('#')[0] + " ##{board_number}"
+ end
+
+ def board_number
+ Boards::GetHistoryOfBoard.new(prev_board.id).call.size + 1 if prev_board
+ end
+
def duplicate_memberships
prev_board.memberships
.map(&:dup)
- .delete_if { |member| member.user == current_user }
.each do |member|
- member.role = 'member'
member.ready = false
end
end
+ def duplicate_permissions_users
+ prev_board.board_permissions_users.map(&:dup)
+ end
+
def default_board_name
Date.today.strftime('%d-%m-%Y')
end
diff --git a/app/operations/boards/invite_users.rb b/app/operations/boards/invite_users.rb
index 49c859a4..315ee8e2 100644
--- a/app/operations/boards/invite_users.rb
+++ b/app/operations/boards/invite_users.rb
@@ -11,8 +11,13 @@ def initialize(board, users)
end
def call
- users_array = users.map { |user| { role: 'member', user_id: user.id } }
- memberships = board.memberships.build(users_array)
+ users_data = users.map { |user| { role: 'member', user_id: user.id } }
+ memberships = board.memberships.build(users_data)
+
+ @users.find_each do |user|
+ BuildPermissions.new(@board, user).call(identifiers_scope: 'member')
+ end
+
board.save
Success(memberships)
end
diff --git a/app/operations/boards/rename_columns.rb b/app/operations/boards/rename_columns.rb
new file mode 100644
index 00000000..2cd82e6d
--- /dev/null
+++ b/app/operations/boards/rename_columns.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module Boards
+ class RenameColumns
+ include Dry::Monads[:result]
+ attr_reader :board
+
+ def initialize(board)
+ @board = board
+ end
+
+ def call(prev_names, new_names)
+ prev_names.zip(new_names).map do |old_type, new_type|
+ unless old_type == new_type
+ board.cards.where(kind: old_type)
+ .update(kind: new_type)
+ end
+ end
+ Success(board)
+ end
+ end
+end
diff --git a/app/policies/action_item_policy.rb b/app/policies/action_item_policy.rb
index cd31259e..f57c6679 100644
--- a/app/policies/action_item_policy.rb
+++ b/app/policies/action_item_policy.rb
@@ -4,26 +4,34 @@ class ActionItemPolicy < ApplicationPolicy
authorize :board, allow_nil: true
def create?
- check?(:user_is_creator?)
+ user.allowed?('create_action_items', board)
+ end
+
+ def update?
+ user.allowed?('update_action_items', current_board)
+ end
+
+ def destroy?
+ user.allowed?('destroy_action_items', current_board)
end
def move?
- check?(:user_is_creator?) && record.pending?
+ user.allowed?('move_action_items', current_board) && record.pending?
end
def close?
- check?(:user_is_creator?) && record.may_close?
+ user.allowed?('close_action_items', current_board) && record.may_close?
end
def complete?
- check?(:user_is_creator?) && record.may_complete?
+ user.allowed?('complete_action_items', current_board) && record.may_complete?
end
def reopen?
- check?(:user_is_creator?) && record.may_reopen?
+ user.allowed?('reopen_action_items', current_board) && record.may_reopen?
end
- def user_is_creator?
- board ? board.creator?(user) : record.board.creator?(user)
+ def current_board
+ board || record.board
end
end
diff --git a/app/policies/api/action_item_policy.rb b/app/policies/api/action_item_policy.rb
deleted file mode 100644
index 58674786..00000000
--- a/app/policies/api/action_item_policy.rb
+++ /dev/null
@@ -1,35 +0,0 @@
-# frozen_string_literal: true
-
-module API
- class ActionItemPolicy < ApplicationPolicy
- authorize :board, allow_nil: true
-
- def update?
- check?(:user_is_creator?)
- end
-
- def destroy?
- check?(:user_is_creator?)
- end
-
- def move?
- check?(:user_is_creator?) && record.pending?
- end
-
- def close?
- check?(:user_is_creator?) && record.may_close?
- end
-
- def complete?
- check?(:user_is_creator?) && record.may_complete?
- end
-
- def reopen?
- check?(:user_is_creator?) && record.may_reopen?
- end
-
- def user_is_creator?
- board ? board.creator?(user) : record.board.creator?(user)
- end
- end
-end
diff --git a/app/policies/api/board_policy.rb b/app/policies/api/board_policy.rb
deleted file mode 100644
index 8c510923..00000000
--- a/app/policies/api/board_policy.rb
+++ /dev/null
@@ -1,17 +0,0 @@
-# frozen_string_literal: true
-
-module API
- class BoardPolicy < ApplicationPolicy
- def suggestions?
- check?(:user_is_member?)
- end
-
- def invite?
- check?(:user_is_member?)
- end
-
- def user_is_member?
- record.member?(user)
- end
- end
-end
diff --git a/app/policies/api/card_policy.rb b/app/policies/api/card_policy.rb
deleted file mode 100644
index bc5e4641..00000000
--- a/app/policies/api/card_policy.rb
+++ /dev/null
@@ -1,21 +0,0 @@
-# frozen_string_literal: true
-
-module API
- class CardPolicy < ApplicationPolicy
- def update?
- check?(:user_is_author?)
- end
-
- def destroy?
- check?(:user_is_author?)
- end
-
- def like?
- !check?(:user_is_author?)
- end
-
- def user_is_author?
- record.author?(user)
- end
- end
-end
diff --git a/app/policies/api/membership_policy.rb b/app/policies/api/membership_policy.rb
deleted file mode 100644
index 7c4ecd54..00000000
--- a/app/policies/api/membership_policy.rb
+++ /dev/null
@@ -1,35 +0,0 @@
-# frozen_string_literal: true
-
-module API
- class MembershipPolicy
- include ActionPolicy::Policy::Core
- include ActionPolicy::Policy::Authorization
- include ActionPolicy::Policy::Reasons
-
- authorize :membership, allow_nil: true
-
- def index?
- check?(:role_is_member?)
- end
-
- def ready_status?
- check?(:role_is_member?)
- end
-
- def ready_toggle?
- check?(:role_is_member?)
- end
-
- def destroy?
- check?(:role_is_creator?) && !record.creator?
- end
-
- def role_is_member?
- membership&.member? || role_is_creator?
- end
-
- def role_is_creator?
- membership&.creator? || false
- end
- end
-end
diff --git a/app/policies/board_policy.rb b/app/policies/board_policy.rb
index 9ab014c6..0f55b5a4 100644
--- a/app/policies/board_policy.rb
+++ b/app/policies/board_policy.rb
@@ -5,12 +5,28 @@ def index?
true
end
+ def my?
+ true
+ end
+
+ def participating?
+ true
+ end
+
+ def history?
+ true
+ end
+
+ def show?
+ record.private ? user.allowed?('view_private_board', record) : true
+ end
+
def new?
true
end
def edit?
- user_is_creator?
+ user.allowed?('edit_board', record)
end
def create?
@@ -18,18 +34,34 @@ def create?
end
def update?
- user_is_creator?
+ user.allowed?('update_board', record)
end
def destroy?
- user_is_creator?
+ user.allowed?('destroy_board', record)
end
def continue?
- user_is_creator?
+ user.allowed?('continue_board', record) && can_continue?
+ end
+
+ def create_cards?
+ user.allowed?('create_cards', record)
+ end
+
+ def create_comments?
+ user.allowed?('create_comments', record)
+ end
+
+ def suggestions?
+ user.allowed?('get_suggestions', record)
+ end
+
+ def invite?
+ user.allowed?('invite_members', record)
end
- def user_is_creator?
- record.creator?(user)
+ def can_continue?
+ !Board.exists?(previous_board_id: record.id)
end
end
diff --git a/app/policies/card_policy.rb b/app/policies/card_policy.rb
index 4acee775..3af3ed82 100644
--- a/app/policies/card_policy.rb
+++ b/app/policies/card_policy.rb
@@ -1,11 +1,15 @@
# frozen_string_literal: true
class CardPolicy < ApplicationPolicy
- def create?
- check?(:user_is_member?)
+ def update?
+ user.allowed?('update_card', record)
end
- def user_is_member?
- record.board.member?(user)
+ def destroy?
+ user.allowed?('destroy_card', record) || user.allowed?('destroy_any_card', record.board)
+ end
+
+ def like?
+ record.author_id == user.id ? false : user.allowed?('like_card', record)
end
end
diff --git a/app/policies/comment_policy.rb b/app/policies/comment_policy.rb
new file mode 100644
index 00000000..5beaf35b
--- /dev/null
+++ b/app/policies/comment_policy.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class CommentPolicy < ApplicationPolicy
+ def update?
+ user.allowed?('update_comment', record)
+ end
+
+ def destroy?
+ user.allowed?('destroy_comment', record)
+ end
+
+ def like?
+ check?(:user_not_author?)
+ end
+
+ def user_not_author?
+ !record.author?(user)
+ end
+end
diff --git a/app/policies/membership_policy.rb b/app/policies/membership_policy.rb
new file mode 100644
index 00000000..99e4391b
--- /dev/null
+++ b/app/policies/membership_policy.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class MembershipPolicy < ApplicationPolicy
+ authorize :membership, allow_nil: true
+
+ def ready_toggle?
+ user.allowed?('toggle_ready_status', record.board)
+ end
+
+ def destroy?
+ return false if record.user_id == user.id
+
+ user.allowed?('destroy_membership', record.board)
+ end
+end
diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb
new file mode 100644
index 00000000..0d17510e
--- /dev/null
+++ b/app/policies/user_policy.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class UserPolicy < ApplicationPolicy
+ def edit?
+ users_match?
+ end
+
+ def update?
+ users_match?
+ end
+
+ def avatar_destroy?
+ users_match?
+ end
+
+ def users_match?
+ user.id == record.id
+ end
+end
diff --git a/app/queries/boards/get_history_of_board.rb b/app/queries/boards/get_history_of_board.rb
new file mode 100644
index 00000000..2cdf1c3b
--- /dev/null
+++ b/app/queries/boards/get_history_of_board.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module Boards
+ class GetHistoryOfBoard
+ attr_reader :board_id
+
+ def initialize(board_id)
+ @board_id = board_id
+ end
+
+ def call
+ previous_boards_ids = Board.connection.execute(history_query).values.flatten
+ Board.where(id: previous_boards_ids)
+ end
+
+ private
+
+ HISTORY_OF_BOARD = <<~SQL
+ WITH RECURSIVE previous_boards(id, previous_board_id) AS (
+ SELECT id, previous_board_id FROM boards WHERE id = ?
+ UNION ALL
+ SELECT b.id, b.previous_board_id FROM previous_boards AS p, boards AS b WHERE b.id = p.previous_board_id
+ )
+ SELECT id FROM previous_boards;
+ SQL
+
+ def history_query
+ ActiveRecord::Base.send(:sanitize_sql_array, [HISTORY_OF_BOARD, board_id])
+ end
+ end
+end
diff --git a/app/queries/boards/suggestions.rb b/app/queries/boards/suggestions.rb
index c0b35598..bef5ce98 100644
--- a/app/queries/boards/suggestions.rb
+++ b/app/queries/boards/suggestions.rb
@@ -17,7 +17,8 @@ def call
def fitting_users(str)
User.where('email ILIKE ?', "#{str}%")
- .or(User.where('uid LIKE ?', "#{str}%"))
+ .or(User.where('nickname ILIKE ?', "#{str}%"))
+ .or(User.where('uid LIKE ?', "#{str}%")).limit(10)
end
def fitting_teams(str)
diff --git a/app/serializers/action_item_serializer.rb b/app/serializers/action_item_serializer.rb
new file mode 100644
index 00000000..42adfc94
--- /dev/null
+++ b/app/serializers/action_item_serializer.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class ActionItemSerializer < ActiveModel::Serializer
+ attributes :id, :body, :times_moved, :status, :assignee_avatar_url
+ has_one :assignee
+ belongs_to :author
+
+ def assignee_avatar_url
+ object.assignee.avatar.thumb.url if object.assignee
+ end
+end
diff --git a/app/serializers/board_serializer.rb b/app/serializers/board_serializer.rb
new file mode 100644
index 00000000..33cd46dd
--- /dev/null
+++ b/app/serializers/board_serializer.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class BoardSerializer < ActiveModel::Serializer
+ attributes :id, :title, :slug, :created_at, :updated_at
+
+ has_many :cards
+end
diff --git a/app/serializers/card_serializer.rb b/app/serializers/card_serializer.rb
new file mode 100644
index 00000000..81cc2a09
--- /dev/null
+++ b/app/serializers/card_serializer.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class CardSerializer < ActiveModel::Serializer
+ attributes :id, :kind, :body, :likes
+ has_one :author
+ has_many :comments
+
+ def comments
+ object.comments.order('created_at DESC')
+ end
+end
diff --git a/app/serializers/comment_serializer.rb b/app/serializers/comment_serializer.rb
new file mode 100644
index 00000000..56ae6e96
--- /dev/null
+++ b/app/serializers/comment_serializer.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+class CommentSerializer < ActiveModel::Serializer
+ attributes :id, :content, :card_id, :likes
+ has_one :author
+end
diff --git a/app/serializers/membership_serializer.rb b/app/serializers/membership_serializer.rb
index d2282461..88ee70be 100644
--- a/app/serializers/membership_serializer.rb
+++ b/app/serializers/membership_serializer.rb
@@ -1,6 +1,7 @@
# frozen_string_literal: true
class MembershipSerializer < ActiveModel::Serializer
- attributes :id, :ready
+ attributes :id, :role, :ready
+
belongs_to :user
end
diff --git a/app/serializers/user_serializer.rb b/app/serializers/user_serializer.rb
index 99d9b612..a0a82040 100644
--- a/app/serializers/user_serializer.rb
+++ b/app/serializers/user_serializer.rb
@@ -1,5 +1,9 @@
# frozen_string_literal: true
class UserSerializer < ActiveModel::Serializer
- attributes :email
+ attributes :id, :email, :avatar, :nickname, :first_name, :last_name, :name
+
+ def name
+ "#{object.first_name} #{object.last_name}"
+ end
end
diff --git a/app/services/daily_action_items_service.rb b/app/services/daily_action_items_service.rb
new file mode 100644
index 00000000..d1a63768
--- /dev/null
+++ b/app/services/daily_action_items_service.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class DailyActionItemsService
+ def send_mails(board)
+ board.users.find_each(batch_size: 500) do |user|
+ DailyActionItemsMailer.send_action_items(user, board).deliver_later
+ end
+ end
+end
diff --git a/app/uploaders/avatar_uploader.rb b/app/uploaders/avatar_uploader.rb
index a89d379d..806da5fe 100644
--- a/app/uploaders/avatar_uploader.rb
+++ b/app/uploaders/avatar_uploader.rb
@@ -5,16 +5,16 @@ class AvatarUploader < CarrierWave::Uploader::Base
# include CarrierWave::RMagick
include CarrierWave::MiniMagick
+ def extension_whitelist
+ %w[jpg jpeg gif png]
+ end
+
# Override the directory where uploaded files will be stored.
# This is a sensible default for uploaders that are meant to be mounted:
def store_dir
"uploads/#{model.class.to_s.underscore}/#{mounted_as}/#{model.id}"
end
- def default_url
- '/assets/default_avatar.jpg'
- end
-
# Process files as they are uploaded:
# process scale: [200, 300]
#
@@ -27,6 +27,10 @@ def default_url
process resize_to_fit: [50, 50]
end
+ version :profile do
+ process resize_to_fit: [400, 400]
+ end
+
# Add a white list of extensions which are allowed to be uploaded.
# For images you might use something like this:
# def extension_whitelist
diff --git a/app/views/action_items/index.html.erb b/app/views/action_items/index.html.erb
index 1fb659f0..3dc0925f 100644
--- a/app/views/action_items/index.html.erb
+++ b/app/views/action_items/index.html.erb
@@ -1,34 +1,80 @@
-
-
Action items
-
-
-
+
+
Action Items assigned to me
+
+
+
Item
+ Continued
-
+ Board
Created at
-
+
-
+
<% @action_items.each do |item| %>
<%= item.body %>
+ <%= item.times_moved if item.times_moved > 0 %>
<% if allowed_to?(:close?, item, context: { board: item.board }) %>
- <%= link_to "close", close_action_item_path(item.id), method: :put, class: 'button is-small is-danger' %>
+ <%= link_to 'close', close_action_item_path(item.id), method: :put, class: 'button is-small is-danger' %>
<% end %>
<% if allowed_to?(:complete?, item, context: { board: item.board }) %>
- <%= link_to "done", complete_action_item_path(item.id), method: :put, class: 'button is-small is-success' %>
+ <%= link_to 'done', complete_action_item_path(item.id), method: :put, class: 'button is-small is-success' %>
<% end %>
<% if allowed_to?(:reopen?, item, context: { board: item.board }) %>
- <%= link_to "reopen", reopen_action_item_path(item.id), method: :put, class: 'button is-small is-light' %>
+ <%= link_to 'reopen', reopen_action_item_path(item.id), method: :put, class: 'button is-small is-light' %>
<% end %>
- <%= link_to "Board: #{item.board}", item.board %>
- <%= item.created_at %>
+ <%= link_to item.board.title, item.board %>
+ <%= item.created_at.strftime('%d %b %Y, %H:%M')%>
+
+ <% end %>
+
+
+
+
+ Show resolved items
+
+
+
+
+
+ Item
+ Continued
+ Board
+ Updated at
+
+
+
+
+ <% @action_items_resolved.each do |item| %>
+
+ <%= item.body %>
+ <%= item.times_moved if item.times_moved > 0 %>
+ <%= link_to item.board.title, item.board %>
+ <%= item.updated_at.strftime('%d %b %Y, %H:%M')%>
<% end %>
-
-
+
+
+
+
+
+
diff --git a/app/views/boards/_board_card.html.erb b/app/views/boards/_board_card.html.erb
new file mode 100644
index 00000000..b79bb6ec
--- /dev/null
+++ b/app/views/boards/_board_card.html.erb
@@ -0,0 +1,48 @@
+
+
+
+ <%= link_to board.title, board %>
+
+
+
+ <%= image_tag 'icon-members.svg', class: 'inline-icon' %>
+
+
+ <%= pluralize(board.users.count, 'member') %>
+
+ <% board.users.each do |user| %>
+ <%= user.email %>
+
+ <% end %>
+
+
+
+ <%= image_tag 'icon-calendar.png', class: 'inline-icon', style: 'max-width: 17px;' %>
+
+
+ <%= board.created_at.strftime('%d %b %Y') %>
+
+
+ <%= image_tag 'icon-cards.png', class: 'inline-icon' %>
+
+
+ <%= pluralize(board.cards.count, 'card')%>
+
+
+ <%= image_tag 'icon-done.svg', class: 'inline-icon' %>
+
+
+ <%= "#{board.action_items.where(status: 'done')&.count || 0}/#{board.action_items.count} action items done" %>
+
+
+
+ <% if allowed_to?(:continue?, board) %>
+ <%= link_to 'continue', continue_board_path(board), class: 'button board-button', method: :post %>
+ <% end %>
+ <% if allowed_to?(:destroy?, board) %>
+ <%= link_to 'delete', board, method: :delete, class: 'button board-button',
+ data: { confirm: 'Are you sure you want to delete this board?' } %>
+ <% end %>
+
+
+
diff --git a/app/views/boards/_form.html.erb b/app/views/boards/_form.html.erb
index 49ede1aa..f53f4597 100644
--- a/app/views/boards/_form.html.erb
+++ b/app/views/boards/_form.html.erb
@@ -11,8 +11,20 @@
<% end %>
- <%= form.text_field :title, class: 'input' %>
+ <%= form.text_field :title, class: 'input' %>
+
+ <%= form.check_box :private%>
+ <%= form.label(:private, 'Make board private')%>
+
+
+ <% @board.column_names.each_with_index do |name, index| %>
+
+ <%= form.label(:private, "The #{(index+1).ordinalize} column name")%>
+ <%= form.text_field :column_names, class: 'input', multiple: true, value: name %>
+
+ <% end %>
+
<%= form.submit %>
diff --git a/app/views/boards/edit.html.erb b/app/views/boards/edit.html.erb
index 9c991d6d..ecf71e4c 100644
--- a/app/views/boards/edit.html.erb
+++ b/app/views/boards/edit.html.erb
@@ -1,9 +1,20 @@
-
-
Editing Board
-
-
- <%= render 'form', board: @board %>
-
+<%= javascript_pack_tag 'application'%>
+
+
+
Editing Board
+
+
+ <% if allowed_to?(:update?, @board) %>
+ <%= render 'form', board: @board %>
+ <% end %>
+
-<%= link_to 'Show', @board %> |
-<%= link_to 'Back', boards_path %>
+ <% if allowed_to?(:invite?, @board) %>
+
+ <%= react_component 'invite-block-container'%>
+
+ <% end %>
+
+ <%= link_to 'Board page', @board %> |
+ <%= link_to 'Boards menu', my_boards_path %>
+
diff --git a/app/views/boards/history.html.erb b/app/views/boards/history.html.erb
new file mode 100644
index 00000000..4573ce6d
--- /dev/null
+++ b/app/views/boards/history.html.erb
@@ -0,0 +1,25 @@
+
+
+
History of <%= @board.title %>
+ <% @boards_by_date.each_with_index do |arr, index| %>
+ <% month, records = arr %>
+
2%>' style="margin-top: 20px;">
+ <%= month %>
+
+
+ <% records.each do |board| %>
+ <%= render 'board_card', board: board %>
+ <% end %>
+
+ <% end %>
+ <% if @boards_by_date.length > 3 %>
+
+ <%= image_tag 'chevron-down.svg', class: 'load-more-boards-button' %>
+
+ <% end %>
+
+
+
+<%= link_to 'Back', board_path(@board) %>
+
+<%= javascript_pack_tag 'boards_index_animation' %>
diff --git a/app/views/boards/index.html.erb b/app/views/boards/index.html.erb
deleted file mode 100644
index cec04cf3..00000000
--- a/app/views/boards/index.html.erb
+++ /dev/null
@@ -1,37 +0,0 @@
-
-
Your boards
-
-
-
-
- TITLE
- DATE
- ACTIONS
-
-
-
-
- <% @boards.each do |board| %>
-
- <%= link_to board.title, board %>
- <%= board.created_at %>
-
- <% if allowed_to?(:continue?, board)%>
- <%= link_to 'continue', continue_board_path(board), method: :post %>
- <% end %>
- <% if allowed_to?(:edit?, board)%>
- <%= link_to 'edit', edit_board_path(board) %>
- <% end %>
- <% if allowed_to?(:destroy?, board)%>
- <%= link_to 'delete', board, method: :delete,
- data: { confirm: 'Are you sure you want to delete this board?' } %>
- <% end %>
-
-
- <% end %>
-
-
-
-
-
-<%= link_to 'New Board', new_board_path %>
diff --git a/app/views/boards/my.html.erb b/app/views/boards/my.html.erb
new file mode 100644
index 00000000..e8c3831d
--- /dev/null
+++ b/app/views/boards/my.html.erb
@@ -0,0 +1,27 @@
+
+
+
My Boards
+
>
+ <%= image_tag 'icon-plus.svg', class: 'inline-icon'%>
+ Create new board
+
+ <% @boards_by_date.each_with_index do |arr, index| %>
+ <% month, records = arr%>
+
2%>' style="margin-top: 20px;">
+ <%= month%>
+
+
+ <% records.each do |board|%>
+ <%= render 'board_card', board: board %>
+ <% end%>
+
+ <% end %>
+ <% if @boards_by_date.length > 3%>
+
+ <%= image_tag 'chevron-down.svg', class: 'load-more-boards-button'%>
+
+ <% end %>
+
+
+
+<%= javascript_pack_tag 'boards_index_animation'%>
diff --git a/app/views/boards/new.html.erb b/app/views/boards/new.html.erb
index 12c6e593..2497e0f9 100644
--- a/app/views/boards/new.html.erb
+++ b/app/views/boards/new.html.erb
@@ -1,7 +1,9 @@
-
-
New Board
+
+
+
New Board
+
+
+ <%= render 'form', board: @board %>
+
+ <%= link_to 'Back', my_boards_path %>
-
- <%= render 'form', board: @board %>
-
-<%= link_to 'Back', boards_path %>
diff --git a/app/views/boards/participating.html.erb b/app/views/boards/participating.html.erb
new file mode 100644
index 00000000..4f9c9c5b
--- /dev/null
+++ b/app/views/boards/participating.html.erb
@@ -0,0 +1,27 @@
+
+
+
Boards where I am
+
>
+ <%= image_tag 'icon-plus.svg', class: 'inline-icon'%>
+ Create new board
+
+ <% @boards_by_date.each_with_index do |arr, index| %>
+ <% month, records = arr%>
+
2%>' style="margin-top: 20px;">
+ <%= month%>
+
+
+ <% records.each do |board|%>
+ <%= render 'board_card', board: board %>
+ <% end%>
+
+ <% end %>
+ <% if @boards_by_date.length > 3%>
+
+ <%= image_tag 'chevron-down.svg', class: 'load-more-boards-button'%>
+
+ <% end %>
+
+
+
+<%= javascript_pack_tag 'boards_index_animation'%>
diff --git a/app/views/boards/show.html.erb b/app/views/boards/show.html.erb
index 40e07f31..ffe145ee 100644
--- a/app/views/boards/show.html.erb
+++ b/app/views/boards/show.html.erb
@@ -1,107 +1,51 @@
<% content_for :scripts do %>
<%= javascript_pack_tag 'application' %>
<%= stylesheet_pack_tag 'application' %>
-
- <%= javascript_pack_tag 'ready_button_component' %>
- <% if allowed_to?(:invite?, @board, with: API::BoardPolicy) %>
- <%= javascript_pack_tag 'autocomplete' %>
+ <% if allowed_to?(:create_cards?, @board) %>
+ <%= javascript_pack_tag 'ready-button' %>
<% end %>
-<% end %>
-
-
+<% end %>
-
-
+
-
-
-<% if @previous_action_items %>
- <% column_class_name = 'column is-one-fifth' %>
-<% else %>
- <% column_class_name = 'column is-one-quarter' %>
-<% end %>
-
-
- <% if @previous_action_items %>
-
-
- PREVIOUS BOARD
-
- <% @previous_action_items.each do |action_item| %>
- <%= react_component('ActionItem/index', {
- id: action_item.id,
- body: action_item.body,
- status: action_item.status,
- times_moved: action_item.times_moved,
- movable: allowed_to?(:move?, action_item, with: API::ActionItemPolicy, context: { board: @board }),
- transitionable: {
- can_close: allowed_to?(:close?, action_item, with: API::ActionItemPolicy, context: { board: @board }),
- can_complete: allowed_to?(:complete?, action_item, with: API::ActionItemPolicy, context: { board: @board }),
- can_reopen: allowed_to?(:reopen?, action_item, with: API::ActionItemPolicy, context: { board: @board })
- }
- })
- %>
- <% end %>
+
- <% end %>
- <% @cards_by_type.each do |kind, cards| %>
-
-
- <%= kind.upcase %>
-
-
- <%= render 'cards/form', card: @board.cards.build(kind: kind) %>
-
- <% cards.each do |card| %>
- <%= react_component('Card/index', {
- id: card.id,
- author: card.author.email.split('@').first,
- avatar: card.author.avatar.url(:thumb),
- body: card.body,
- likes: card.likes,
- editable: allowed_to?(:update?, card, with: API::CardPolicy),
- deletable: allowed_to?(:destroy?, card, with: API::CardPolicy)
- })
- %>
+
- <% end %>
-
-
-
- ACTION ITEMS
-
-
- <%= render 'action_items/form', action_item: @action_item %>
-
- <% @action_items.each do |action_item| %>
- <%= react_component('ActionItem/index', {
- id: action_item.id,
- body: action_item.body,
- times_moved: action_item.times_moved,
- editable: allowed_to?(:update?, action_item, with: API::ActionItemPolicy, context: { board: @board }),
- deletable: allowed_to?(:destroy?, action_item, with: API::ActionItemPolicy, context: { board: @board })
- })
- %>
- <% end %>
-
+
+<%= react_component('card-table', {
+ actionItems: @action_items,
+ board: @board,
+ cardsByType: @cards_by_type,
+ creators: @board_creators,
+ initPrevItems: @previous_action_items || [],
+ user: current_user,
+ users: @users
+})
+%>
diff --git a/app/views/boardsql/show.html.erb b/app/views/boardsql/show.html.erb
new file mode 100644
index 00000000..3bdf7974
--- /dev/null
+++ b/app/views/boardsql/show.html.erb
@@ -0,0 +1,7 @@
+<% content_for :scripts do %>
+ <%= javascript_pack_tag 'application' %>
+ <%= stylesheet_pack_tag 'application' %>
+<% end %>
+
+<%= javascript_pack_tag 'board' %>
+
diff --git a/app/views/cards/_form.html.erb b/app/views/cards/_form.html.erb
index d5775589..a33b6eb0 100644
--- a/app/views/cards/_form.html.erb
+++ b/app/views/cards/_form.html.erb
@@ -1,5 +1,5 @@
<%= form_with(model: card, url: board_cards_path(card.board.slug), local: true) do |form| %>
Add new '<%= card.kind %>' card
- <%= form.hidden_field(:kind) %>
- <%= form.text_field :body, class: 'input', autocomplete: 'off' %>
+ <%= form.hidden_field :kind, id: "card_#{card.kind}" %>
+ <%= form.text_field :body, class: 'input', autocomplete: 'off', id: "card_#{card.kind}_body" %>
<% end %>
diff --git a/app/views/daily_action_items_mailer/send_action_items.html.erb b/app/views/daily_action_items_mailer/send_action_items.html.erb
new file mode 100644
index 00000000..3157d7f6
--- /dev/null
+++ b/app/views/daily_action_items_mailer/send_action_items.html.erb
@@ -0,0 +1,11 @@
+
<%= @greeting %>
+
List of your new ActionItems from board <%= link_to @board.title, board_url(@board) %>:
+
+
+ <% @action_items.each do |item| %>
+
+ <%= item.body %>
+
+ <% end %>
+
+
diff --git a/app/views/devise/registrations/new.html.erb b/app/views/devise/registrations/new.html.erb
deleted file mode 100644
index 5750dc42..00000000
--- a/app/views/devise/registrations/new.html.erb
+++ /dev/null
@@ -1,29 +0,0 @@
-
Sign up
-
-<%= form_for(resource, as: resource_name, url: registration_path(resource_name)) do |f| %>
- <%= render 'devise/shared/error_messages', resource: resource %>
-
-
- <%= f.label :email %>
- <%= f.email_field :email, autofocus: true, autocomplete: 'email' %>
-
-
-
- <%= f.label :password %>
- <% if @minimum_password_length %>
- (<%= @minimum_password_length %> characters minimum)
- <% end %>
- <%= f.password_field :password, autocomplete: 'new-password' %>
-
-
-
- <%= f.label :password_confirmation %>
- <%= f.password_field :password_confirmation, autocomplete: 'new-password' %>
-
-
-
- <%= f.submit 'Sign up' %>
-
-<% end %>
-
-<%= render 'devise/shared/links' %>
diff --git a/app/views/devise/sessions/new.html.erb b/app/views/devise/sessions/new.html.erb
index c92483ea..3e5d19d2 100644
--- a/app/views/devise/sessions/new.html.erb
+++ b/app/views/devise/sessions/new.html.erb
@@ -1,26 +1,3 @@
-
Log in
-
-<%= form_for(resource, as: resource_name, url: session_path(resource_name)) do |f| %>
-
- <%= f.label :email %>
- <%= f.email_field :email, autofocus: true, autocomplete: 'email' %>
-
-
-
- <%= f.label :password %>
- <%= f.password_field :password, autocomplete: 'current-password' %>
-
-
- <% if devise_mapping.rememberable? %>
-
- <%= f.check_box :remember_me %>
- <%= f.label :remember_me %>
-
- <% end %>
-
-
- <%= f.submit 'Log in' %>
-
-<% end %>
+
Log in
<%= render 'devise/shared/links' %>
diff --git a/app/views/devise/shared/_links.html.erb b/app/views/devise/shared/_links.html.erb
index fc51f322..9238f744 100644
--- a/app/views/devise/shared/_links.html.erb
+++ b/app/views/devise/shared/_links.html.erb
@@ -1,11 +1,3 @@
-<%- if controller_name != 'sessions' %>
- <%= link_to 'Log in', new_session_path(resource_name) %>
-<% end %>
-
-<%- if devise_mapping.registerable? && controller_name != 'registrations' %>
- <%= link_to 'Sign up', new_registration_path(resource_name) %>
-<% end %>
-
<%- if devise_mapping.omniauthable? %>
<%- resource_class.omniauth_providers.each do |provider| %>
<%= link_to "Sign in with #{OmniAuth::Utils.camelize(provider)}", omniauth_authorize_path(resource_name, provider) %>
diff --git a/app/views/home/index.html.erb b/app/views/home/index.html.erb
index 4a895391..d829df3e 100644
--- a/app/views/home/index.html.erb
+++ b/app/views/home/index.html.erb
@@ -1,13 +1,16 @@
Welcome to Retro
-
- <% if user_signed_in? %>
-
Logged in as <%= current_user.email %>
-
<%= link_to 'See your boards', boards_path %>
-
<%= link_to 'Logout', destroy_user_session_path, method: :delete %>
- <% else %>
- <%= link_to 'Sign up', new_user_registration_path%> |
- <%= link_to 'Login', new_user_session_path %> |
- <%= link_to 'Sign up with GitHub', user_github_omniauth_authorize_path %>
+<% if user_signed_in? %>
+
Logged in as <%= current_user.email %>
+
<%= link_to 'Logout', destroy_user_session_path, method: :delete %>
+<% else %>
+ <% if ENV.fetch('SKIP_ALFRED', false) == 'true' %>
+ <%= link_to 'Sign in as Developer', user_developer_omniauth_callback_path %>
+ <%= '|' if providers.any? %>
<% end %>
-
+
+ <% providers.each do |provider| %>
+ <%= link_to "Sign in with #{provider.capitalize}", send("user_#{provider}_omniauth_authorize_path") %>
+ <%= '|' unless provider == providers.last %>
+ <% end %>
+<% end %>
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb
index adf4dfc8..d54b07a7 100644
--- a/app/views/layouts/application.html.erb
+++ b/app/views/layouts/application.html.erb
@@ -4,6 +4,7 @@
Retrospective
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
+ <%= action_cable_meta_tag %>
<%= stylesheet_link_tag 'application', media: 'all' %>
<%= javascript_include_tag 'application' %>
@@ -12,11 +13,17 @@
-
+
+
+ <%= render 'shared/menu' %>
+
+
<%= notice %>
<%= alert %>
-
+
<%= yield %>
+
+