diff --git a/apps/web/config/routes.rb b/apps/web/config/routes.rb index 4e1692f..937590e 100644 --- a/apps/web/config/routes.rb +++ b/apps/web/config/routes.rb @@ -5,7 +5,7 @@ resources :vacancies, only: %i[new create show] resources :companies, only: %i[index show] do resources :reviews, only: %i[new create], controller: 'reviews' - resources :interviews, only: %i[new create], controller: 'interviews' + resources :interviews, only: %i[index new create], controller: 'interviews' end resources :subscribers, only: %i[create] diff --git a/apps/web/controllers/companies/show.rb b/apps/web/controllers/companies/show.rb index 7028559..dd0b3d2 100644 --- a/apps/web/controllers/companies/show.rb +++ b/apps/web/controllers/companies/show.rb @@ -16,7 +16,6 @@ class Show def call(params) result = operation.call(id: params[:id]) - case result when Success @company = result.value! diff --git a/apps/web/controllers/interviews/create.rb b/apps/web/controllers/interviews/create.rb index 56da8f4..5be8915 100644 --- a/apps/web/controllers/interviews/create.rb +++ b/apps/web/controllers/interviews/create.rb @@ -31,7 +31,7 @@ def call(params) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength rollbar.error(result.failure, payload: params.to_h) end - redirect_to routes.company_path(params[:company_id]) + redirect_to routes.company_interviews_path(params[:company_id]) end private diff --git a/apps/web/controllers/interviews/index.rb b/apps/web/controllers/interviews/index.rb new file mode 100644 index 0000000..a445f58 --- /dev/null +++ b/apps/web/controllers/interviews/index.rb @@ -0,0 +1,29 @@ +module Web + module Controllers + module Interviews + class Index + include Web::Action + include Dry::Monads::Result::Mixin + + include Import[ + operation: 'companies.operations.show', + interview_operation: 'interviews.operations.list' + ] + + expose :interviews, :company + + def call(params) + result = interview_operation.call(company_id: params[:company_id].to_i) + company_result = operation.call(id: params[:company_id].to_i) + case company_result + when Success + @company = company_result.value! + @interviews = result.value_or([]) + when Failure + redirect_to routes.companies_path + end + end + end + end + end +end diff --git a/apps/web/templates/companies/index.html.slim b/apps/web/templates/companies/index.html.slim index a3a50b7..c258a02 100644 --- a/apps/web/templates/companies/index.html.slim +++ b/apps/web/templates/companies/index.html.slim @@ -38,7 +38,7 @@ ul.list-group .col-sm-8 h4 = link_to company.name, routes.company_path(company.id), title: company.name .col-sm-4 - = "Рейтинг: #{company.rating_total}" + = "Рейтинг: #{company.rating_total} | Рейтинг интервью: #{company.interview_rating_total}" .row .col-sm-8 = company.url diff --git a/apps/web/templates/companies/show.html.slim b/apps/web/templates/companies/show.html.slim index dec03f9..39f67c5 100644 --- a/apps/web/templates/companies/show.html.slim +++ b/apps/web/templates/companies/show.html.slim @@ -41,9 +41,12 @@ hr.mb-4.mt-4 .col - if current_account.id = link_to 'Оставить отзыв', routes.new_company_review_path(company.id), class: 'btn btn-success btn-lg new-vacancy-btn' - = link_to 'Оставить отзыв', routes.new_company_interview_path(company.id), class: 'btn btn-success btn-lg new-vacancy-btn' + /col + /= link_to 'Отзывы об интервью', routes.company_interviews_path(company.id), class: 'btn btn-primary btn-lg new-interview-btn' - else = link_to 'Оставить отзыв', '#', class: 'btn btn-success btn-lg new-vacancy-btn disabled' + /.col + /= link_to 'Интервью', '#', class: 'btn btn-success btn-lg new-vacancy-btn disabled' hr.mb-4.mt-4 diff --git a/apps/web/templates/interviews/index.html.slim b/apps/web/templates/interviews/index.html.slim new file mode 100644 index 0000000..1f5a17a --- /dev/null +++ b/apps/web/templates/interviews/index.html.slim @@ -0,0 +1,74 @@ +.row.mt-4 + .col + h1 + = link_to 'Компании', routes.companies_path + | > #{link_to company.name, routes.company_path(company.id)} + | > Список отзывов об интервью + +.row + .col + = company_information(company) + +hr.mb-4.mt-4 + +.row + .col.mb-4.vacancy_details + -if company.ratings.any? + - default_rating_names.each do |rating, description| + .row + .col-8 + = description + .col-4 + = company_interview_ratings[rating] + - else + | Рейтингов пока нет. Оставте первый рейтинг + +.likely.likely-big + .twitter Твитнуть + .facebook Поделиться + .vkontakte Поделиться + .telegram Отправить + .whatsapp Вотсапнуть + +hr.mb-4.mt-4 + +- if current_account.id.nil? + .row + | Только #{link_to 'зарегистрированные пользователи', '/auth/github', class: 'link-in-text' } могут оставлять отзывы о компаниях + +.row.mt-4 + .col + = link_to 'К списку компаний', routes.companies_path, class: 'btn btn-outline-secondary btn-lg' + + .col + - if current_account.id + = link_to 'Оставить отзыв', routes.new_company_interview_path(company.id), class: 'btn btn-success btn-lg new-vacancy-btn' + - else + = link_to 'Оставить отзыв', '#', class: 'btn btn-success btn-lg new-vacancy-btn disabled' + +hr.mb-4.mt-4 + +br + +.row + .col.mb-4.vacancy_details + -if interviews.any? + - interviews.each do |interview| + li.list-group-item + .row + /.col-sm-2 + /img src="#{review.author.avatar_url}" alt="#{review.author.github} avatar" height="42" width="42" + .col-sm-6 + h4 = interview_author(interview) + span = published_at(interview) + + + .row + hr.mb-4.mt-4 + + .row + .col + = raw(interview.body) + + - else + | Отзывов пока нет. Оставте первый отзыв \ No newline at end of file diff --git a/apps/web/views/interviews/index.rb b/apps/web/views/interviews/index.rb new file mode 100644 index 0000000..5392519 --- /dev/null +++ b/apps/web/views/interviews/index.rb @@ -0,0 +1,53 @@ +module Web + module Views + module Interviews + class Index + include Web::View + + def title + 'Отзывы на компании Ruby, Hanami и Rails' + end + + # rubocop:disable Layout/LineLength + def seo_meta_information + { + title: 'Отзывы на компании Ruby, Hanami и Rails', + description: 'Отзывы на компании использующие Ruby по всему миру. Бесплатные условия для работодателей и соискателей.', + url: 'https://rubyjobs.dev/companies', + image: '' + } + end + + def default_rating_names # rubocop:disable Metrics/MethodLength + { + overall_impression: 'Общее впечатление', + recommendation: 'Рекомендация друзьям' + } + end + + def interview_author(interview) + if interview.anonymous + 'Anonymous author' + else + raw "#{interview.author.name} (#{link_to interview.author.github, "https://github.com/#{interview.author.github}"})" + end + end + + def published_at(interview) + RelativeTime.in_words(interview.created_at, locale: :ru) + end + + def company_interview_ratings + Hanami::Utils::Hash.symbolize(company.interview_ratings) + end + + def company_information(company) + # last_rating_time = RelativeTime.in_words(company.created_at, locale: :ru) + company_link = link_to company.name, company.url + + raw "Компания #{company_link}, рейтинг #{company.rating_total.round}" + end + end + end + end +end diff --git a/db/migrations/20200730182023_change_fields_in_companies.rb b/db/migrations/20200730182023_change_fields_in_companies.rb new file mode 100644 index 0000000..2b03bf3 --- /dev/null +++ b/db/migrations/20200730182023_change_fields_in_companies.rb @@ -0,0 +1,7 @@ +Hanami::Model.migration do + change do + alter_table :companies do + rename_column :interviews, :interview_ratings + end + end +end diff --git a/lib/core/entities/company.rb b/lib/core/entities/company.rb index e018260..d03236a 100644 --- a/lib/core/entities/company.rb +++ b/lib/core/entities/company.rb @@ -3,17 +3,22 @@ class Review < Hanami::Entity end +class Interview < Hanami::Entity +end + class Company < Hanami::Entity attributes do attribute :id, Types::Int - attribute :reviews, Types::Collection(Review) + attribute :interviews, Types::Collection(Interview) attribute :name, Types::String attribute :url, Types::String attribute :rating_total, Types::Float attribute :ratings, Core::Types::CompanyRatings + attribute :interview_rating_total, Types::Float + attribute :interview_ratings, Core::Types::CompanyRatings attribute :created_at, Types::Time attribute :updated_at, Types::Time diff --git a/lib/core/repositories/company_repository.rb b/lib/core/repositories/company_repository.rb index be2dffe..e323b9d 100644 --- a/lib/core/repositories/company_repository.rb +++ b/lib/core/repositories/company_repository.rb @@ -30,10 +30,8 @@ def update_statistic(id, ratings) # rubocop:disable Metrics/AbcSize, Metrics/Met new_ratings = {} company_ratings = Hanami::Utils::Hash.symbolize(company.ratings) - ALLOWED_RATINGS.each do |rating| new_ratings[rating] = company_ratings[rating].to_f if ratings[rating].to_f.zero? - next unless ratings[rating].to_f.positive? new_ratings[rating] = if company_ratings[rating].to_f.zero? @@ -42,7 +40,6 @@ def update_statistic(id, ratings) # rubocop:disable Metrics/AbcSize, Metrics/Met (company_ratings[rating].to_f + ratings[rating].to_f) / 2 end end - total_rating = (new_ratings.values.sum / new_ratings.values.count).round(1) update(id, ratings: new_ratings, rating_total: total_rating) @@ -61,6 +58,34 @@ def update_statistic(id, ratings) # rubocop:disable Metrics/AbcSize, Metrics/Met team_level ].freeze + def update_interview_statistic(id, interviews) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + transaction do |_t| + company = find(id) + new_interviews = {} + company_interviews = Hanami::Utils::Hash.symbolize(company.interview_ratings) + ALLOWED_INTERVIEW_RATINGS.each do |interview| + new_interviews[interview] = company_interviews[interview].to_f if interviews[interview].to_f.zero? + + next unless interviews[interview].to_f.positive? + + new_interviews[interview] = if company_interviews[interview].to_f.zero? + interviews[interview].to_f + else + (company_interviews[interview].to_f + interviews[interview].to_f) / 2 + end + end + + total_rating = (new_interviews.values.sum / new_interviews.values.count).round(1) + + update(id, interview_ratings: new_interviews, interview_rating_total: total_rating) + end + end + + ALLOWED_INTERVIEW_RATINGS = %i[ + overall_impression + recommendation + ].freeze + private def where_by_downcased_name(company_name) diff --git a/lib/interviews/operations/create.rb b/lib/interviews/operations/create.rb index 23ce658..543d258 100644 --- a/lib/interviews/operations/create.rb +++ b/lib/interviews/operations/create.rb @@ -32,6 +32,7 @@ def call(payload) interview = yield persist_interview(payload) # TODO: move this line with company repo to separate worker + company_repo.update_interview_statistic(payload[:company_id], payload[:interview_rating]) send_notification(interview) Success(interview) diff --git a/lib/interviews/operations/list.rb b/lib/interviews/operations/list.rb index 7a209f1..4700ecc 100644 --- a/lib/interviews/operations/list.rb +++ b/lib/interviews/operations/list.rb @@ -6,7 +6,9 @@ class List < ::Libs::Operation include Import[interview_repo: 'repositories.interview',] def call(company_id:) - Success(interview_repo.all_for_companies(company_id)) + Success( + interview_repo.all_for_companies(company_id) + ) end end end diff --git a/spec/interviews/operations/create_spec.rb b/spec/interviews/operations/create_spec.rb index dae5387..77467cb 100644 --- a/spec/interviews/operations/create_spec.rb +++ b/spec/interviews/operations/create_spec.rb @@ -8,7 +8,7 @@ end let(:interview_repo) { instance_double('InterviewRepository', create_with_interview_rating: Interview.new) } - let(:company_repo) { instance_double('CompanyRepository') } + let(:company_repo) { instance_double('CompanyRepository', update_interview_statistic: Company.new) } let(:params) do { @@ -29,6 +29,20 @@ context 'when successful operation' do it { expect(subject).to be_success } it { expect(subject.value!).to be_a(Interview) } + + it 'calls company statistic updater' do + expect(company_repo).to receive(:update_interview_statistic).with( + 10, + { + author_id: 0, + + overall_impression: 3.0, + recommendation: 3.0 + } + ) + + subject + end end context 'when interview data is invalid' do diff --git a/spec/web/controllers/interviews/create_spec.rb b/spec/web/controllers/interviews/create_spec.rb index 4a6797c..bc5411a 100644 --- a/spec/web/controllers/interviews/create_spec.rb +++ b/spec/web/controllers/interviews/create_spec.rb @@ -32,7 +32,7 @@ let(:operation) { ->(*) { Success(Vacancy.new(id: 123)) } } let(:success_flash) { 'Отзыв успешно создан.' } - it { expect(subject).to redirect_to '/companies/1' } + it { expect(subject).to redirect_to '/companies/1/interviews' } it 'shows flash message' do subject @@ -44,7 +44,7 @@ let(:operation) { ->(*) { Failure(:error) } } let(:flash_message) { 'Произошла ошибка, пожалуйста повторите позже' } - it { expect(subject).to redirect_to '/companies/1' } + it { expect(subject).to redirect_to '/companies/1/interviews' } it 'shows flash message' do subject @@ -59,7 +59,7 @@ let(:company_id) { Fabricate(:company).id } let(:action) { described_class.new } - it { expect(subject).to redirect_to "/companies/#{company_id}" } + it { expect(subject).to redirect_to "/companies/#{company_id}/interviews" } end context 'when not authorised' do diff --git a/spec/web/controllers/interviews/index_spec.rb b/spec/web/controllers/interviews/index_spec.rb new file mode 100644 index 0000000..ba0fae3 --- /dev/null +++ b/spec/web/controllers/interviews/index_spec.rb @@ -0,0 +1,50 @@ +RSpec.describe Web::Controllers::Interviews::Index, type: :action do + subject { action.call(params) } + + let(:action) { described_class.new(operation: operation, interview_operation: interview_operation) } + + let(:params) { { id: 0 } } + + context 'when operation returns success value' do + let(:operation) { ->(*) { Success(Company.new(id: 0)) } } + let(:interview_operation) { ->(*) { Success([Interview.new]) } } + + it { expect(subject).to be_success } + + it 'call operation with a right contract' do + expect(operation).to receive(:call).with(id: 0) + subject + end + + it 'exposes vacancy' do + subject + expect(action.company).to eq(Company.new(id: 0)) + expect(action.interviews).to eq([Interview.new]) + end + end + + context 'when operation returns failure value' do + let(:operation) { ->(*) { Failure(:not_found) } } + let(:interview_operation) { ->(*) { Success([Interview.new]) } } + + it { expect(subject).to redirect_to '/companies' } + + it 'call operation with a right contract' do + expect(operation).to receive(:call).with(id: 0) + subject + end + end + + context 'with real dependencies' do + subject { action.call(params) } + + let(:action) { described_class.new } + let(:params) { { id: company.id } } + + let(:company) { Fabricate.create(:company) } + + before { Fabricate(:review, company_id: company.id) } + + it { expect(subject).to be_success } + end +end diff --git a/spec/web/views/interviews/index_spec.rb b/spec/web/views/interviews/index_spec.rb new file mode 100644 index 0000000..c6137a9 --- /dev/null +++ b/spec/web/views/interviews/index_spec.rb @@ -0,0 +1,10 @@ +RSpec.describe Web::Views::Interviews::Index, type: :view do + let(:exposures) { Hash[format: :html] } + let(:template) { Hanami::View::Template.new('apps/web/templates/interviews/index.html.slim') } + let(:view) { described_class.new(template, exposures) } + let(:rendered) { view.render } + + it 'exposes #format' do + expect(view.format).to eq exposures.fetch(:format) + end +end