diff --git a/.gitignore b/.gitignore index a7f1252..6c46912 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,6 @@ /coverage .idea .ruby-version +.ruby-gemset .env.dev .env.development diff --git a/apps/moderation/application.rb b/apps/moderation/application.rb index 5af5d59..2df7172 100644 --- a/apps/moderation/application.rb +++ b/apps/moderation/application.rb @@ -21,6 +21,7 @@ class Application < Hanami::Application # When you add new directories, remember to add them here. # load_paths << %w[ + helpers controllers views ] diff --git a/apps/moderation/config/routes.rb b/apps/moderation/config/routes.rb index 7c53944..bed9ddb 100644 --- a/apps/moderation/config/routes.rb +++ b/apps/moderation/config/routes.rb @@ -2,5 +2,7 @@ root to: 'dashboard#index' +get 'vacancy/:id', to: 'dashboard#show' + resources :vacancy_approve, only: %i[update] resources :vacancy_disapprove, only: %i[update] diff --git a/apps/moderation/controllers/dashboard/show.rb b/apps/moderation/controllers/dashboard/show.rb new file mode 100644 index 0000000..0eb3c84 --- /dev/null +++ b/apps/moderation/controllers/dashboard/show.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Moderation + module Controllers + module Dashboard + class Show + include Moderation::Action + include Dry::Monads::Result::Mixin + include Import[ + operation: 'vacancies.operations.vacancy_for_moderation' + ] + + expose :vacancy + + def call(params) + result = operation.call(id: params[:id]) + + case result + when Success + @vacancy = result.value! + when Failure + redirect_to routes.root_path + end + end + end + end + end +end diff --git a/apps/moderation/helpers/vacancy.rb b/apps/moderation/helpers/vacancy.rb new file mode 100644 index 0000000..786777e --- /dev/null +++ b/apps/moderation/helpers/vacancy.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +module Moderation + module Helpers + module Vacancy + def approve_button(id) + html.form(action: routes.vacancy_approve_path(id), method: 'POST') do + input(type: 'hidden', name: '_method', value: 'PATCH') + input(type: 'submit', value: 'Принять', class: 'btn btn-success') + end + end + + def disapprove_button(id) + html.form(action: routes.vacancy_disapprove_path(id), method: 'POST') do + input(type: 'hidden', name: '_method', value: 'PATCH') + input(type: 'submit', value: 'Отклонить', class: 'btn btn-warning') + end + end + + def company_text(vacancy) + published_at = RelativeTime.in_words(vacancy.created_at, locale: :ru) + raw "Компания #{company_link(vacancy)} (#{vacancy.location}), опубликована #{published_at}" + end + + def company_link(vacancy) + if vacancy.contact.site + link_to vacancy.contact.company, vacancy.contact.site + else + vacancy.contact.company + end + end + + def remote_badge(vacancy) + return unless vacancy.remote_available + + html.span(class: 'mr-2 badge badge-success') { 'Удаленно' } + end + + def position_type_badge(vacancy) + html.span(class: 'mr-2 badge badge-info') { POSITION_TYPE_VALUES[vacancy.position_type] } + end + + def vacancy_salary_information(vacancy) + currency = CURRENCY_VALUES[vacancy.salary_currency] + unit = UNIT_VALUES[vacancy.salary_unit] + + html.span(class: 'salary') do + text 'от ' + span(class: 'money') { vacancy.salary_min } + text ' до ' + span(class: 'money') { vacancy.salary_max } + text " #{currency} #{unit}" + end + end + + POSITION_TYPE_VALUES = { + 'full_time' => 'Полная занятость', + 'part_time' => 'Частичная занятость', + 'contractor' => 'Работа по контракту', + 'intern' => 'Интернатура', + 'temp' => 'Временная работа', + 'other' => 'Другое' + }.freeze + + CURRENCY_VALUES = { 'rub' => 'рублей', 'usd' => 'долларов', 'eur' => 'евро' }.freeze + + UNIT_VALUES = { + 'monthly' => 'в месяц', + 'yearly' => 'в год', + 'by hour' => 'в час', + 'per project' => 'за проект' + }.freeze + + def vacancy_details(vacancy) + raw_body(vacancy.details) + end + + def raw_body(body) + raw(body || '') + end + end + end +end diff --git a/apps/moderation/templates/dashboard/_vacancy.html.slim b/apps/moderation/templates/dashboard/_vacancy.html.slim new file mode 100644 index 0000000..f73adae --- /dev/null +++ b/apps/moderation/templates/dashboard/_vacancy.html.slim @@ -0,0 +1,42 @@ +li.list-group-item + .row + .col-sm-8 + h4 #{vacancy.position} (id: #{vacancy.id}) + .col-sm-4 + = vacancy_salary_information(vacancy) + .row + .col-sm-8 + = company_text(vacancy) + .col-sm-4 + = remote_badge(vacancy) + = position_type_badge(vacancy) + + hr.mb-4.mt-4 + + .row + .col.vacancy_details + = vacancy_details(vacancy) + + .row + .col + = vacancy.tags + + hr.mb-4.mt-4 + + .row + .col + = vacancy.contact.email + + .col + = vacancy.contact.full_name + + hr.mb-4.mt-4 + + .row + .col.mb-4 + = approve_button(vacancy.id) + + .col.mb-4.btn-float-right + = disapprove_button(vacancy.id) + + hr diff --git a/apps/moderation/templates/dashboard/index.html.slim b/apps/moderation/templates/dashboard/index.html.slim index 9324941..cce08e7 100644 --- a/apps/moderation/templates/dashboard/index.html.slim +++ b/apps/moderation/templates/dashboard/index.html.slim @@ -4,45 +4,4 @@ hr ul.list-group - vacancies.each do |vacancy| - li.list-group-item - .row - .col-sm-8 - h4 #{vacancy.position} (id: #{vacancy.id}) - .col-sm-4 - = vacancy_salary_information(vacancy) - .row - .col-sm-8 - = company_text(vacancy) - .col-sm-4 - = remote_badge(vacancy) - = position_type_badge(vacancy) - - hr.mb-4.mt-4 - - .row - .col.vacancy_details - = vacancy_details(vacancy) - - .row - .col - = vacancy.tags - - hr.mb-4.mt-4 - - .row - .col - = vacancy.contact.email - - .col - = vacancy.contact.full_name - - hr.mb-4.mt-4 - - .row - .col.mb-4 - = approve_button(vacancy.id) - - .col.mb-4.btn-float-right - = disapprove_button(vacancy.id) - - hr + = render partial: 'vacancy', locals: {vacancy: vacancy} diff --git a/apps/moderation/templates/dashboard/show.html.slim b/apps/moderation/templates/dashboard/show.html.slim new file mode 100644 index 0000000..7baf45c --- /dev/null +++ b/apps/moderation/templates/dashboard/show.html.slim @@ -0,0 +1,5 @@ +h1 Moderation + +hr + += render partial: 'vacancy' diff --git a/apps/moderation/views/dashboard/index.rb b/apps/moderation/views/dashboard/index.rb index 3921b0f..6962081 100644 --- a/apps/moderation/views/dashboard/index.rb +++ b/apps/moderation/views/dashboard/index.rb @@ -5,82 +5,7 @@ module Views module Dashboard class Index include Moderation::View - - def approve_button(id) - html.form(action: routes.vacancy_approve_path(id), method: 'POST') do - input(type: 'hidden', name: '_method', value: 'PATCH') - input(type: 'submit', value: 'Принять', class: 'btn btn-success') - end - end - - def disapprove_button(id) - html.form(action: routes.vacancy_disapprove_path(id), method: 'POST') do - input(type: 'hidden', name: '_method', value: 'PATCH') - input(type: 'submit', value: 'Отклонить', class: 'btn btn-warning') - end - end - - def company_text(vacancy) - published_at = RelativeTime.in_words(vacancy.created_at, locale: :ru) - raw "Компания #{company_link(vacancy)} (#{vacancy.location}), опубликована #{published_at}" - end - - def company_link(vacancy) - if vacancy.contact.site - link_to vacancy.contact.company, vacancy.contact.site - else - vacancy.contact.company - end - end - - def remote_badge(vacancy) - return unless vacancy.remote_available - - html.span(class: 'mr-2 badge badge-success') { 'Удаленно' } - end - - def position_type_badge(vacancy) - html.span(class: 'mr-2 badge badge-info') { POSITION_TYPE_VALUES[vacancy.position_type] } - end - - def vacancy_salary_information(vacancy) - currency = CURRENCY_VALUES[vacancy.salary_currency] - unit = UNIT_VALUES[vacancy.salary_unit] - - html.span(class: 'salary') do - text 'от ' - span(class: 'money') { vacancy.salary_min } - text ' до ' - span(class: 'money') { vacancy.salary_max } - text " #{currency} #{unit}" - end - end - - POSITION_TYPE_VALUES = { - 'full_time' => 'Полная занятость', - 'part_time' => 'Частичная занятость', - 'contractor' => 'Работа по контракту', - 'intern' => 'Интернатура', - 'temp' => 'Временная работа', - 'other' => 'Другое' - }.freeze - - CURRENCY_VALUES = { 'rub' => 'рублей', 'usd' => 'долларов', 'eur' => 'евро' }.freeze - - UNIT_VALUES = { - 'monthly' => 'в месяц', - 'yearly' => 'в год', - 'by hour' => 'в час', - 'per project' => 'за проект' - }.freeze - - def vacancy_details(vacancy) - raw_body(vacancy.details) - end - - def raw_body(body) - raw(body || '') - end + include Moderation::Helpers::Vacancy end end end diff --git a/apps/moderation/views/dashboard/show.rb b/apps/moderation/views/dashboard/show.rb new file mode 100644 index 0000000..e5c8ed1 --- /dev/null +++ b/apps/moderation/views/dashboard/show.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Moderation + module Views + module Dashboard + class Show + include Moderation::View + include Moderation::Helpers::Vacancy + end + end + end +end diff --git a/lib/core/repositories/vacancy_repository.rb b/lib/core/repositories/vacancy_repository.rb index 4e92728..63f86f3 100644 --- a/lib/core/repositories/vacancy_repository.rb +++ b/lib/core/repositories/vacancy_repository.rb @@ -19,11 +19,11 @@ def disapprove_by_pk(id) end def all_for_moderation - aggregate(:contact).where( - published: false, - archived: false, - deleted_at: nil - ).map_to(Vacancy).to_a + for_modertion.map_to(Vacancy).to_a + end + + def find_for_moderation(id) + for_modertion.by_pk(id).map_to(Vacancy).one end def find_with_contact(id) @@ -33,4 +33,14 @@ def find_with_contact(id) deleted_at: nil ).by_pk(id).map_to(Vacancy).one end + + private + + def for_modertion + aggregate(:contact).where( + published: false, + archived: false, + deleted_at: nil + ) + end end diff --git a/lib/vacancies/operations/vacancy_for_moderation.rb b/lib/vacancies/operations/vacancy_for_moderation.rb new file mode 100644 index 0000000..f14b1cd --- /dev/null +++ b/lib/vacancies/operations/vacancy_for_moderation.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Vacancies + module Operations + class VacancyForModeration < ::Libs::Operation + include Import[ + vacancy_repo: 'repositories.vacancy' + ] + + def call(id:) + vacancy = vacancy_repo.find_for_moderation(id) + vacancy ? Success(vacancy) : Failure(:not_found) + end + end + end +end diff --git a/spec/core/repositories/vacancy_repository_spec.rb b/spec/core/repositories/vacancy_repository_spec.rb index bc38250..6c8b58d 100644 --- a/spec/core/repositories/vacancy_repository_spec.rb +++ b/spec/core/repositories/vacancy_repository_spec.rb @@ -180,4 +180,43 @@ it { expect(subject).to be(nil) } end end + + describe '#find_for_moderation' do + subject { repo.find_for_moderation(vacancy.id) } + + let(:vacancy) { Fabricate.create(:vacancy, published: published, archived: archived, deleted_at: deleted_at) } + + context 'when vacancy not published' do + let(:published) { false } + let(:archived) { false } + let(:deleted_at) { nil } + + it { expect(subject).to eq(vacancy) } + it { expect(subject.contact).to be_a(Contact) } + end + + context 'when vacancy published and not archived or deleted' do + let(:published) { true } + let(:archived) { false } + let(:deleted_at) { nil } + + it { expect(subject).to be(nil) } + end + + context 'when vacancy published and archived' do + let(:published) { true } + let(:archived) { true } + let(:deleted_at) { nil } + + it { expect(subject).to be(nil) } + end + + context 'when vacancy published and deleted' do + let(:published) { true } + let(:archived) { false } + let(:deleted_at) { Time.now } + + it { expect(subject).to be(nil) } + end + end end diff --git a/spec/moderation/controllers/dashboard/show_spec.rb b/spec/moderation/controllers/dashboard/show_spec.rb new file mode 100644 index 0000000..9f326f9 --- /dev/null +++ b/spec/moderation/controllers/dashboard/show_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +RSpec.describe Moderation::Controllers::Dashboard::Show, type: :action do + subject { action.call(params) } + + let(:params) { { id: 42 } } + let(:action) { described_class.new(operation: operation) } + let(:operation) { instance_double('Vacancies::Operations::VacancyForModeration') } + + context 'when operation returns success value' do + let(:vacancy) { Fabricate.build(:vacancy) } + let(:params) { { id: 42 } } + + before do + allow(operation).to receive(:call).with(id: 42).and_return(Success(vacancy)) + end + + it { expect(subject).to be_success } + + it 'exposes list of vacancies' do + subject + expect(action.vacancy).to eq(vacancy) + end + end + + context 'when operation returns success value' do + before do + allow(operation).to receive(:call).with(id: 42).and_return(Failure(:not_found)) + end + + it { expect(subject).to redirect_to '/moderation' } + end + +end diff --git a/spec/moderation/views/dashboard/show_spec.rb b/spec/moderation/views/dashboard/show_spec.rb new file mode 100644 index 0000000..fb206d9 --- /dev/null +++ b/spec/moderation/views/dashboard/show_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +RSpec.describe Moderation::Views::Dashboard::Show, type: :view do + let(:exposures) { Hash[format: :html] } + let(:template) { Hanami::View::Template.new('apps/moderation/templates/dashboard/show.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 diff --git a/spec/vacancies/operations/vacancy_for_moderation_spec.rb b/spec/vacancies/operations/vacancy_for_moderation_spec.rb new file mode 100644 index 0000000..3b425db --- /dev/null +++ b/spec/vacancies/operations/vacancy_for_moderation_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +RSpec.describe Vacancies::Operations::VacancyForModeration, type: :operation do + let(:operation) { described_class.new(vacancy_repo: vacancy_repo) } + let(:vacancy_repo) { instance_double('VacancyRepository') } + let(:vacancy) { Fabricate.build(:vacancy) } + + before do + allow(vacancy_repo).to receive(:find_for_moderation).and_return(nil) + allow(vacancy_repo).to receive(:find_for_moderation).with(42).and_return(vacancy) + end + + context 'when a vacancy exists' do + subject { operation.call(id: 42) } + + it { expect(subject).to be_success } + it { expect(subject.value!).to eq(vacancy) } + end + + context "when a vacancy doesn't exist" do + subject { operation.call(id: 10) } + + it { expect(subject).to be_failure } + it { expect(subject.failure).to eq(:not_found) } + end +end