diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css
index e2d6d54fc12..cca9379c689 100644
--- a/app/assets/stylesheets/application.css
+++ b/app/assets/stylesheets/application.css
@@ -57,6 +57,8 @@
@import "./application/blocks/page-content/_page-content-header.css";
@import "./application/blocks/page-content/_page-content-members.css";
@import "./application/blocks/page-content/_page-content-prev-next.css";
+@import "./application/blocks/pair-work/_pair-work-info.css";
+@import "./application/blocks/pair-work/_pair-work-schedule-dates.css";
@import "./application/blocks/practice/_categories.css";
@import "./application/blocks/practice/_category-practices-item.css";
@import "./application/blocks/practice/_completion-massage.css";
diff --git a/app/assets/stylesheets/application/blocks/event/_event-main-actions.css b/app/assets/stylesheets/application/blocks/event/_event-main-actions.css
index d18f20e3acd..f6582ae9e54 100644
--- a/app/assets/stylesheets/application/blocks/event/_event-main-actions.css
+++ b/app/assets/stylesheets/application/blocks/event/_event-main-actions.css
@@ -2,21 +2,23 @@
.event-main-actions {
position: relative;
z-index: 1;
- padding-block: .75rem;
margin-inline: 1rem;
border-radius: 4px;
}
+.event-main-actions:first-child {
+ margin-top: 1rem;
+ margin-bottom: 0;
+}
+
@media (min-width: 48em) {
.event-main-actions {
- padding-inline: 2rem;
margin-bottom: 1.5rem;
}
}
@media (max-width: 47.9375em) {
.event-main-actions {
- padding-inline: .75rem;
margin-bottom: 1rem;
}
}
@@ -45,6 +47,72 @@
color: var(--danger);
}
+.event-main-actions__header {
+ padding-block: .75rem;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.event-main-actions__header .a-button {
+ margin-block: -.5rem;
+}
+
+@media (min-width: 48em) {
+ .event-main-actions__header {
+ padding-inline: 1rem;
+ }
+}
+
+@media (max-width: 47.9375em) {
+ .event-main-actions__header {
+ padding-inline: .75rem;
+ }
+}
+
+.is-participationed .event-main-actions__header {
+ border-bottom: 1px solid var(--success);
+ color: #4e732e;
+}
+
+.is-unparticipationed.is-available .event-main-actions__header {
+ border-bottom: 1px solid var(--primary);
+ color: #28248c;
+}
+
+.is-unparticipationed.is-capacity-over .event-main-actions__header {
+ border-bottom: 1px solid var(--warning);
+ color: #6f5819;
+}
+
+.is-non-participationed .event-main-actions__header {
+ border-bottom: 1px solid var(--danger);
+ color: var(--danger);
+}
+
+.event-main-actions__title {
+ font-size: .875rem;
+ line-height: 1.4;
+ font-weight: 700;
+ text-align: center;
+}
+
+.event-main-actions__body {
+ padding-block: .75rem;
+}
+
+@media (min-width: 48em) {
+ .event-main-actions__body {
+ padding-inline: 1rem;
+ }
+}
+
+@media (max-width: 47.9375em) {
+ .event-main-actions__body {
+ padding-inline: .75rem;
+ }
+}
+
.event-main-actions__description {
font-size: .875rem;
line-height: 1.4;
diff --git a/app/assets/stylesheets/application/blocks/page/_page-body-header.css b/app/assets/stylesheets/application/blocks/page/_page-body-header.css
new file mode 100644
index 00000000000..e7f2b870d23
--- /dev/null
+++ b/app/assets/stylesheets/application/blocks/page/_page-body-header.css
@@ -0,0 +1,13 @@
+.page-body-header__inner {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ margin-bottom: 1rem;
+}
+
+.page-body-header__title {
+ font-size: 1rem;
+ line-height: 1.4;
+ font-weight: 700;
+ color: var(--main);
+}
diff --git a/app/assets/stylesheets/application/blocks/pair-work/_pair-work-info.css b/app/assets/stylesheets/application/blocks/pair-work/_pair-work-info.css
new file mode 100644
index 00000000000..1b02a65406e
--- /dev/null
+++ b/app/assets/stylesheets/application/blocks/pair-work/_pair-work-info.css
@@ -0,0 +1,48 @@
+.pair-work-info {
+ display: flex;
+ gap: 0.75rem;
+}
+
+.pair-work-info__user-icon {
+ width: 3.5rem;
+ height: 3.5rem;
+}
+
+.pair-work-info__end {
+ flex: 1;
+}
+
+.pair-badge {
+ font-family: serif;
+ border: double 0.1875rem var(--stamp-color);
+ border-radius: 0.75rem;
+ width: 4rem;
+ height: 3.5rem;
+ padding: 0.125rem;
+ position: absolute;
+ z-index: 1;
+ transform: rotate(25deg);
+ font-size: 1.125rem;
+ line-height: 1.1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-weight: 800;
+ color: var(--stamp-color);
+ flex-direction: column;
+ pointer-events: none;
+}
+
+@media (min-width: 48em) {
+ .pair-badge {
+ right: 0;
+ top: -0.125rem;
+ }
+}
+
+@media (max-width: 47.9375em) {
+ .pair-badge {
+ left: 80%;
+ top: 0;
+ }
+}
diff --git a/app/assets/stylesheets/application/blocks/pair-work/_pair-work-schedule-dates.css b/app/assets/stylesheets/application/blocks/pair-work/_pair-work-schedule-dates.css
new file mode 100644
index 00000000000..b2b770778de
--- /dev/null
+++ b/app/assets/stylesheets/application/blocks/pair-work/_pair-work-schedule-dates.css
@@ -0,0 +1,44 @@
+.pair-work-schedule-dates.is-solved .pair-work-schedule-dates__table {
+ display: none;
+}
+
+.pair-work-schedule-dates.is-solved input:checked + .pair-work-schedule-dates__table {
+ display: block;
+}
+
+.pair-work-schedule-dates__action-items {
+ display: flex;
+ justify-content: center;
+}
+
+.pair-work-schedule-dates__action-item {
+ min-width: 20rem;
+}
+
+.pair-work-schedule-dates__action-item-description {
+ margin-top: 0.25rem;
+ font-size: 0.75rem;
+ line-height: 1.4;
+ text-align: center;
+}
+
+.pair-work-schedule-dates__title {
+ font-size: 1rem;
+ line-height: 1.4;
+ font-weight: 600;
+ margin-bottom: 1rem;
+}
+
+.pair-work-schedule-dates__action + .pair-work-schedule-dates__table-container .pair-work-schedule-dates__table {
+ margin-top: 1rem;
+}
+
+.pair-work-schedule-dates__cancel {
+ display: flex;
+ justify-content: center;
+ margin-bottom: 1rem;
+}
+
+.pair-work-schedule-dates__cancel-action {
+ min-width: 20rem;
+}
diff --git a/app/assets/stylesheets/atoms/_a-card.css b/app/assets/stylesheets/atoms/_a-card.css
index 5bb548f1b74..b4bc776b2d0 100644
--- a/app/assets/stylesheets/atoms/_a-card.css
+++ b/app/assets/stylesheets/atoms/_a-card.css
@@ -65,15 +65,6 @@
overflow-y: auto;
}
-.a-card:has(.a-table) {
- border: none;
-}
-
-.a-card:has(.a-table) .card-header {
- border: solid 1px var(--border);
- border-bottom: none;
-}
-
.a-card.is-danger {
border: solid 0.25rem var(--danger);
}
diff --git a/app/assets/stylesheets/atoms/_a-form-help.css b/app/assets/stylesheets/atoms/_a-form-help.css
index 6ce6d52acfd..1b114490cfe 100644
--- a/app/assets/stylesheets/atoms/_a-form-help.css
+++ b/app/assets/stylesheets/atoms/_a-form-help.css
@@ -6,7 +6,7 @@
margin-top: 0.5rem;
}
label + .a-form-help:not(:first-child) {
- margin-top: 0;
+ margin-top: -0.5rem;
}
.a-form-help + input, .a-form-help + textarea {
margin-top: 0.5rem;
diff --git a/app/assets/stylesheets/config/variables/_css-variables.css b/app/assets/stylesheets/config/variables/_css-variables.css
index b651655649c..562d37a9e59 100644
--- a/app/assets/stylesheets/config/variables/_css-variables.css
+++ b/app/assets/stylesheets/config/variables/_css-variables.css
@@ -81,7 +81,7 @@
--input-focus-shadow: rgba(87, 82, 232, 0.4) 0 0 1px 2px;
--header-height__md-up: 3.125rem;
--header-height__sm-down: 2.75rem;
- --global-nav-width: 5.5rem;
+ --global-nav-width: 5.75rem;
--global-nav-width-sm: 13rem;
--thread-header-author: 4.75rem;
--side-nav-width: 17rem;
diff --git a/app/assets/stylesheets/shared/blocks/_global-nav.css b/app/assets/stylesheets/shared/blocks/_global-nav.css
index 52cbfff4d68..00b596bab11 100644
--- a/app/assets/stylesheets/shared/blocks/_global-nav.css
+++ b/app/assets/stylesheets/shared/blocks/_global-nav.css
@@ -51,8 +51,8 @@ input:checked + .global-nav .global-nav__background.a-overlay {
@media (min-width: 48em) {
.global-nav-links__link {
flex-direction: column;
- height: 4.125rem;
- gap: 0.25rem;
+ height: 4.5rem;
+ gap: 0.375rem;
padding-inline: 0;
justify-content: center;
}
@@ -73,19 +73,15 @@ input:checked + .global-nav .global-nav__background.a-overlay {
}
@media (min-width: 48em) {
.global-nav-links__link-icon {
- font-size: 1.125rem;
+ font-size: 1.25rem;
margin-right: 0;
- margin-bottom: 0.25rem;
+ display: flex;
+ align-items: center;
}
}
-.global-nav-links__link-icon .fa-rocket {
- font-size: 1.15em;
-}
.global-nav-links__link-label {
- font-size: 0.625rem;
line-height: 1.2;
- text-align: center;
}
@media (max-width: 47.9375em) {
.global-nav-links__link-label {
@@ -93,9 +89,10 @@ input:checked + .global-nav .global-nav__background.a-overlay {
}
}
@media (min-width: 48em) {
- .global-nav-links__link-label.is-sm {
- transform: scale(0.875);
- margin-top: -0.125rem;
+ .global-nav-links__link-label {
+ font-size: 0.625rem;
+ line-height: 1.4;
+ text-align: center;
}
}
diff --git a/app/components/page_tabs_component.html.slim b/app/components/page_tabs_component.html.slim
index 7f526a5291e..c7098a5833d 100644
--- a/app/components/page_tabs_component.html.slim
+++ b/app/components/page_tabs_component.html.slim
@@ -1,4 +1,4 @@
-.page-tabs
+nav.page-tabs
.container
ul.page-tabs__items
- tabs.each do |tab|
diff --git a/app/components/sub_tab_component.html.slim b/app/components/sub_tab_component.html.slim
index 07f8001c404..6276094d2f8 100644
--- a/app/components/sub_tab_component.html.slim
+++ b/app/components/sub_tab_component.html.slim
@@ -1,3 +1,9 @@
li.tab-nav__item
= link_to link, class: "tab-nav__item-link #{active ? 'is-active' : ''}" do
= name
+ - if count.present?
+ = " (#{count})"
+ - if badge&.positive?
+ .page-tabs__item-count.a-notification-count
+ .not-solved-count
+ = badge
diff --git a/app/components/sub_tab_component.rb b/app/components/sub_tab_component.rb
index b44a4a2cda8..105aa25e14d 100644
--- a/app/components/sub_tab_component.rb
+++ b/app/components/sub_tab_component.rb
@@ -1,13 +1,15 @@
# frozen_string_literal: true
class SubTabComponent < ViewComponent::Base
- def initialize(name:, link:, active: false)
+ def initialize(name:, link:, active: false, count: nil, badge: nil)
@name = name
@link = link
@active = active
+ @count = count
+ @badge = badge
end
private
- attr_reader :name, :link, :active
+ attr_reader :name, :link, :active, :count, :badge
end
diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb
index a506ca6b1ad..81514916e95 100644
--- a/app/controllers/home_controller.rb
+++ b/app/controllers/home_controller.rb
@@ -7,6 +7,7 @@ def index
if current_user
display_dashboard
display_events_on_dashboard
+ display_pair_works_on_dashboard
display_welcome_message_for_adviser
set_required_fields
display_products_for_mentor
@@ -57,6 +58,10 @@ def display_events_on_dashboard
@upcoming_events_groups = UpcomingEvent.upcoming_events_groups
end
+ def display_pair_works_on_dashboard
+ @upcoming_pair_works = PairWork.upcoming_pair_works(current_user)
+ end
+
def display_welcome_message_for_adviser
@welcome_message_first_time = cookies[:confirmed_welcome_message]
end
diff --git a/app/controllers/pair_works/reservations_controller.rb b/app/controllers/pair_works/reservations_controller.rb
new file mode 100644
index 00000000000..127a65164a9
--- /dev/null
+++ b/app/controllers/pair_works/reservations_controller.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+class PairWorks::ReservationsController < ApplicationController
+ before_action :require_mentor_login, only: %i[create]
+
+ def create
+ @pair_work = PairWork.find(params[:pair_work_id])
+ if @pair_work.reserve(pair_work_reservation_params)
+ ActiveSupport::Notifications.instrument('pair_work.reserve', pair_work: @pair_work)
+ redirect_to Redirection.determin_url(self, @pair_work), notice: @pair_work.generate_notice_message(:reserve)
+ else
+ @comments = @pair_work.comments.order(:created_at)
+ render 'pair_works/show'
+ end
+ end
+
+ def destroy; end
+
+ private
+
+ def pair_work_reservation_params
+ params.require(:pair_work).permit(:reserved_at).merge(buddy_id: current_user.id)
+ end
+end
diff --git a/app/controllers/pair_works_controller.rb b/app/controllers/pair_works_controller.rb
new file mode 100644
index 00000000000..8b40fbd4e6f
--- /dev/null
+++ b/app/controllers/pair_works_controller.rb
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+
+class PairWorksController < ApplicationController
+ before_action :set_my_pair_work, only: %i[edit update destroy]
+
+ PAGER_NUMBER = 10
+
+ def index
+ @pair_works = PairWork.by_target(params[:target])
+ .with_avatar
+ .includes(:practice, :comments, :user)
+ .order(:published_at)
+ .page(params[:page])
+ .per(PAGER_NUMBER)
+ @pair_works_property = PairWork.generate_pair_works_property(params[:target])
+ end
+
+ def show
+ @pair_work = PairWork.find(params[:id])
+ @comments = @pair_work.comments.order(:created_at)
+ end
+
+ def edit; end
+
+ def new
+ @pair_work = PairWork.new(channel: 'ペアワーク・モブワーク1')
+ end
+
+ def create
+ @pair_work = PairWork.new(pair_work_params)
+ @pair_work.user = current_user
+ set_wip
+ if @pair_work.save
+ ActiveSupport::Notifications.instrument('pair_work.create', pair_work: @pair_work)
+ redirect_to Redirection.determin_url(self, @pair_work), notice: @pair_work.generate_notice_message(:create)
+ else
+ render :new
+ end
+ end
+
+ def update
+ set_wip
+ if @pair_work.update(pair_work_params)
+ ActiveSupport::Notifications.instrument('pair_work.update', pair_work: @pair_work)
+ redirect_to Redirection.determin_url(self, @pair_work), notice: @pair_work.generate_notice_message(:update)
+ else
+ render :edit
+ end
+ end
+
+ def destroy
+ @pair_work.destroy
+ redirect_to pair_works_url, notice: @pair_work.generate_notice_message(:destroy)
+ end
+
+ private
+
+ def pair_work_params
+ params.require(:pair_work).permit(:practice_id, :title, :description, :reserved_at, :buddy_id, :channel,
+ schedules_attributes: %i[id proposed_at _destroy])
+ end
+
+ def set_my_pair_work
+ @pair_work = current_user.admin? ? PairWork.find(params[:id]) : current_user.pair_works.find(params[:id])
+ end
+
+ def set_wip
+ @pair_work.wip = params[:commit] == 'WIP'
+ end
+end
diff --git a/app/decorators/pair_work_decorator.rb b/app/decorators/pair_work_decorator.rb
new file mode 100644
index 00000000000..a1ea8eef288
--- /dev/null
+++ b/app/decorators/pair_work_decorator.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module PairWorkDecorator
+ def important?
+ comments.blank? && !solved?
+ end
+end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 768588c202d..88513a6c301 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -15,4 +15,8 @@ def smart_search_available?
def movie_available?
Rails.env.local? || Switchlet.enabled?(:movie)
end
+
+ def pair_work_available?
+ Rails.env.local? || Switchlet.enabled?(:pair_work)
+ end
end
diff --git a/app/helpers/page_tabs/questions_and_pair_works_helper.rb b/app/helpers/page_tabs/questions_and_pair_works_helper.rb
new file mode 100644
index 00000000000..eb75d17b371
--- /dev/null
+++ b/app/helpers/page_tabs/questions_and_pair_works_helper.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module PageTabs
+ module QuestionsAndPairWorksHelper
+ def questions_and_pair_works_page_tabs
+ tabs = []
+ tabs << { name: 'Q&A', link: questions_path(target: 'not_solved'), badge: Question.unsolved_badge(current_user:, practice_id: params[:practice_id]) }
+ tabs << { name: 'ペアワーク', link: pair_works_path(target: 'not_solved'), badge: PairWork.unsolved_badge(current_user:) }
+ render PageTabsComponent.new(tabs:, active_tab: question_and_pair_work_active_tab)
+ end
+
+ private
+
+ def question_and_pair_work_active_tab
+ case request.path
+ when %r{\A/questions}
+ 'Q&A'
+ when %r{\A/pair_works}
+ 'ペアワーク'
+ end
+ end
+ end
+end
diff --git a/app/helpers/pair_works_helper.rb b/app/helpers/pair_works_helper.rb
new file mode 100644
index 00000000000..783b07fab4e
--- /dev/null
+++ b/app/helpers/pair_works_helper.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+module PairWorksHelper
+ def schedule_dates(date)
+ (0..6).map { |i| date.to_date + i.days }
+ end
+
+ def sorted_wdays(date)
+ max_wday = 6
+ sorted_wdays = [date.wday]
+ max_wday.times do
+ next sorted_wdays << 0 if sorted_wdays.last == max_wday
+
+ sorted_wdays << sorted_wdays.last + 1
+ end
+ sorted_wdays
+ end
+
+ def schedule_check_disabled?(target_time, pair_work: nil)
+ expired = target_time < Time.current
+ if pair_work
+ expired || pair_work.user_id == current_user.id
+ else
+ expired
+ end
+ end
+
+ def learning_time_frame_checked?(target_time, id)
+ !schedule_check_disabled?(target_time) && current_user.learning_time_frame_ids.include?(id)
+ end
+
+ def schedule_target_time(day_count, hour_count, pair_work: nil)
+ if pair_work
+ pair_work.created_at.beginning_of_day + day_count.days + hour_count.hours
+ else
+ Time.current.beginning_of_day + day_count.days + hour_count.hours
+ end
+ end
+
+ def schedule_check_box_id(target_time)
+ "schedule_ids_#{target_time.strftime('%Y%m%d%H%M')}"
+ end
+
+ def meta_label_by_status(upcoming_pair_work)
+ today = upcoming_pair_work.reserved_at.to_date == Time.current.to_date
+ today ? 'a-meta__label is-important' : 'a-meta__label'
+ end
+end
diff --git a/app/helpers/sub_tabs/pair_works_helper.rb b/app/helpers/sub_tabs/pair_works_helper.rb
new file mode 100644
index 00000000000..5f4ff2f98cd
--- /dev/null
+++ b/app/helpers/sub_tabs/pair_works_helper.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+module SubTabs
+ module PairWorksHelper
+ def pair_works_sub_tabs
+ tabs = []
+ tabs << { name: 'ペア募集中', link: pair_works_path(target: 'not_solved'), badge: PairWork.unsolved_badge(current_user:) }
+ tabs << { name: 'ペア確定', link: pair_works_path(target: 'solved') }
+ tabs << { name: '全て', link: pair_works_path }
+ render SubTabsComponent.new(tabs:, active_tab: pair_work_active_tab)
+ end
+
+ private
+
+ def pair_work_active_tab
+ case params[:target]
+ when 'not_solved'
+ 'ペア募集中'
+ when 'solved'
+ 'ペア確定'
+ else
+ '全て'
+ end
+ end
+ end
+end
diff --git a/app/helpers/page_tabs/questions_helper.rb b/app/helpers/sub_tabs/questions_helper.rb
similarity index 84%
rename from app/helpers/page_tabs/questions_helper.rb
rename to app/helpers/sub_tabs/questions_helper.rb
index d7f4f4c7340..47c2a6888c1 100644
--- a/app/helpers/page_tabs/questions_helper.rb
+++ b/app/helpers/sub_tabs/questions_helper.rb
@@ -1,14 +1,14 @@
# frozen_string_literal: true
-module PageTabs
+module SubTabs
module QuestionsHelper
- def questions_page_tabs
+ def questions_sub_tabs
unsolved_badge = Question.unsolved_badge(current_user:, practice_id: params[:practice_id])
tabs = []
tabs << { name: '未解決', link: questions_path(target: 'not_solved'), badge: unsolved_badge }
tabs << { name: '解決済み', link: questions_path(target: 'solved') }
tabs << { name: '全て', link: questions_path }
- render PageTabsComponent.new(tabs:, active_tab: question_active_tab)
+ render SubTabsComponent.new(tabs:, active_tab: question_active_tab)
end
private
diff --git a/app/mailers/activity_mailer.rb b/app/mailers/activity_mailer.rb
index eac9d294aaf..4db68afc575 100644
--- a/app/mailers/activity_mailer.rb
+++ b/app/mailers/activity_mailer.rb
@@ -19,6 +19,7 @@ class ActivityMailer < ApplicationMailer # rubocop:todo Metrics/ClassLength
@product = params[:product] if params&.key?(:product)
@report = params[:report] if params&.key?(:report)
@regular_event = params[:regular_event] if params&.key?(:regular_event)
+ @pair_work = params[:pair_work] if params&.key?(:pair_work)
@message = params[:message] if params&.key?(:message)
end
@@ -468,4 +469,43 @@ def added_work(args = {})
message.perform_deliveries = @user.mail_notification? && !@user.retired?
message
end
+
+ def came_pair_work(args = {})
+ @receiver ||= args[:receiver]
+ @pair_work ||= args[:pair_work]
+
+ @user = @receiver
+ @title = @pair_work.practice.present? ? "「#{@pair_work.practice.title}」についてのペアワーク依頼がありました。" : 'ペアワーク依頼がありました。'
+ @link_url = notification_redirector_url(
+ link: "/pair_works/#{@pair_work.id}",
+ kind: Notification.kinds[:came_pair_work]
+ )
+
+ subject = "[FBC] #{@pair_work.user.login_name}さんからペアワーク依頼「#{@pair_work.title}」が投稿されました。"
+ message = mail(to: @user.email, subject:)
+
+ message.perform_deliveries = @user.mail_notification? && !@user.retired?
+ message
+ end
+
+ def matching_pair_work(args = {})
+ @receiver ||= args[:receiver]
+ @pair_work ||= args[:pair_work]
+
+ @user = @receiver
+ @title = @pair_work.practice.present? ? "「#{@pair_work.practice.title}」についてのペアワークのペアが見つかりました。" : 'ペアワークのペアが見つかりました。'
+ matched_user = @pair_work.buddy
+ @user_name = @receiver == matched_user ? 'あなた' : "#{matched_user.login_name}さん"
+
+ @link_url = notification_redirector_url(
+ link: "/pair_works/#{@pair_work.id}",
+ kind: Notification.kinds[:matching_pair_work]
+ )
+
+ subject = "[FBC] #{@pair_work.user.login_name}さんのペアワーク【 #{@pair_work.title} 】のペアが#{@user_name}に決定しました。"
+ message = mail(to: @user.email, subject:)
+
+ message.perform_deliveries = @user.mail_notification? && !@user.retired?
+ message
+ end
end
diff --git a/app/models/cache.rb b/app/models/cache.rb
index 1f3b96702e1..c32e39f03ef 100644
--- a/app/models/cache.rb
+++ b/app/models/cache.rb
@@ -63,6 +63,17 @@ def delete_not_solved_question_count
Rails.cache.delete 'not_solved_question_count'
end
+ def not_solved_pair_work_count
+ Rails.cache.fetch 'not_solved_pair_work_count' do
+ Rails.logger.info '[CACHE MISS] Executing DB query for not_solved_pair_work_count'
+ PairWork.not_solved.not_wip.count
+ end
+ end
+
+ def delete_not_solved_pair_work_count
+ Rails.cache.delete 'not_solved_pair_work_count'
+ end
+
def mentioned_notification_count(user)
Rails.cache.fetch "#{user.id}-mentioned_notification_count" do
user.notifications.by_target(:mention).latest_of_each_link.size
diff --git a/app/models/concerns/mentioner.rb b/app/models/concerns/mentioner.rb
index 490703d2ead..a79430acc62 100644
--- a/app/models/concerns/mentioner.rb
+++ b/app/models/concerns/mentioner.rb
@@ -26,6 +26,9 @@ def where_mention
"#{user.login_name}さんのQ&A「#{practice_title}」"
when MicroReport
"#{user.login_name}さんの分報"
+ when PairWork
+ practice_title = practice ? practice[:title] : 'プラクティス選択なし'
+ "#{user.login_name}さんのペアワーク「#{practice_title}」"
end
end
@@ -77,7 +80,8 @@ def target_of_comment(commentable_class, commentable)
Event: "特別イベント「#{commentable.title}」",
RegularEvent: "定期イベント「#{commentable.title}」",
Page: "Docs「#{commentable.title}」",
- Announcement: "お知らせ「#{commentable.title}」"
+ Announcement: "お知らせ「#{commentable.title}」",
+ PairWork: "ペアワーク「#{commentable.title}」"
}[:"#{commentable_class}"]
end
end
diff --git a/app/models/concerns/watchable.rb b/app/models/concerns/watchable.rb
index b7c8e44de94..e75443d6d78 100644
--- a/app/models/concerns/watchable.rb
+++ b/app/models/concerns/watchable.rb
@@ -33,12 +33,14 @@ def notification_title
"Docs「#{self[:title]}」"
when Announcement
"お知らせ「#{self[:title]}」"
+ when PairWork
+ "ペアワーク「#{self[:title]}」"
end
end
def body
case self
- when Question, Event, RegularEvent, Report, Announcement
+ when Question, Event, RegularEvent, Report, Announcement, PairWork
self[:description]
else
self[:body]
diff --git a/app/models/notification.rb b/app/models/notification.rb
index d9e933e4a39..cffd62b2e0d 100644
--- a/app/models/notification.rb
+++ b/app/models/notification.rb
@@ -43,7 +43,9 @@ class Notification < ApplicationRecord
create_article: 24,
added_work: 25,
came_inquiry: 26,
- training_completed: 27
+ training_completed: 27,
+ came_pair_work: 28,
+ matching_pair_work: 29
}
scope :unreads, -> { where(read: false) }
diff --git a/app/models/pair_work.rb b/app/models/pair_work.rb
new file mode 100644
index 00000000000..15698facc06
--- /dev/null
+++ b/app/models/pair_work.rb
@@ -0,0 +1,115 @@
+# frozen_string_literal: true
+
+class PairWork < ApplicationRecord
+ include Searchable
+ include Commentable
+ include Reactionable
+ include Watchable
+ include WithAvatar
+ include Mentioner
+
+ PairWorksProperty = Struct.new(:title, :empty_message)
+
+ has_many :schedules, class_name: 'PairWorkSchedule', dependent: :destroy, inverse_of: :pair_work
+ belongs_to :user
+ belongs_to :practice, optional: true
+ belongs_to :buddy, class_name: 'User', optional: true
+ accepts_nested_attributes_for :schedules, allow_destroy: true
+ alias sender user
+
+ after_save PairWorkCallbacks.new
+ before_destroy PairWorkCallbacks.new, prepend: true
+ after_destroy PairWorkCallbacks.new
+
+ validates :title, presence: true, length: { maximum: 256 }
+ validates :description, presence: true
+ validates :schedules, presence: true
+ before_validation :set_published_at, if: :will_be_published?
+ validates :reserved_at, presence: { message: 'が選択されていません' }, on: :reserve
+ validates :buddy, presence: { message: 'が選択されていません' }, on: :reserve
+ validate :reserved_at_in_schedules, on: :reserve
+
+ scope :solved, -> { where.not(reserved_at: nil) }
+ scope :not_solved, -> { where(reserved_at: nil) }
+ scope :wip, -> { where(wip: true) }
+ scope :not_wip, -> { where(wip: false) }
+ scope :not_held, -> { not_solved.or(where('reserved_at > ?', Time.current)) }
+ scope :by_target, lambda { |target|
+ case target
+ when 'solved'
+ solved
+ when 'not_solved'
+ not_solved.not_wip
+ else
+ all
+ end
+ }
+ scope :upcoming_pair_works, lambda { |user|
+ now = Time.current
+ within_day = now...(now + 3.days)
+ PairWork.where(user_id: user.id).or(PairWork.where(buddy_id: user.id))
+ .solved
+ .where(reserved_at: within_day)
+ }
+
+ mentionable_as :description
+
+ def self.ransackable_attributes(_auth_object = nil)
+ %w[title description wip reserved_at published_at created_at updated_at buddy_id user_id practice_id]
+ end
+
+ def self.ransackable_associations(_auth_object = nil)
+ %w[user buddy practice schedules comments reactions watches bookmarks]
+ end
+
+ def self.generate_pair_works_property(target)
+ case target
+ when 'solved'
+ PairWorksProperty.new('ペア確定済みのペアワーク', 'ペア確定済みのペアワークはありません。')
+ when 'not_solved'
+ PairWorksProperty.new('募集中のペアワーク', '募集中のペアワークはありません。')
+ else
+ PairWorksProperty.new('全てのペアワーク', 'ペアワークはありません。')
+ end
+ end
+
+ def self.unsolved_badge(current_user:)
+ return nil if !current_user.admin_or_mentor?
+
+ ::Cache.not_solved_pair_work_count
+ end
+
+ def generate_notice_message(action_name)
+ return 'ペアワークをWIPとして保存しました。' if wip?
+
+ {
+ create: 'ペアワークを作成しました。',
+ update: 'ペアワークを更新しました。',
+ destroy: 'ペアワークを削除しました。',
+ reserve: 'ペアが確定しました。'
+ }[action_name]
+ end
+
+ def solved?
+ !reserved_at.nil?
+ end
+
+ def reserve(params)
+ assign_attributes(params)
+ save(context: :reserve)
+ end
+
+ private
+
+ def will_be_published?
+ !wip && published_at.nil?
+ end
+
+ def set_published_at
+ self.published_at = Time.current
+ end
+
+ def reserved_at_in_schedules
+ errors.add(:reserved_at, 'は提案されたスケジュールに含まれていません') unless schedules.map(&:proposed_at).include?(reserved_at)
+ end
+end
diff --git a/app/models/pair_work_callbacks.rb b/app/models/pair_work_callbacks.rb
new file mode 100644
index 00000000000..65d0683eaaf
--- /dev/null
+++ b/app/models/pair_work_callbacks.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+class PairWorkCallbacks
+ def after_save(pair_work)
+ if pair_work.saved_change_to_attribute?(:published_at, from: nil)
+ Rails.logger.info '[CACHE CLEARED#after_save] Cache destroyed for unsolved pair work count.'
+ Cache.delete_not_solved_pair_work_count
+ elsif pair_work.saved_change_to_attribute?(:reserved_at) || pair_work.saved_change_to_attribute?(:wip)
+ Rails.logger.info '[CACHE CLEARED#after_save] Cache destroyed for unsolved pair work count.'
+ Cache.delete_not_solved_pair_work_count
+ end
+ end
+
+ def before_destroy(pair_work)
+ return if pair_work.wip? || pair_work.solved?
+
+ Cache.delete_not_solved_pair_work_count
+ Rails.logger.info '[CACHE CLEARED#before_destroy] Cache destroyed for unsolved pair work count.'
+ end
+
+ def after_destroy(pair_work)
+ delete_notification(pair_work)
+ end
+
+ private
+
+ def delete_notification(pair_work)
+ Notification.where(link: "/pair_works/#{pair_work.id}").destroy_all
+ end
+end
diff --git a/app/models/pair_work_matching_notifier.rb b/app/models/pair_work_matching_notifier.rb
new file mode 100644
index 00000000000..597e22908e1
--- /dev/null
+++ b/app/models/pair_work_matching_notifier.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+class PairWorkMatchingNotifier
+ def call(_name, _started, _finished, _unique_id, payload)
+ pair_work = payload[:pair_work]
+ return if pair_work.wip?
+ return if !pair_work.saved_change_to_attribute?(:reserved_at, from: nil, to: pair_work.reserved_at)
+
+ notify_watchers(pair_work)
+ notify_to_chat(pair_work)
+ end
+
+ private
+
+ def notify_watchers(pair_work)
+ receivers = User.where(id: pair_work.watches.select(:user_id))
+ receivers.each do |receiver|
+ ActivityDelivery.with(pair_work:, receiver:).notify(:matching_pair_work)
+ end
+ end
+
+ def notify_to_chat(pair_work)
+ ChatNotifier.message(<<~TEXT)
+ ペアワーク:「#{pair_work.title}」のマッチングペアが決定しました。
+ <#{Rails.application.routes.url_helpers.pair_work_url(pair_work)}>
+ TEXT
+ end
+end
diff --git a/app/models/pair_work_notifier.rb b/app/models/pair_work_notifier.rb
new file mode 100644
index 00000000000..2e17136af78
--- /dev/null
+++ b/app/models/pair_work_notifier.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+class PairWorkNotifier
+ def call(_name, _started, _finished, _unique_id, payload)
+ pair_work = payload[:pair_work]
+ return if pair_work.wip?
+ return unless pair_work.saved_change_to_attribute?(:published_at, from: nil)
+
+ notify_mentors(pair_work)
+ notify_to_chat(pair_work)
+ end
+
+ private
+
+ def notify_mentors(pair_work)
+ User.mentor.each do |user|
+ ActivityDelivery.with(receiver: user, pair_work:).notify(:came_pair_work) if pair_work.user != user
+ end
+ end
+
+ def notify_to_chat(pair_work)
+ ChatNotifier.message(<<~TEXT)
+ ペアワーク:「#{pair_work.title}」を#{pair_work.user.login_name}さんが作成しました。
+ <#{Rails.application.routes.url_helpers.pair_work_url(pair_work)}>
+ TEXT
+ end
+end
diff --git a/app/models/pair_work_schedule.rb b/app/models/pair_work_schedule.rb
new file mode 100644
index 00000000000..cabc1f36cf5
--- /dev/null
+++ b/app/models/pair_work_schedule.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class PairWorkSchedule < ApplicationRecord
+ belongs_to :pair_work
+ validates :proposed_at, presence: true
+ validates :proposed_at, uniqueness: { scope: :pair_work_id }
+end
diff --git a/app/models/searcher/configuration.rb b/app/models/searcher/configuration.rb
index 1a3876c3a7c..a85378499ca 100644
--- a/app/models/searcher/configuration.rb
+++ b/app/models/searcher/configuration.rb
@@ -74,6 +74,12 @@ module Configuration
columns: %i[title description],
includes: [:user],
label: '定期イベント'
+ },
+ pair_work: {
+ model: PairWork,
+ columns: %i[title description],
+ includes: [:user],
+ label: 'ペアワーク'
}
}.freeze
diff --git a/app/models/unfinished_data_destroyer.rb b/app/models/unfinished_data_destroyer.rb
index 38ae6d0240d..661aa74eeb8 100644
--- a/app/models/unfinished_data_destroyer.rb
+++ b/app/models/unfinished_data_destroyer.rb
@@ -5,6 +5,7 @@ def call(_name, _started, _finished, _id, payload)
user = payload[:user]
Product.where(user:).unchecked.destroy_all
Report.where(user:).wip.destroy_all
+ PairWork.where(user:).not_held.destroy_all
user.update(career_path: 0)
end
end
diff --git a/app/models/user.rb b/app/models/user.rb
index 6e10402b6f8..5abb27936d8 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -132,6 +132,7 @@ class User < ApplicationRecord # rubocop:todo Metrics/ClassLength
has_many :micro_reports, dependent: :destroy
has_many :authored_micro_reports, class_name: 'MicroReport', foreign_key: 'comment_user_id', dependent: :destroy, inverse_of: :comment_user
has_many :learning_time_frames_users, dependent: :destroy
+ has_many :pair_works, dependent: :destroy
has_many :participate_events,
through: :participations,
diff --git a/app/models/watch_for_pair_work_creator.rb b/app/models/watch_for_pair_work_creator.rb
new file mode 100644
index 00000000000..327ef13983b
--- /dev/null
+++ b/app/models/watch_for_pair_work_creator.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+class WatchForPairWorkCreator
+ def call(_name, _started, _finished, _unique_id, payload)
+ pair_work = payload[:pair_work]
+ return if pair_work.wip?
+ return unless pair_work.saved_change_to_attribute?(:published_at, from: nil)
+
+ watch_pair_work_records = watch_records(pair_work)
+ Watch.insert_all(watch_pair_work_records) # rubocop:disable Rails/SkipsModelValidations
+ end
+
+ def watch_records(pair_work)
+ mentors = User.mentor.to_a
+ watching_users = mentors << pair_work.user
+ watching_users.uniq.map do |user|
+ {
+ watchable_type: 'PairWork',
+ watchable_id: pair_work.id,
+ created_at: Time.current,
+ updated_at: Time.current,
+ user_id: user.id
+ }
+ end
+ end
+end
diff --git a/app/notifiers/activity_notifier.rb b/app/notifiers/activity_notifier.rb
index 121ea8c665c..9fe7ceb5e37 100644
--- a/app/notifiers/activity_notifier.rb
+++ b/app/notifiers/activity_notifier.rb
@@ -354,6 +354,39 @@ def chose_correct_answer(params = {})
)
end
+ def came_pair_work(params = {})
+ params.merge!(@params)
+ pair_work = params[:pair_work]
+ receiver = params[:receiver]
+
+ notification(
+ body: "#{pair_work.user.login_name}さんからペアワーク依頼「#{pair_work.title}」が投稿されました。",
+ kind: :came_pair_work,
+ receiver:,
+ sender: pair_work.user,
+ link: Rails.application.routes.url_helpers.polymorphic_path(pair_work),
+ read: false
+ )
+ end
+
+ def matching_pair_work(params = {})
+ params.merge!(@params)
+ pair_work = params[:pair_work]
+ sender = pair_work.user
+ receiver = params[:receiver]
+ matched_user = pair_work.buddy
+ user_name = receiver == matched_user ? 'あなた' : "#{matched_user.login_name}さん"
+
+ notification(
+ body: "#{sender.login_name}さんのペアワーク【 #{pair_work.title} 】のペアが#{user_name}に決定しました。",
+ kind: :matching_pair_work,
+ receiver:,
+ sender:,
+ link: Rails.application.routes.url_helpers.polymorphic_path(pair_work),
+ read: false
+ )
+ end
+
def moved_up_event_waiting_user(params = {})
params.merge!(@params)
event = params[:event]
diff --git a/app/views/activity_mailer/came_pair_work.html.slim b/app/views/activity_mailer/came_pair_work.html.slim
new file mode 100644
index 00000000000..81fc64f7add
--- /dev/null
+++ b/app/views/activity_mailer/came_pair_work.html.slim
@@ -0,0 +1,9 @@
+= render '/notification_mailer/notification_mailer_template',
+ title: @title,
+ link_url: @link_url,
+ link_text: 'ペアワークのページへ' do
+ p #{@pair_work.user.login_name}さんからペアワーク依頼がありました。確認してみよう!!
+ div(style='border-top: solid 1px #ccc; height: 0;')
+ h1(style='margin-top: 1em; border-left: solid 6px #4638a0; padding: 0 0 0 1rem; font-size: 1.5em; border-bottom: none; color: #444444;')
+ = @pair_work.title
+ = md2html(@pair_work.description)
diff --git a/app/views/activity_mailer/matching_pair_work.html.slim b/app/views/activity_mailer/matching_pair_work.html.slim
new file mode 100644
index 00000000000..f3593d1ae1e
--- /dev/null
+++ b/app/views/activity_mailer/matching_pair_work.html.slim
@@ -0,0 +1,9 @@
+= render '/notification_mailer/notification_mailer_template',
+ title: @title,
+ link_url: @link_url,
+ link_text: 'ペアワークのページへ' do
+ p [FBC] #{@pair_work.user.login_name}さんのペアワーク【 #{@pair_work.title} 】のペアが#{@user_name}に決定しました。
+ div(style='border-top: solid 1px #ccc; height: 0;')
+ h1(style='margin-top: 1em; border-left: solid 6px #4638a0; padding: 0 0 0 1rem; font-size: 1.5em; border-bottom: none; color: #444444;')
+ = @pair_work.title
+ = md2html(@pair_work.description)
diff --git a/app/views/admin/_admin_page_tabs.html.slim b/app/views/admin/_admin_page_tabs.html.slim
index fcf0a950dbc..804ad367a20 100644
--- a/app/views/admin/_admin_page_tabs.html.slim
+++ b/app/views/admin/_admin_page_tabs.html.slim
@@ -1,4 +1,4 @@
-.page-tabs
+nav.page-tabs
.container
ul.page-tabs__items
li.page-tabs__item
diff --git a/app/views/application/_global_nav.slim b/app/views/application/_global_nav.slim
index 6fc70396ad9..a3ea4ed56ed 100644
--- a/app/views/application/_global_nav.slim
+++ b/app/views/application/_global_nav.slim
@@ -5,20 +5,22 @@ nav.global-nav
li.global-nav-links__item
= link_to root_path, class: "global-nav-links__link #{current_link(/^home/)}" do
.global-nav-links__link-icon
- i.fa-solid.fa-gauge
+ i.fa-solid.fa-home
.global-nav-links__link-label.is-sm
| ダッシュボード
li.global-nav-links__item
= link_to announcements_path, class: "global-nav-links__link #{current_link(/^announcements/)}" do
.global-nav-links__link-icon
i.fa-solid.fa-bullhorn
- .global-nav-links__link-label お知らせ
+ .global-nav-links__link-label
+ | お知らせ
li.global-nav-links__item
- anchor = "category-#{@current_user_practice.category_active_or_unstarted_practice.id}" if @current_user_practice.category_active_or_unstarted_practice.present?
= link_to course_practices_path(current_user.course, anchor:), class: "global-nav-links__link #{current_link(/^(courses-practices|practices)-/)}" do
.global-nav-links__link-icon
i.fa-solid.fa-book
- .global-nav-links__link-label プラクティス
+ .global-nav-links__link-label
+ | プラクティス
li.global-nav-links__item
= link_to reports_path, class: "global-nav-links__link #{current_link(/^(reports|external_entries)/)}" do
.global-nav-links__link-icon
@@ -26,7 +28,12 @@ nav.global-nav
- if admin_or_mentor_login? && Cache.unchecked_report_count.positive?
.global-nav__item-count.a-notification-count.is-only-mentor
= Cache.unchecked_report_count
- .global-nav-links__link-label 日報・ブログ
+ .global-nav-links__link-label
+ | 日報
+ br.is-hidden-sm-down
+ span.is-hidden-md-up
+ | ・
+ | ブログ
- if staff_login?
li.global-nav-links__item
- products_link = admin_or_mentor_login? ? products_unassigned_index_path : products_path
@@ -36,15 +43,31 @@ nav.global-nav
- if admin_or_mentor_login? && Cache.unassigned_product_count.positive?
.global-nav__item-count.a-notification-count.is-only-mentor
= Cache.unassigned_product_count
- .global-nav-links__link-label 提出物
+ .global-nav-links__link-label
+ | 提出物
li.global-nav-links__item
- = link_to questions_path(target: 'not_solved'), class: "global-nav-links__link #{current_link(/^questions/)}" do
+ = link_to questions_path(target: 'not_solved'), class: "global-nav-links__link #{current_link(/^(questions|pair_works)/)}" do
.global-nav-links__link-icon
- i.fa-solid.fa-comments-question-check
- - if Cache.not_solved_question_count.positive?
+ i.fa-solid.fa-comment-question
+ // TODO ペアワークを本番リリースしたらこの分岐は削除
+ - if pair_work_available?
+ - if Cache.not_solved_question_count.positive? || Cache.not_solved_pair_work_count.positive?
+ .global-nav__item-count.a-notification-count
+ = Cache.not_solved_question_count + Cache.not_solved_pair_work_count
+ - elsif Cache.not_solved_question_count.positive?
.global-nav__item-count.a-notification-count
= Cache.not_solved_question_count
- .global-nav-links__link-label Q&A
+ // TODO ペアワークを本番リリースしたらこの分岐は削除
+ - if pair_work_available?
+ .global-nav-links__link-label
+ | Q&A
+ br.is-hidden-sm-down
+ span.is-hidden-md-up
+ | ・
+ | ペアワーク
+ - else
+ .global-nav-links__link-label
+ | Q&A
li.global-nav-links__item
= link_to '/pages', class: "global-nav-links__link #{@page&.slug != 'help' && @page&.slug != 'help-adviser' && current_link(/^pages/)}" do
.global-nav-links__link-icon
@@ -58,18 +81,20 @@ nav.global-nav
= link_to portfolios_path, class: "global-nav-links__link #{current_link(/^works/)}" do
.global-nav-links__link-icon
i.fa-solid.fa-rocket
- .global-nav-links__link-label.is-sm
+ .global-nav-links__link-label
| ポートフォリオ
li.global-nav-links__item
= link_to users_path, class: "global-nav-links__link #{current_link(/^users-(index|show)/)}" do
.global-nav-links__link-icon
i.fa-solid.fa-users
- .global-nav-links__link-label ユーザー
+ .global-nav-links__link-label
+ | ユーザー
li.global-nav-links__item
= link_to events_path, class: "global-nav-links__link #{current_link(/^events|^regular_events/)}" do
.global-nav-links__link-icon
i.fa-solid.fa-beer
- .global-nav-links__link-label イベント
+ .global-nav-links__link-label
+ | イベント
li.global-nav-links__item
= link_to action_uncompleted_index_path(current_user.talk), class: "global-nav-links__link #{current_link(/talk/)}" do
.global-nav-links__link-icon
diff --git a/app/views/companies/_tabs.html.slim b/app/views/companies/_tabs.html.slim
index f218dddefa9..11bc4f53b59 100644
--- a/app/views/companies/_tabs.html.slim
+++ b/app/views/companies/_tabs.html.slim
@@ -1,4 +1,4 @@
-.page-tabs
+nav.page-tabs
.container
ul.page-tabs__items
li.page-tabs__item
diff --git a/app/views/events/_participation.html.slim b/app/views/events/_participation.html.slim
index 7a49a90dc9a..cd9e83f24d4 100644
--- a/app/views/events/_participation.html.slim
+++ b/app/views/events/_participation.html.slim
@@ -1,40 +1,39 @@
- if current_user.trainee && event.job_hunting
.event-main-actions.is-non-participationed
- .event-main-actions__description
- p
- | 企業研修で参加されている方はこのイベントに参加できません。
- ul.event-main-actions__items
- li.event-main-actions__item
- .a-button.is-disabled.is-lg.is-block
- | 参加申込
+ .event-main-actions__body
+ .event-main-actions__description
+ p 企業研修で参加されている方はこのイベントに参加できません。
+ ul.event-main-actions__items
+ li.event-main-actions__item
+ .a-button.is-disabled.is-lg.is-block
+ | 参加申込
- elsif current_user.participating?(event)
.event-main-actions.is-participationed
- .event-main-actions__description
- p
- | 参加登録しています。
- ul.event-main-actions__items
- li.event-main-actions__item
- = link_to event_participation_path(event_id: event), method: :delete,
- data: { confirm: '特別イベントの参加をキャンセルします。よろしいですか?' }, class: 'event-main-actions__item-cancel' do
- | 参加を取り消す
+ .event-main-actions__body
+ .event-main-actions__description
+ p 参加登録しています。
+ ul.event-main-actions__items
+ li.event-main-actions__item
+ = link_to event_participation_path(event_id: event), method: :delete,
+ data: { confirm: '特別イベントの参加をキャンセルします。よろしいですか?' }, class: 'event-main-actions__item-cancel' do
+ | 参加を取り消す
- elsif event.opening?
.event-main-actions.is-unparticipationed(class="#{event.capacity > event.participants.count ? 'is-available' : 'is-capacity-over'}")
- .event-main-actions__description
- - if event.capacity > event.participants.count
- // TODO helprtにしてわかりやすくしたい↑
- p
- | あと#{event.capacity - event.participants.count}名が参加できます。
- - else
- p
- | 参加者が定員を超えているので補欠での登録になります。
- ul.event-main-actions__items
- li.event-main-actions__item
+ .event-main-actions__body
+ .event-main-actions__description
- if event.capacity > event.participants.count
- // TODO helprtにしてわかりやすくしたい↑
- = link_to event_participations_path(event_id: event), method: :post,
- data: { confirm: '特別イベント参加申込をします。よろしいですか?' }, class: 'a-button is-primary is-md is-block' do
- | 参加申込
+ // TODO helperにしてわかりやすくしたい↑
+ p あと#{event.capacity - event.participants.count}名が参加できます。
- else
- = link_to event_participations_path(event_id: event), method: :post,
- data: { confirm: '補欠として特別イベント参加申込をします。よろしいですか?' }, class: 'a-button is-warning is-md is-block' do
- | 補欠登録
+ p 参加者が定員を超えているので補欠での登録になります。
+ ul.event-main-actions__items
+ li.event-main-actions__item
+ - if event.capacity > event.participants.count
+ // TODO helperにしてわかりやすくしたい↑
+ = link_to event_participations_path(event_id: event), method: :post,
+ data: { confirm: '特別イベント参加申込をします。よろしいですか?' }, class: 'a-button is-primary is-md is-block' do
+ | 参加申込
+ - else
+ = link_to event_participations_path(event_id: event), method: :post,
+ data: { confirm: '補欠として特別イベント参加申込をします。よろしいですか?' }, class: 'a-button is-warning is-md is-block' do
+ | 補欠登録
diff --git a/app/views/home/_upcoming_pair_work.html.slim b/app/views/home/_upcoming_pair_work.html.slim
new file mode 100644
index 00000000000..34f77dc44cc
--- /dev/null
+++ b/app/views/home/_upcoming_pair_work.html.slim
@@ -0,0 +1,21 @@
+.card-list-item
+ .card-list-item__inner
+ .card-list-item__label
+ span ペア
ワーク
+ .card-list-item__rows
+ .card-list-item__row
+ header.card-list-item-title
+ h2.card-list-item-title__title
+ = link_to pair_work_path(upcoming_pair_work), class: 'card-list-item-title__link has-badge a-text-link' do
+ span.a-text-link__text
+ = upcoming_pair_work.title
+ .card-list-item__row
+ .card-list-item-meta
+ .card-list-item-meta__items
+ .card-list-item-meta__item
+ .a-meta
+ span class=meta_label_by_status(upcoming_pair_work)
+ | 開催日時
+ span class=meta_label_by_status(upcoming_pair_work)
+ time datetime=upcoming_pair_work.reserved_at.iso8601
+ = l upcoming_pair_work.reserved_at
diff --git a/app/views/home/_upcoming_pair_works.html.slim b/app/views/home/_upcoming_pair_works.html.slim
new file mode 100644
index 00000000000..681482d31ee
--- /dev/null
+++ b/app/views/home/_upcoming_pair_works.html.slim
@@ -0,0 +1,7 @@
+.a-card
+ header.card-header.is-sm.has-no-border
+ h2.card-header__title
+ | ペアワークの予定があります!
+ hr.a-border-tint
+ .card-list.has-scroll
+ = render partial: 'upcoming_pair_work', collection: upcoming_pair_works
diff --git a/app/views/home/index.html.slim b/app/views/home/index.html.slim
index 0ac8a3c9679..fc163828271 100644
--- a/app/views/home/index.html.slim
+++ b/app/views/home/index.html.slim
@@ -25,6 +25,8 @@
.container.is-xl
.row
.col-xs-12.col-xl-6.col-xxl-6
+ - if @upcoming_pair_works.present?
+ = render 'upcoming_pair_works', upcoming_pair_works: @upcoming_pair_works
- if @upcoming_events_groups.present?
= render 'upcoming_events_groups', upcoming_events_groups: @upcoming_events_groups
- if @announcements.present?
diff --git a/app/views/mentor/_mentor_page_tabs.html.slim b/app/views/mentor/_mentor_page_tabs.html.slim
index 5c0cfb07a6c..10cd5c0a83b 100644
--- a/app/views/mentor/_mentor_page_tabs.html.slim
+++ b/app/views/mentor/_mentor_page_tabs.html.slim
@@ -1,4 +1,4 @@
-.page-tabs
+nav.page-tabs
.container
ul.page-tabs__items
li.page-tabs__item
diff --git a/app/views/pages/_tabs.html.slim b/app/views/pages/_tabs.html.slim
index f0d3c3ed859..86fbdb727ac 100644
--- a/app/views/pages/_tabs.html.slim
+++ b/app/views/pages/_tabs.html.slim
@@ -1,4 +1,4 @@
-.page-tabs
+nav.page-tabs
.container
ul.page-tabs__items
li.page-tabs__item
diff --git a/app/views/pair_works/_body.html.slim b/app/views/pair_works/_body.html.slim
new file mode 100644
index 00000000000..7cac768ccde
--- /dev/null
+++ b/app/views/pair_works/_body.html.slim
@@ -0,0 +1,104 @@
+.a-card
+ .card-body
+ - if pair_work.solved?
+ - buddy = pair_work.buddy
+ .event-main-actions.is-participationed
+ header.event-main-actions__header
+ h2.event-main-actions__title
+ | ペアが確定しました
+ = link_to events_calendars_url(protocol: :webcal, format: :ics, user_id: current_user.id), class: 'a-button is-sm is-secondary' do
+ i.fa-regular.fa-plus
+ span カレンダーに登録
+
+ .event-main-actions__body
+ .pair-work-info
+ .pair-work-info__start
+ = render 'users/icon', user: buddy, link_class: 'pair-work-info__user-link', image_class: 'pair-work-info__user-icon'
+ .pair-work-info__end
+ .event-meta
+ .event-meta__items
+ .event-meta__item
+ dt.event-meta__item-label
+ | ペア
+ dd.event-meta__item-value
+ = link_to buddy.long_name, user_path(buddy)
+ .event-meta__item
+ dt.event-meta__item-label
+ | 日時
+ dd.event-meta__item-value
+ time.pair-work-info__datetime datetime=pair_work.reserved_at.iso8601
+ = l pair_work.reserved_at
+ .event-meta__item
+ dt.event-meta__item-label
+ | チャンネル
+ dd.event-meta__item-value
+ = pair_work.channel
+
+ .card-body__description
+ .a-long-text.is-md.js-markdown-view
+ = pair_work.description
+ hr.a-border-tint
+ = render 'reactions/reactions', reactionable: pair_work
+
+ hr.a-border-tint
+ footer.card-footer class=(current_user == pair_work.user || current_user.admin? ? '' : 'is-hidden')
+ .card-main-actions
+ ul.card-main-actions__items
+ li.card-main-actions__item
+ = link_to edit_pair_work_path(pair_work), class: 'card-main-actions__action a-button is-sm is-secondary is-block' do
+ i.fa-solid.fa-pen
+ | 内容修正
+ li.card-main-actions__item.is-sub class=(current_user.admin? || pair_work.user == current_user ? '' : 'is-hidden')
+ = link_to '削除', pair_work_path(pair_work),
+ data: { confirm: '本当に削除しますか?' }, method: :delete, class: 'card-main-actions__muted-action'
+
+.a-card
+ header.card-header
+ h2.card-header__title
+ - if pair_work.user == current_user
+ | 希望日時
+ - else
+ | ペアを希望する
+ hr.a-border-tint
+ .card-body
+ .card-body__description
+ .pair-work-schedule-dates class=(pair_work.solved? && pair_work.user != current_user ? 'is-solved' : '')
+ - if pair_work.solved?
+ .pair-work-schedule-dates__action
+ .pair-work-schedule-dates__action-items
+ - if pair_work.buddy == current_user
+ .pair-work-schedule-dates__action-item
+ label.a-button.is-md.is-secondary.is-block(for='show-schedule-dates')
+ | 日時の変更、ペアをキャンセル
+ .pair-work-schedule-dates__action-item-description
+ p あなたがペアとして確定しています。
+ - elsif mentor_login?
+ .pair-work-schedule-dates__action-item
+ label.a-button.is-md.is-secondary.is-block(for='show-schedule-dates')
+ | ペア変更、自分がペアになる
+ .pair-work-schedule-dates__action-item-description
+ p 代わりに自分がペアになる場合はこちらから変更。
+ - if mentor_login?
+ .pair-work-schedule-dates__table-container
+ input.a-toggle-checkbox#show-schedule-dates(type="checkbox")
+ .pair-work-schedule-dates__table
+ - if pair_work.buddy == current_user
+ h3.pair-work-schedule-dates__title
+ | 日時の変更、もしくはキャンセル
+ .pair-work-schedule-dates__cancel
+ .pair-work-schedule-dates__cancel-action
+ // TODO: ペア確定の取り消し機能実装後にリンク先を修正
+ .a-button.is-md.is-danger.is-block
+ | ペア確定を取り消す
+ - else
+ h3.pair-work-schedule-dates__title
+ | ペアを希望する場合、以下のスケジュールから都合のいい日時を選択してください。
+ = render 'pair_works/schedule', pair_work:
+ - elsif pair_work.user == current_user
+ .pair-work-schedule-dates__table-container
+ .pair-work-schedule-dates__table
+ = render 'pair_works/schedule', pair_work:
+ - else
+ .a-long-text.is-md
+ .message.danger
+ p 現在はテスト運用のため、メンターのみがペアを申し込めます。
diff --git a/app/views/pair_works/_form.html.slim b/app/views/pair_works/_form.html.slim
new file mode 100644
index 00000000000..7466616fbff
--- /dev/null
+++ b/app/views/pair_works/_form.html.slim
@@ -0,0 +1,101 @@
+= render 'errors', object: pair_work
+= form_with model: pair_work, local: true, class: 'form', html: { name: 'pair_work' } do |f|
+ .form__items
+ .form-item
+ .form-item
+ .row
+ .col-lg-6.col-xs-12
+ .form-item
+ = f.label :practice, class: 'a-form-label'
+ .select-practices
+ = f.select :practice_id,
+ practice_options_within_course,
+ { include_blank: 'プラクティス選択なし' },
+ { class: 'js-choices-singles', id: 'js-choices-practice' }
+
+ .form-item
+ .row.js-markdown-parent
+ .col-md-6.col-xs-12
+ = f.label :title, class: 'a-form-label'
+ = f.text_field :title, class: 'a-text-input js-warning-form', placeholder: 'CSS の display: flex の使い方をきっちり理解したい'
+ .a-form-help
+ p
+ | 「タイトル」にはペアワークで解決したい問題を簡潔に明確に入力しましょう。
+ br
+ | 具体的で詳細な内容は「内容」に入力してください。
+ .form-item
+ .row.js-markdown-parent
+ .col-md-6.col-xs-12
+ = f.label :description, class: 'a-form-label'
+ .form-textarea
+ .form-textarea__body
+ = f.text_area :description, class: 'a-text-input markdown-form__text-area js-markdown js-warning-form',
+ data: { 'preview': '.js-preview', 'input': '.file-input' }
+ .form-textarea__footer
+ .form-textarea__bottom-note
+ | 途中保存は「#{request.os == 'Mac OSX' ? 'command + s' : 'Ctrl + s'}」 マメに保存しよう。
+ .col-md-6.col-xs-12
+ .a-form-label
+ | プレビュー
+ .js-preview.a-long-text.is-md.practices-edit__input.markdown-form__preview
+
+ - if pair_work.new_record?
+ .form-item
+ .row
+ .col-md-6.col-xs-12
+ = f.label :schedules, class: 'a-form-label'
+ .a-form-help(class='!mb-2')
+ p
+ | ペアワークを実施する希望日時にチェックを入れてください。
+ br
+ = link_to edit_current_user_path(anchor: 'activity-schedule') do
+ | ユーザー情報の「主な活動予定時間」
+ | を登録しておくと、初期設定でその日時が選択されるようになります。
+ .form-table.is-sm.is-sticky(class='!max-h-96')
+ table(class='!min-w-full')
+ thead
+ tr
+ th
+ - schedule_dates(Time.current).each do |day|
+ th = l day, format: :mdw_unique
+ tbody
+ - 24.times do |hour|
+ tr(class='hover:!bg-[var(--background-more-tint)]')
+ th
+ | #{hour}:00
+ - sorted_wdays(Time.current).each_with_index do |wday, day|
+ ruby:
+ target_time = schedule_target_time(day, hour)
+ check_box_id = schedule_check_box_id(target_time)
+ learning_time_frame_id = wday * 24 + hour + 1
+ td.form-table__check
+ = label_tag check_box_id do
+ span
+ = check_box_tag 'pair_work[schedules_attributes][][proposed_at]',
+ target_time.iso8601,
+ learning_time_frame_checked?(target_time, learning_time_frame_id),
+ id: check_box_id, disabled: schedule_check_disabled?(target_time)
+ .form-item
+ .row.js-markdown-parent
+ .col-md-6.col-xs-12
+ = f.label :channel, 'ペアワークを行うチャンネル', class: 'a-form-label'
+ = f.text_field :channel, class: 'a-text-input js-warning-form'
+ .a-form-help(class='!mb-2')
+ p
+ | 特に理由がない場合は「ペアワーク・モブワーク1」を登録してください。
+ br
+ | もし、同じ時間に「ペアワーク・モブワーク1」が別のペアワークに使われていて、
+ | それに気づいた場合は別のチャンネルに変更してください。
+ .form-actions
+ ul.form-actions__items
+ li.form-actions__item.is-main
+ = f.submit 'WIP', class: 'a-button is-lg is-secondary is-block', id: 'js-shortcut-wip'
+ li.form-actions__item.is-main
+ - if pair_work.new_record?
+ = f.submit '登録する', class: 'a-button is-lg is-primary is-block'
+ - elsif pair_work.wip?
+ = f.submit 'ペアワークを公開', class: 'a-button is-lg is-primary is-block'
+ - else
+ = f.submit '更新する', class: 'a-button is-lg is-primary is-block'
+ li.form-actions__item
+ = link_to 'キャンセル', pair_works_path(target: 'not_solved'), class: 'a-button is-sm is-secondary'
diff --git a/app/views/pair_works/_header.html.slim b/app/views/pair_works/_header.html.slim
new file mode 100644
index 00000000000..7e9f2e1c8bc
--- /dev/null
+++ b/app/views/pair_works/_header.html.slim
@@ -0,0 +1,57 @@
+header.page-content-header
+ .page-content-header__start
+ .page-content-header__user
+ = render 'users/icon', user: pair_work.user, link_class: 'page-content-header__user-link', image_class: 'page-content-header__user-icon-image'
+ - if pair_work.solved?
+ .pair-badge
+ .pair-badge__label
+ | ペア
+ br
+ | 確定
+ .page-content-header__end
+ .page-content-header__row
+ .page-content-header__before-title
+ - if pair_work.practice_id.present?
+ = link_to pair_work.practice.title, practice_path(pair_work.practice_id), class: 'a-category-link'
+ h1.page-content-header__title class=(pair_work.wip ? 'is-wip' : '')
+ - if pair_work.wip?
+ span.a-title-label.is-wip
+ | WIP
+ - elsif pair_work.solved?
+ span.a-title-label.is-solved.is-success.js-solved-status
+ | ペア確定
+ - else
+ span.a-title-label.is-solved.is-danger.js-solved-status
+ | 募集中
+ | #{pair_work.title}
+
+ .page-content-header__row
+ .page-content-header-metas
+ .page-content-header-metas__start
+ - if pair_work.wip?
+ .page-content-header-metas__meta
+ .a-meta
+ span.a-meta__value
+ | ペアワーク作成中
+ .page-content-header-metas__meta
+ = link_to pair_work.user.long_name, user_path(pair_work.user), class: 'a-user-name'
+ - if !pair_work.wip?
+ .page-content-header-metas__meta
+ .a-meta
+ span.a-meta__label
+ | 公開
+ span.a-meta__value
+ time datetime=pair_work.published_at.iso8601
+ = l pair_work.published_at
+ .page-content-header-metas__meta
+ .a-meta
+ span.a-meta__label
+ | 更新
+ span.a-meta__value
+ time datetime=pair_work.updated_at.iso8601
+ = l pair_work.updated_at
+
+ .page-content-header__row
+ .page-content-header-actions
+ .page-content-header-actions__start
+ = render 'watches/watch_toggle', type: pair_work.class.to_s, id: pair_work.id, watch: pair_work.watch_by(current_user)
diff --git a/app/views/pair_works/_pair_work.html.slim b/app/views/pair_works/_pair_work.html.slim
new file mode 100644
index 00000000000..efcb857aa6c
--- /dev/null
+++ b/app/views/pair_works/_pair_work.html.slim
@@ -0,0 +1,50 @@
+.card-list-item(class="#{pair_work.wip ? 'is-wip' : ''}")
+ .card-list-item__inner
+ .card-list-item__user
+ = render 'users/icon', user: pair_work.user, link_class: 'card-list-item__user-link', image_class: 'card-list-item__user-icon'
+ .card-list-item__rows
+ .card-list-item__row
+ .card-list-item-title
+ - if pair_work.wip?
+ .a-list-item-badge.is-wip
+ span WIP
+ h2.card-list-item-title__title(itemprop='name')
+ = link_to pair_work, itemprop: 'url', class: 'card-list-item-title__link a-text-link js-unconfirmed-link' do
+ = pair_work.title
+ - if pair_work.practice
+ .card-list-item__row
+ .card-list-item-meta
+ .card-list-item-meta__items
+ .card-list-item-meta__item
+ = link_to pair_work.practice.title, practice_path(pair_work.practice), class: 'a-meta is-practice'
+ .card-list-item__row
+ .card-list-item-meta
+ .card-list-item-meta__items
+ - if pair_work.wip?
+ .card-list-item-meta__item
+ .a-meta
+ | 作成中
+ .card-list-item-meta__item
+ = link_to pair_work.user, class: 'a-user-name' do
+ = pair_work.user.long_name
+ .card-list-item__row
+ .card-list-item-meta
+ .card-list-item-meta__items
+ .card-list-item-meta__item
+ .a-meta
+ span.a-meta__label
+ | 投稿日時
+ - if pair_work.wip?
+ span.a-meta__value
+ = l pair_work.created_at
+ - else
+ span.a-meta__value
+ = l pair_work.published_at
+ .card-list-item-meta__item
+ .a-meta(class="#{pair_work.important? ? 'is-important' : ''}")
+ | コメント(#{pair_work.comments.length})
+
+ - if pair_work.solved?
+ .stamp.is-circle.is-solved
+ .stamp__content.is-icon 確
+ .stamp__content.is-icon 定
diff --git a/app/views/pair_works/_schedule.html.slim b/app/views/pair_works/_schedule.html.slim
new file mode 100644
index 00000000000..68b18917ea9
--- /dev/null
+++ b/app/views/pair_works/_schedule.html.slim
@@ -0,0 +1,43 @@
+- proposed_at_list = pair_work.schedules.map(&:proposed_at)
+
+.a-table.is-sm(class='!text-center')
+ table(class='!min-w-full')
+ thead
+ tr
+ th
+ - schedule_dates(pair_work.created_at).each do |date|
+ th = l date, format: :mdw_unique
+ tbody
+ - 24.times do |hour|
+ tr(class='hover:!bg-[var(--background-more-tint)]')
+ th #{hour}:00
+ - 7.times do |day|
+ - target_time = schedule_target_time(day, hour, pair_work:)
+ - if proposed_at_list.include?(target_time)
+ td(class='!p-0 !h-6 !relative')
+ = form_with model: pair_work, url: pair_work_reservations_path(pair_work), method: :post, local: true do |f|
+ = f.hidden_field :reserved_at, value: target_time
+ = f.button type: :submit,
+ disabled: schedule_check_disabled?(target_time, pair_work:),
+ class: ['!absolute',
+ '!top-0',
+ '!flex',
+ '!w-full',
+ '!h-full',
+ '!text-center',
+ '!justify-center',
+ '!items-center',
+ '!p-1',
+ '!border',
+ '!border-solid',
+ '!rounded',
+ '!text-[var(--main)]',
+ '!bg-[var(--input-selected-background)]',
+ '!border-[var(--main)]',
+ 'enabled:!cursor-pointer'].join(' '),
+ id: target_time.iso8601,
+ data: { confirm: "#{l target_time, format: :mdw_and_time} にペアワークを申し込みますか?" } do
+ i.fa-duotone.fa-solid.fa-check
+ - else
+ td(class='!p-0 !h-6')
+ span ✖️
diff --git a/app/views/pair_works/edit.html.slim b/app/views/pair_works/edit.html.slim
new file mode 100644
index 00000000000..071f50b44d8
--- /dev/null
+++ b/app/views/pair_works/edit.html.slim
@@ -0,0 +1,21 @@
+- title 'ペアワーク編集'
+- set_meta_tags description: 'ペアワーク編集ページです。'
+
+= render 'questions_and_pair_works/questions_and_pair_works_header'
+.page-main
+ header.page-main-header
+ .container
+ .page-main-header__inner
+ .page-main-header__start
+ h2.page-main-header__title
+ = title
+ .page-main-header__end
+ .page-header-actions
+ ul.page-header-actions__items
+ li.page-header-actions__item
+ = link_to pair_works_path(target: 'not_solved'), class: 'a-button is-md is-secondary is-block is-back' do
+ | ペアワーク一覧
+ hr.a-border
+ .page-body
+ .container.is-xxl
+ = render 'form', pair_work: @pair_work
diff --git a/app/views/pair_works/index.html.slim b/app/views/pair_works/index.html.slim
new file mode 100644
index 00000000000..0aea86f7072
--- /dev/null
+++ b/app/views/pair_works/index.html.slim
@@ -0,0 +1,41 @@
+- title @pair_works_property.title
+- set_meta_tags description: 'ペアワークの一覧です。'
+
+= render 'questions_and_pair_works/questions_and_pair_works_header'
+.page-main
+ header.page-main-header
+ .container
+ .page-main-header__inner
+ .page-main-header__start
+ h2.page-main-header__title
+ | ペアワーク
+ .page-main-header__end
+ .page-header-actions
+ ul.page-header-actions__items
+ li.page-header-actions__item
+ = link_to new_pair_work_path, class: 'a-button is-md is-secondary is-block' do
+ i.fa-regular.fa-plus
+ span ペアを募集する
+ .a-page-notice
+ .container
+ .a-page-notice__inner
+ p 現在はテスト運用のため、メンターのみがペアを申し込めます。
+
+ hr.a-border
+ = pair_works_sub_tabs
+
+ .page-body
+ - if @pair_works.empty?
+ .o-empty-message
+ .o-empty-message__icon
+ i.fa-regular.fa-sad-tear
+ .o-empty-message__text
+ = @pair_works_property.empty_message
+ - else
+ .container.is-md
+ .page-content.pair_works
+ = paginate @pair_works
+ .card-list.a-card
+ .card-list__items
+ = render partial: 'pair_work', collection: @pair_works
+ = paginate @pair_works
diff --git a/app/views/pair_works/new.html.slim b/app/views/pair_works/new.html.slim
new file mode 100644
index 00000000000..68faecf01b5
--- /dev/null
+++ b/app/views/pair_works/new.html.slim
@@ -0,0 +1,22 @@
+- title 'ペア募集作成'
+- set_meta_tags description: 'ペア募集作成ページです。'
+
+= render 'questions_and_pair_works/questions_and_pair_works_header'
+.page-main
+ header.page-main-header
+ .container
+ .page-main-header__inner
+ .page-main-header__start
+ h2.page-main-header__title
+ | ペア募集作成
+ .page-main-header__end
+ .page-header-actions
+ ul.page-header-actions__items
+ li.page-header-actions__item
+ = link_to pair_works_path(target: 'not_solved'), class: 'a-button is-md is-secondary is-block is-back' do
+ | ペアワーク一覧
+ hr.a-border
+
+ .page-body
+ .container.is-xxl
+ = render 'form', pair_work: @pair_work
diff --git a/app/views/pair_works/show.html.slim b/app/views/pair_works/show.html.slim
new file mode 100644
index 00000000000..f2b4b9ae945
--- /dev/null
+++ b/app/views/pair_works/show.html.slim
@@ -0,0 +1,35 @@
+- title "ペアワーク: #{truncate(@pair_work.title, length: 35, omission: '...')}"
+- set_meta_tags og: { title: "ペアワーク: #{@pair_work.title}" }
+- if @pair_work.practice
+ - set_meta_tags description: "#{@pair_work.user.long_name}さんが投稿した、プラクティス「#{@pair_work.practice.title}」のペアワーク募集「#{@pair_work.title}」のページです。"
+- else
+ - set_meta_tags description: "#{@pair_work.user.long_name}さんが投稿した、ペアワーク募集「#{@pair_work.title}」のページです。このペアワーク募集に関連するプラクティスはありません。"
+
+= render 'questions_and_pair_works/questions_and_pair_works_header'
+.page-main
+ header.page-main-header
+ .container
+ .page-main-header__inner
+ .page-main-header__start
+ h2.page-main-header__title
+ | ペアワーク
+ .page-main-header__end
+ .page-header-actions
+ ul.page-header-actions__items
+ li.page-header-actions__item
+ = link_to pair_works_path(target: 'not_solved'), class: 'a-button is-md is-secondary is-block is-back' do
+ | ペアワーク一覧
+ li.page-header-actions__item
+ = link_to new_pair_work_path, class: 'a-button is-md is-secondary is-block' do
+ i.fa-regular.fa-plus
+ span ペアを募集する
+ hr.a-border
+
+ .page-body
+ .page-body__inner
+ .container.is-md
+ .page-content.pair-work
+ = render 'errors', object: @pair_work
+ = render 'pair_works/header', pair_work: @pair_work
+ = render 'pair_works/body', pair_work: @pair_work
+ = render 'comments/comments', commentable: @pair_work, commentable_type: 'PairWork'
diff --git a/app/views/products/_tabs.html.slim b/app/views/products/_tabs.html.slim
index 89414700d14..b9e938b4f56 100644
--- a/app/views/products/_tabs.html.slim
+++ b/app/views/products/_tabs.html.slim
@@ -1,4 +1,4 @@
-.page-tabs
+nav.page-tabs
.container
ul.page-tabs__items
li.page-tabs__item
diff --git a/app/views/questions/edit.html.slim b/app/views/questions/edit.html.slim
index 47867afd08d..0a259bcf7c8 100644
--- a/app/views/questions/edit.html.slim
+++ b/app/views/questions/edit.html.slim
@@ -1,19 +1,27 @@
- title '質問編集'
- set_meta_tags description: '質問編集ページです。'
-header.page-header
- .container
- .page-header__inner
- .page-header__start
- h2.page-header__title
- = title
- .page-header__end
- .page-header-actions
- ul.page-header-actions__items
- li.page-header-actions__item
- = link_to questions_path(target: 'not_solved'), class: 'a-button is-md is-secondary is-block is-back' do
- | Q&A一覧
+= render 'questions_and_pair_works/questions_and_pair_works_header'
hr.a-border
-.page-body
- .container.is-xxl
- = render 'form', question: @question
+.page-main
+ header.page-main-header
+ .container
+ .page-main-header__inner
+ .page-main-header__start
+ h2.page-main-header__title
+ = title
+ .page-main-header__end
+ .page-header-actions
+ ul.page-header-actions__items
+ li.page-header-actions__item
+ = link_to new_question_path, class: 'a-button is-md is-secondary is-block' do
+ i.fa-regular.fa-plus
+ span 質問する
+ li.page-header-actions__item
+ = link_to questions_path(target: 'not_solved'), class: 'a-button is-md is-secondary is-block is-back' do
+ | Q&A一覧
+ hr.a-border
+
+ .page-body
+ .container.is-xxl
+ = render 'form', question: @question
diff --git a/app/views/questions/index.html.slim b/app/views/questions/index.html.slim
index cd0ebf599c1..9f65a984ebc 100644
--- a/app/views/questions/index.html.slim
+++ b/app/views/questions/index.html.slim
@@ -4,37 +4,12 @@
- else
- set_meta_tags description: 'Q&Aの一覧です。'
-header.page-header
- .container
- .page-header__inner
- .page-header__start
- h2.page-header__title
- | Q&A
- .page-header__end
- .page-header-actions
- .page-header-actions__items
- .page-header-actions__item
- = link_to new_question_path, class: 'a-button is-md is-secondary is-block' do
- i.fa-regular.fa-plus
- span
- | 質問する
-
-= questions_page_tabs
-
-- if @tag.present?
- header.page-main-header
- .container
- .page-main-header__inner
- .page-main-header__start
- h1.page-main-header__title
- | タグ「#{@tag.name}」のQ&A(#{@questions.total_count})
- - if admin_login?
- .page-main-header__end
- .page-main-header-actions
- .page-main-header-actions__items
- = react_component('Tags/TagEditButton', tagId: @tag.id, tagName: @tag.name)
- hr.a-border
+= render 'questions_and_pair_works/questions_and_pair_works_header'
.page-main
+ = render 'questions_and_pair_works/questions_and_pair_works_page_main_header'
+ hr.a-border
+ = questions_sub_tabs
+
nav.page-filter.form
.container.is-md
= form_with url: questions_path, method: 'get', local: true
@@ -49,6 +24,20 @@ header.page-header
hr.a-border
.page-body
.page-body__inner.has-side-nav
+
+ - if @tag.present?
+ header.page-body-header
+ .container.is-md
+ .page-body-header__inner
+ .page-body-header__start
+ h1.page-body-header__title
+ | タグ「#{@tag.name}」のQ&A(#{@questions.total_count})
+ - if admin_login?
+ .page-body-header__end
+ .page-body-header-actions
+ .page-body-header-actions__items
+ = react_component('Tags/TagEditButton', tagId: @tag.id, tagName: @tag.name)
+
.container.is-md
.page-body__columns
.page-body__column.is-main
diff --git a/app/views/questions/new.html.slim b/app/views/questions/new.html.slim
index 33a6378dbe4..7f47592526b 100644
--- a/app/views/questions/new.html.slim
+++ b/app/views/questions/new.html.slim
@@ -1,25 +1,17 @@
- title '質問作成'
- set_meta_tags description: '質問作成ページです。'
-header.page-header
- .container
- .page-header__inner
- .page-header__start
- h2.page-header__title
- | 質問する
- .page-header__end
- .page-header-actions
- ul.page-header-actions__items
- li.page-header-actions__item
- = link_to questions_path(target: 'not_solved'), class: 'a-button is-md is-secondary is-block is-back' do
- | Q&A一覧
-hr.a-border
-.a-page-notice.page-notice
- .container
- .a-page-notice__inner
- p
- = link_to '/pages/how_to_ask_questions', target: '_blank', class: 'page-notice__link', rel: 'noopener noreferrer' do
- | このドキュメントを参考にして質問をしてみよう!
-.page-body
- .container.is-xxl
- = render 'form', question: @question
+= render 'questions_and_pair_works/questions_and_pair_works_header'
+.page-main
+ = render 'questions_and_pair_works/questions_and_pair_works_page_main_header'
+ hr.a-border
+ .a-page-notice.page-notice
+ .container
+ .a-page-notice__inner
+ p
+ = link_to '/pages/how_to_ask_questions', target: '_blank', class: 'page-notice__link', rel: 'noopener noreferrer' do
+ | このドキュメントを参考にして質問をしてみよう!
+
+ .page-body
+ .container.is-xxl
+ = render 'form', question: @question
diff --git a/app/views/questions/show.html.slim b/app/views/questions/show.html.slim
index b2b0ebd1c14..fa38e83784a 100644
--- a/app/views/questions/show.html.slim
+++ b/app/views/questions/show.html.slim
@@ -5,61 +5,48 @@
- else
- set_meta_tags description: "#{@question.user.long_name}さんが投稿した、質問「#{@question.title}」のページです。この質問に関連するプラクティスはありません。"
-header.page-header
- .container
- .page-header__inner
- .page-header__start
- .page-header__title
- | Q&A
- .page-header__end
- .page-header-actions
- ul.page-header-actions__items
- li.page-header-actions__item
- = link_to new_question_path, class: 'a-button is-md is-secondary is-block' do
- i.fa-regular.fa-plus
- span
- | 質問する
- li.page-header-actions__item
- = link_to questions_path(target: 'not_solved'), class: 'a-button is-md is-secondary is-block is-back' do
- | Q&A一覧
-hr.a-border
-.page-body
- .page-body__inner.has-side-nav
- .container.is-md
- .question.page-content
- = render 'question_header', question: @question
- = render 'question_body', question: @question
- .loading-content
- - 3.times do
- = render 'questions/comment_placeholder'
- .answer-content.is-hidden
- - if @question.ai_answer
- = render 'ai_answer', question: @question
- header.thread-comments__header
- h2.thread-comments__title
- | 回答・コメント
- .answers-list
- - @answers.each do |answer|
- = render 'answer', question: @question, user: current_user, answer: answer
- = render 'new_answer', question: @question, user: current_user
- nav.a-side-nav
- .a-side-nav__inner
- header.a-side-nav__header
- h2.a-side-nav__title
- - if @question.practice
- = link_to @question.practice,
- class: 'a-side-nav__title-link' do
- = @question.practice.title
- - else
- | 関連プラクティス無し
- hr.a-border
- .a-side-nav__body
- = render 'nav_questions', questions: @practice_questions
- hr.a-border
- footer.card-footer
- .card-footer__footer-link
- = link_to questions_path(practice_id: @question.practice), class: 'card-footer__footer-text-link' do
- | 全て見る
+= render 'questions_and_pair_works/questions_and_pair_works_header'
+.page-main
+ = render 'questions_and_pair_works/questions_and_pair_works_page_main_header'
+ hr.a-border
+
+ .page-body
+ .page-body__inner.has-side-nav
+ .container.is-md
+ .question.page-content
+ = render 'question_header', question: @question
+ = render 'question_body', question: @question
+ .loading-content
+ - 3.times do
+ = render 'questions/comment_placeholder'
+ .answer-content.is-hidden
+ - if @question.ai_answer
+ = render 'ai_answer', question: @question
+ header.thread-comments__header
+ h2.thread-comments__title
+ | 回答・コメント
+ .answers-list
+ - @answers.each do |answer|
+ = render 'answer', question: @question, user: current_user, answer: answer
+ = render 'new_answer', question: @question, user: current_user
+ nav.a-side-nav
+ .a-side-nav__inner
+ header.a-side-nav__header
+ h2.a-side-nav__title
+ - if @question.practice
+ = link_to @question.practice,
+ class: 'a-side-nav__title-link' do
+ = @question.practice.title
+ - else
+ | 関連プラクティス無し
+ hr.a-border
+ .a-side-nav__body
+ = render 'nav_questions', questions: @practice_questions
+ hr.a-border
+ footer.card-footer
+ .card-footer__footer-link
+ = link_to questions_path(practice_id: @question.practice), class: 'card-footer__footer-text-link' do
+ | 全て見る
= render '/shared/modal', id: 'modal-delete-request', modal_title: '質問の削除申請' do
.modal__description.is-md
diff --git a/app/views/questions_and_pair_works/_questions_and_pair_works_header.html.slim b/app/views/questions_and_pair_works/_questions_and_pair_works_header.html.slim
new file mode 100644
index 00000000000..7fd1bd5ee35
--- /dev/null
+++ b/app/views/questions_and_pair_works/_questions_and_pair_works_header.html.slim
@@ -0,0 +1,29 @@
+// TODO ペアワークを本番リリースしたらこの分岐は削除
+- if pair_work_available?
+ header.page-header
+ .container
+ .page-header__inner
+ .page-header__start
+ h2.page-header__title
+ | Q&A・ペアワーク
+ .page-header__end
+ = questions_and_pair_works_page_tabs
+- else
+ header.page-header
+ .container
+ .page-header__inner
+ .page-header__start
+ h2.page-header__title
+ | Q&A
+ .page-header__end
+ .page-header-actions
+ ul.page-header-actions__items
+ - unless params[:action] == 'new'
+ li.page-header-actions__item
+ = link_to new_question_path, class: 'a-button is-md is-secondary is-block' do
+ i.fa-regular.fa-plus
+ span 質問する
+ - unless params[:action] == 'index'
+ li.page-header-actions__item
+ = link_to questions_path(target: 'not_solved'), class: 'a-button is-md is-secondary is-block is-back' do
+ | Q&A一覧
diff --git a/app/views/questions_and_pair_works/_questions_and_pair_works_page_main_header.html.slim b/app/views/questions_and_pair_works/_questions_and_pair_works_page_main_header.html.slim
new file mode 100644
index 00000000000..7e2fb2bcc45
--- /dev/null
+++ b/app/views/questions_and_pair_works/_questions_and_pair_works_page_main_header.html.slim
@@ -0,0 +1,20 @@
+// TODO ペアワークを本番リリースしたらこの分岐は削除
+- if pair_work_available?
+ header.page-main-header
+ .container
+ .page-main-header__inner
+ .page-main-header__start
+ h2.page-main-header__title
+ | Q&A
+ .page-main-header__end
+ .page-header-actions
+ ul.page-header-actions__items
+ - unless params[:action] == 'new'
+ li.page-header-actions__item
+ = link_to new_question_path, class: 'a-button is-md is-secondary is-block' do
+ i.fa-regular.fa-plus
+ span 質問する
+ - unless params[:action] == 'index'
+ li.page-header-actions__item
+ = link_to questions_path(target: 'not_solved'), class: 'a-button is-md is-secondary is-block is-back' do
+ | Q&A一覧
diff --git a/app/views/regular_events/_participation.html.slim b/app/views/regular_events/_participation.html.slim
index 05b61691df7..636a14672f4 100644
--- a/app/views/regular_events/_participation.html.slim
+++ b/app/views/regular_events/_participation.html.slim
@@ -1,17 +1,18 @@
- if current_user.participating?(regular_event)
.event-main-actions.is-participationed
- .event-main-actions__description
- p
- | 参加登録しています。
- ul.event-main-actions__items
- li.event-main-actions__item
- = link_to regular_event_participation_path(regular_event_id: regular_event), method: :delete,
- data: { confirm: 'イベントの参加をキャンセルします。よろしいですか?' }, class: 'event-main-actions__item-cancel' do
- | 参加を取り消す
+ .event-main-actions__body
+ .event-main-actions__description
+ p 参加登録しています。
+ ul.event-main-actions__items
+ li.event-main-actions__item
+ = link_to regular_event_participation_path(regular_event_id: regular_event), method: :delete,
+ data: { confirm: 'イベントの参加をキャンセルします。よろしいですか?' }, class: 'event-main-actions__item-cancel' do
+ | 参加を取り消す
- else
.event-main-actions.is-unparticipationed.is-available
- ul.event-main-actions__items
- li.event-main-actions__item
- = link_to regular_event_participations_path(regular_event_id: regular_event), method: :post,
- data: { confirm: 'イベント参加申込をします。よろしいですか?' }, class: 'a-button is-primary is-md is-block' do
- | 参加申込
+ .event-main-actions__body
+ ul.event-main-actions__items
+ li.event-main-actions__item
+ = link_to regular_event_participations_path(regular_event_id: regular_event), method: :post,
+ data: { confirm: 'イベント参加申込をします。よろしいですか?' }, class: 'a-button is-primary is-md is-block' do
+ | 参加申込
diff --git a/app/views/users/form/_learning_time_frames.html.slim b/app/views/users/form/_learning_time_frames.html.slim
index 4d2116e2e99..f7e69d69ab3 100644
--- a/app/views/users/form/_learning_time_frames.html.slim
+++ b/app/views/users/form/_learning_time_frames.html.slim
@@ -1,4 +1,4 @@
-.form-item
+.form-item#activity-schedule
= f.label :learning_time_frames, '主な活動予定時間', class: 'a-form-label'
.a-form-help
p
diff --git a/config/initializers/active_support_notifications.rb b/config/initializers/active_support_notifications.rb
index 98bff9b0154..2da6b3c9f83 100644
--- a/config/initializers/active_support_notifications.rb
+++ b/config/initializers/active_support_notifications.rb
@@ -68,4 +68,14 @@
times_channel_destroyer = TimesChannelDestroyer.new
ActiveSupport::Notifications.subscribe('retirement.create', times_channel_destroyer)
ActiveSupport::Notifications.subscribe('training_completion.create', times_channel_destroyer)
+
+ watch_for_pair_work_creator = WatchForPairWorkCreator.new
+ ActiveSupport::Notifications.subscribe('pair_work.create', watch_for_pair_work_creator)
+ ActiveSupport::Notifications.subscribe('pair_work.update', watch_for_pair_work_creator)
+
+ pair_work_notifier = PairWorkNotifier.new
+ ActiveSupport::Notifications.subscribe('pair_work.create', pair_work_notifier)
+ ActiveSupport::Notifications.subscribe('pair_work.update', pair_work_notifier)
+
+ ActiveSupport::Notifications.subscribe('pair_work.reserve', PairWorkMatchingNotifier.new)
end
diff --git a/config/locales/ja.yml b/config/locales/ja.yml
index a1a788f1d54..ba87ba169cb 100644
--- a/config/locales/ja.yml
+++ b/config/locales/ja.yml
@@ -10,6 +10,7 @@ ja:
md: '%m月%d日'
sm: '%m/%d'
long: '%Y年%m月%d日(%a)'
+ mdw_unique: '%-m/%-d(%a)'
time:
formats:
default: '%Y年%m月%d日(%a) %H:%M'
@@ -21,6 +22,7 @@ ja:
year_only: '%Y'
mdw_unique: '%-m/%-d(%a)'
long: '%Y年%m月%d日(%a)'
+ mdw_and_time: '%-m月%-d日(%a)%H:%M'
dates:
today: '今日'
tomorrow: '明日'
@@ -67,6 +69,7 @@ ja:
coding_test: コーディングテスト
movie: 動画
grant_course_application: 給付金対応コース受講申請
+ pair_work: ペアワーク
attributes:
user:
login_name: アカウント
@@ -369,6 +372,13 @@ ja:
output: 出力
doorkeeper/application:
redirect_uri: Redirect URL
+ pair_work:
+ practice: プラクティス
+ title: タイトル
+ description: 内容
+ schedules: スケジュール
+ reserved_at: 希望した日時
+ buddy: ペア
enums:
emotion:
negative: Negative
diff --git a/config/routes.rb b/config/routes.rb
index 0e5c0a80c54..0494034e998 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -117,6 +117,9 @@
end
end
resources :press_releases, only: %i(index)
+ resources :pair_works do
+ resource :reservations, only: %i(create destroy), controller: "pair_works/reservations"
+ end
get "articles/tags/:tag", to: "articles#index", as: :tag, tag: /.+/
get 'sponsorships', to: 'articles/sponsorships#index'
get "pages/tags/:tag", to: "pages#index", as: :pages_tag, tag: /.+/, format: "html"
diff --git a/db/fixtures/pair_work_schedules.yml b/db/fixtures/pair_work_schedules.yml
new file mode 100644
index 00000000000..85a3e9e00bc
--- /dev/null
+++ b/db/fixtures/pair_work_schedules.yml
@@ -0,0 +1,11 @@
+pair_work_schedule1:
+ pair_work: pair_work1
+ proposed_at: <%= Time.zone.parse('2025-02-07 23:00:00') %>
+
+pair_work_schedule2:
+ pair_work: pair_work2
+ proposed_at: <%= Time.zone.parse('2025-03-06 23:00:00') %>
+
+pair_work_schedule3:
+ pair_work: pair_work3
+ proposed_at: <%= Time.current.beginning_of_day + 23.hours %>
diff --git a/db/fixtures/pair_works.yml b/db/fixtures/pair_works.yml
new file mode 100644
index 00000000000..5a333aba734
--- /dev/null
+++ b/db/fixtures/pair_works.yml
@@ -0,0 +1,32 @@
+pair_work1:
+ title: ペア確定済みの動作確認
+ description: ペア確定済みのペアワークです
+ reserved_at: <%= Time.zone.parse('2025-02-07 23:00:00') %>
+ user: kimura
+ practice: null
+ buddy: sotugyou
+ published_at: <%= Time.zone.parse('2025-02-01 00:00:00') %>
+ created_at: <%= Time.zone.parse('2025-02-01 00:00:00') %>
+ wip: false
+
+pair_work2:
+ title: 募集中の動作確認
+ description: 募集中のペアワークです
+ reserved_at: null
+ user: kimura
+ practice: null
+ buddy: null
+ published_at: <%= Time.zone.parse('2025-02-28 00:00:00') %>
+ created_at: <%= Time.zone.parse('2025-02-28 00:00:00') %>
+ wip: false
+
+pair_work3:
+ title: 近日開催のペアワークの動作確認
+ description: 近日開催のペア確定済みペアワークです
+ reserved_at: <%= Time.current.beginning_of_day + 23.hours %>
+ user: kimura
+ practice: null
+ buddy: sotugyou
+ published_at: <%= Time.current %>
+ created_at: <%= Time.current %>
+ wip: false
diff --git a/db/migrate/20250203011220_create_pair_works.rb b/db/migrate/20250203011220_create_pair_works.rb
new file mode 100644
index 00000000000..e6f9faeda9e
--- /dev/null
+++ b/db/migrate/20250203011220_create_pair_works.rb
@@ -0,0 +1,16 @@
+class CreatePairWorks < ActiveRecord::Migration[8.1]
+ def change
+ create_table :pair_works do |t|
+ t.string :title, null: false
+ t.text :description
+ t.timestamp :reserved_at
+ t.references :user, null: false, foreign_key: true
+ t.references :practice, foreign_key: true
+ t.references :buddy, foreign_key: { to_table: :users }
+ t.timestamp :published_at
+ t.boolean :wip, null: false
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/migrate/20250203025711_create_schedules.rb b/db/migrate/20250203025711_create_schedules.rb
new file mode 100644
index 00000000000..ccb4a8ed9c9
--- /dev/null
+++ b/db/migrate/20250203025711_create_schedules.rb
@@ -0,0 +1,10 @@
+class CreateSchedules < ActiveRecord::Migration[8.1]
+ def change
+ create_table :schedules do |t|
+ t.references :pair_work, null: false, foreign_key: true
+ t.timestamp :proposed_at, null: false
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/migrate/20250414013258_add_channel_to_pair_work.rb b/db/migrate/20250414013258_add_channel_to_pair_work.rb
new file mode 100644
index 00000000000..a1a7cabc175
--- /dev/null
+++ b/db/migrate/20250414013258_add_channel_to_pair_work.rb
@@ -0,0 +1,5 @@
+class AddChannelToPairWork < ActiveRecord::Migration[8.1]
+ def change
+ add_column :pair_works, :channel, :string, default: 'ペアワーク・モブワーク1', null: false
+ end
+end
diff --git a/db/migrate/20250519012857_add_not_null_constraint_to_pair_works_description.rb b/db/migrate/20250519012857_add_not_null_constraint_to_pair_works_description.rb
new file mode 100644
index 00000000000..899712cf3dc
--- /dev/null
+++ b/db/migrate/20250519012857_add_not_null_constraint_to_pair_works_description.rb
@@ -0,0 +1,5 @@
+class AddNotNullConstraintToPairWorksDescription < ActiveRecord::Migration[8.1]
+ def change
+ change_column_null :pair_works, :description, false
+ end
+end
diff --git a/db/migrate/20250830033535_add_default_to_pair_works_wip.rb b/db/migrate/20250830033535_add_default_to_pair_works_wip.rb
new file mode 100644
index 00000000000..4488aea7877
--- /dev/null
+++ b/db/migrate/20250830033535_add_default_to_pair_works_wip.rb
@@ -0,0 +1,5 @@
+class AddDefaultToPairWorksWip < ActiveRecord::Migration[8.1]
+ def change
+ change_column_default :pair_works, :wip, from: nil, to: false
+ end
+end
diff --git a/db/migrate/20250830033632_add_index_to_pair_works_published_at.rb b/db/migrate/20250830033632_add_index_to_pair_works_published_at.rb
new file mode 100644
index 00000000000..09cefa3f427
--- /dev/null
+++ b/db/migrate/20250830033632_add_index_to_pair_works_published_at.rb
@@ -0,0 +1,5 @@
+class AddIndexToPairWorksPublishedAt < ActiveRecord::Migration[8.1]
+ def change
+ add_index :pair_works, :published_at, if_not_exists: true
+ end
+end
diff --git a/db/migrate/20251115001156_rename_schedules_to_pair_work_schedules.rb b/db/migrate/20251115001156_rename_schedules_to_pair_work_schedules.rb
new file mode 100644
index 00000000000..aca72e30882
--- /dev/null
+++ b/db/migrate/20251115001156_rename_schedules_to_pair_work_schedules.rb
@@ -0,0 +1,5 @@
+class RenameSchedulesToPairWorkSchedules < ActiveRecord::Migration[8.1]
+ def change
+ rename_table :schedules, :pair_work_schedules
+ end
+end
diff --git a/db/migrate/20260111044958_add_unique_index_to_pair_work_schedules.rb b/db/migrate/20260111044958_add_unique_index_to_pair_work_schedules.rb
new file mode 100644
index 00000000000..35f4be82319
--- /dev/null
+++ b/db/migrate/20260111044958_add_unique_index_to_pair_work_schedules.rb
@@ -0,0 +1,5 @@
+class AddUniqueIndexToPairWorkSchedules < ActiveRecord::Migration[8.1]
+ def change
+ add_index :pair_work_schedules, [:pair_work_id, :proposed_at], unique: true
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 9c23be100f5..19a22ca37b1 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -551,6 +551,33 @@
t.index ["user_id"], name: "index_pages_on_user_id"
end
+ create_table "pair_work_schedules", force: :cascade do |t|
+ t.bigint "pair_work_id", null: false
+ t.datetime "proposed_at", null: false
+ t.datetime "created_at", precision: 6, null: false
+ t.datetime "updated_at", precision: 6, null: false
+ t.index ["pair_work_id", "proposed_at"], name: "index_pair_work_schedules_on_pair_work_id_and_proposed_at", unique: true
+ t.index ["pair_work_id"], name: "index_pair_work_schedules_on_pair_work_id"
+ end
+
+ create_table "pair_works", force: :cascade do |t|
+ t.string "title", null: false
+ t.text "description", null: false
+ t.datetime "reserved_at"
+ t.bigint "user_id", null: false
+ t.bigint "practice_id"
+ t.bigint "buddy_id"
+ t.datetime "published_at"
+ t.boolean "wip", default: false, null: false
+ t.datetime "created_at", precision: 6, null: false
+ t.datetime "updated_at", precision: 6, null: false
+ t.string "channel", default: "ペアワーク・モブワーク1", null: false
+ t.index ["buddy_id"], name: "index_pair_works_on_buddy_id"
+ t.index ["practice_id"], name: "index_pair_works_on_practice_id"
+ t.index ["published_at"], name: "index_pair_works_on_published_at"
+ t.index ["user_id"], name: "index_pair_works_on_user_id"
+ end
+
create_table "participations", force: :cascade do |t|
t.datetime "created_at", null: false
t.boolean "enable", default: false, null: false
@@ -1123,6 +1150,10 @@
add_foreign_key "organizers", "users"
add_foreign_key "pages", "practices"
add_foreign_key "pages", "users"
+ add_foreign_key "pair_work_schedules", "pair_works"
+ add_foreign_key "pair_works", "practices"
+ add_foreign_key "pair_works", "users"
+ add_foreign_key "pair_works", "users", column: "buddy_id"
add_foreign_key "participations", "events"
add_foreign_key "participations", "users"
add_foreign_key "practices", "practices", column: "source_id"
diff --git a/db/seeds.rb b/db/seeds.rb
index d162743c707..e0ea89c47f4 100644
--- a/db/seeds.rb
+++ b/db/seeds.rb
@@ -74,6 +74,8 @@
skipped_practices
grant_course_applications
practices_movies
+ pair_works
+ pair_work_schedules
]
ActiveRecord::FixtureSet.create_fixtures 'db/fixtures', tables
diff --git a/test/decorators/pair_work_decorator_test.rb b/test/decorators/pair_work_decorator_test.rb
new file mode 100644
index 00000000000..3300f1ed3f7
--- /dev/null
+++ b/test/decorators/pair_work_decorator_test.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+require 'test_helper'
+require 'active_decorator_test_case'
+
+class PairWorkDecoratorTest < ActiveDecoratorTestCase
+ test '#important?' do
+ not_solved_pair_work = decorate(pair_works(:pair_work1))
+ assert not_solved_pair_work.important?
+
+ not_solved_pair_work.comments.create!(
+ user: users(:komagata),
+ description: 'コメント'
+ )
+ assert_not not_solved_pair_work.important?
+
+ solved_pair_work = decorate(pair_works(:pair_work2))
+ assert_not solved_pair_work.important?
+ end
+end
diff --git a/test/deliveries/activity_delivery_test.rb b/test/deliveries/activity_delivery_test.rb
index 888e4526a92..758ab42f59e 100644
--- a/test/deliveries/activity_delivery_test.rb
+++ b/test/deliveries/activity_delivery_test.rb
@@ -499,4 +499,52 @@ class ActivityDeliveryTest < ActiveSupport::TestCase
ActivityDelivery.with(**params).notify(:added_work)
end
end
+
+ test '.notify(:came_pair_work)' do
+ pair_work = pair_works(:pair_work1)
+ params = {
+ pair_work:,
+ receiver: users(:mentormentaro)
+ }
+
+ assert_difference -> { AbstractNotifier::Testing::Driver.deliveries.count }, 1 do
+ ActivityDelivery.notify!(:came_pair_work, **params)
+ end
+
+ assert_difference -> { AbstractNotifier::Testing::Driver.enqueued_deliveries.count }, 1 do
+ ActivityDelivery.notify(:came_pair_work, **params)
+ end
+
+ assert_difference -> { AbstractNotifier::Testing::Driver.deliveries.count }, 1 do
+ ActivityDelivery.with(**params).notify!(:came_pair_work)
+ end
+
+ assert_difference -> { AbstractNotifier::Testing::Driver.enqueued_deliveries.count }, 1 do
+ ActivityDelivery.with(**params).notify(:came_pair_work)
+ end
+ end
+
+ test '.notify(:matching_pair_work)' do
+ pair_work = pair_works(:pair_work2)
+ params = {
+ pair_work:,
+ receiver: users(:mentormentaro)
+ }
+
+ assert_difference -> { AbstractNotifier::Testing::Driver.deliveries.count }, 1 do
+ ActivityDelivery.notify!(:matching_pair_work, **params)
+ end
+
+ assert_difference -> { AbstractNotifier::Testing::Driver.enqueued_deliveries.count }, 1 do
+ ActivityDelivery.notify(:matching_pair_work, **params)
+ end
+
+ assert_difference -> { AbstractNotifier::Testing::Driver.deliveries.count }, 1 do
+ ActivityDelivery.with(**params).notify!(:matching_pair_work)
+ end
+
+ assert_difference -> { AbstractNotifier::Testing::Driver.enqueued_deliveries.count }, 1 do
+ ActivityDelivery.with(**params).notify(:matching_pair_work)
+ end
+ end
end
diff --git a/test/fixtures/pair_work_schedules.yml b/test/fixtures/pair_work_schedules.yml
new file mode 100644
index 00000000000..b852a84098c
--- /dev/null
+++ b/test/fixtures/pair_work_schedules.yml
@@ -0,0 +1,15 @@
+pair_work_schedule1:
+ pair_work: pair_work1
+ proposed_at: <%= Time.zone.parse('2025-01-01 00:00:00') %>
+
+pair_work_schedule2:
+ pair_work: pair_work1
+ proposed_at: <%= Time.zone.parse('2025-01-07 23:00:00') %>
+
+pair_work_schedule3:
+ pair_work: pair_work2
+ proposed_at: <%= Time.zone.parse('2025-01-02 01:00:00') %>
+
+pair_work_schedule4:
+ pair_work: pair_work3
+ proposed_at: <%= Time.zone.parse('2025-01-02 01:00:00') %>
diff --git a/test/fixtures/pair_works.yml b/test/fixtures/pair_works.yml
new file mode 100644
index 00000000000..2920b597dfe
--- /dev/null
+++ b/test/fixtures/pair_works.yml
@@ -0,0 +1,32 @@
+pair_work1:
+ title: 募集中のペアワークです(タイトル)
+ description: 募集中のペアワークです(詳細)
+ reserved_at: null
+ user: kimura
+ practice: practice1
+ buddy: null
+ published_at: <%= Time.zone.parse('2025-01-01 00:00:00') %>
+ created_at: <%= Time.zone.parse('2025-01-01 00:00:00') %>
+ wip: false
+
+pair_work2:
+ title: ペア確定済みのペアワークです(タイトル)
+ description: ペア確定済みのペアワークです(詳細)
+ reserved_at: <%= Time.zone.parse('2025-01-02 01:00:00') %>
+ user: kimura
+ practice: practice1
+ buddy: sotugyou
+ published_at: <%= Time.zone.parse('2025-01-01 00:00:00') %>
+ created_at: <%= Time.zone.parse('2025-01-01 00:00:00') %>
+ wip: false
+
+pair_work3:
+ title: wipのペアワークです(タイトル)
+ description: wipのペアワークです(詳細)
+ reserved_at: null
+ user: kimura
+ practice: null
+ buddy: null
+ published_at: null
+ created_at: <%= Time.zone.parse('2025-01-01 00:00:00') %>
+ wip: true
diff --git a/test/helpers/pair_works_helper_test.rb b/test/helpers/pair_works_helper_test.rb
new file mode 100644
index 00000000000..8579dfe36b0
--- /dev/null
+++ b/test/helpers/pair_works_helper_test.rb
@@ -0,0 +1,92 @@
+# frozen_string_literal: true
+
+require 'test_helper'
+
+class PairWorksHelperTest < ActionView::TestCase
+ setup do
+ def current_user
+ users(:kimura)
+ end
+ end
+
+ test 'schedule_dates' do
+ created_at = Time.zone.local(2025, 1, 1, 0, 0, 0)
+ dates = [
+ Date.new(2025, 1, 1),
+ Date.new(2025, 1, 2),
+ Date.new(2025, 1, 3),
+ Date.new(2025, 1, 4),
+ Date.new(2025, 1, 5),
+ Date.new(2025, 1, 6),
+ Date.new(2025, 1, 7)
+ ]
+ assert_equal dates, schedule_dates(created_at)
+
+ date = Date.new(2025, 1, 1)
+ assert_equal dates, schedule_dates(date)
+ end
+
+ test 'sorted_wdays' do
+ wdays_if_wednesday = [3, 4, 5, 6, 0, 1, 2]
+ assert_equal wdays_if_wednesday, sorted_wdays(Date.new(2025, 1, 1))
+ end
+
+ test 'schedule_check_disabled?' do
+ past_date = Time.current.yesterday
+ future_date = Time.current.tomorrow
+ my_pair_work = pair_works(:pair_work1)
+
+ assert schedule_check_disabled?(past_date)
+ assert_not schedule_check_disabled?(future_date)
+ assert schedule_check_disabled?(future_date, pair_work: my_pair_work)
+ end
+
+ test 'learning_time_frame_checked?' do
+ future_date = Time.current.tomorrow
+ past_date = Time.current.yesterday
+ my_learning_time_frame_id = LearningTimeFramesUser.create!(user: current_user, learning_time_frame_id: 3).learning_time_frame_id
+
+ assert learning_time_frame_checked?(future_date, my_learning_time_frame_id)
+ assert_not learning_time_frame_checked?(past_date, my_learning_time_frame_id)
+ end
+
+ test 'schedule_target_time' do
+ elapsed_day_count = 1
+ elapsed_time_count = 1
+ base_time = Time.current.beginning_of_day
+ target_time = base_time + elapsed_day_count.days + elapsed_time_count.hours
+
+ assert_equal target_time, schedule_target_time(elapsed_day_count, elapsed_time_count)
+
+ pair_work = pair_works(:pair_work1)
+ base_time = pair_work.created_at
+ target_time = base_time + elapsed_day_count.days + elapsed_time_count.hours
+
+ assert_equal target_time, schedule_target_time(elapsed_day_count, elapsed_time_count, pair_work:)
+ end
+
+ test 'schedule_check_box_id' do
+ travel_to Time.zone.local(2025, 1, 1, 0, 0, 0) do
+ elapsed_day_count = 1
+ elapsed_time_count = 1
+ target_time = schedule_target_time(elapsed_day_count, elapsed_time_count)
+
+ assert_equal 'schedule_ids_202501020100', schedule_check_box_id(target_time)
+ end
+ end
+
+ test 'meta_label_by_status' do
+ pair_work = pair_works(:pair_work2)
+ normal_meta_label = 'a-meta__label'
+ important_meta_label = 'a-meta__label is-important'
+
+ assert_equal normal_meta_label, meta_label_by_status(pair_work)
+ assert_not_equal important_meta_label, meta_label_by_status(pair_work)
+
+ reserved_on = Time.zone.local(2025, 1, 2)
+ travel_to reserved_on do
+ assert_equal important_meta_label, meta_label_by_status(pair_work)
+ assert_not_equal normal_meta_label, meta_label_by_status(pair_work)
+ end
+ end
+end
diff --git a/test/mailers/activity_mailer_test.rb b/test/mailers/activity_mailer_test.rb
index 82cb0ed9a39..bac2ff3b41d 100644
--- a/test/mailers/activity_mailer_test.rb
+++ b/test/mailers/activity_mailer_test.rb
@@ -1285,6 +1285,108 @@ class ActivityMailerTest < ActionMailer::TestCase
assert_empty ActionMailer::Base.deliveries
end
+ test 'came_pair_work' do
+ pair_work = pair_works(:pair_work1)
+ mentor = users(:komagata)
+
+ ActivityMailer.came_pair_work(
+ receiver: mentor,
+ pair_work:
+ ).deliver_now
+
+ assert_not ActionMailer::Base.deliveries.empty?
+ email = ActionMailer::Base.deliveries.last
+ query = CGI.escapeHTML({ kind: 28, link: "/pair_works/#{pair_work.id}" }.to_param)
+ assert_equal ['noreply@bootcamp.fjord.jp'], email.from
+ assert_equal ['komagata@fjord.jp'], email.to
+ assert_equal '[FBC] kimuraさんからペアワーク依頼「募集中のペアワークです(タイトル)」が投稿されました。', email.subject
+ assert_match(%r{ペアワークのページへ}, email.body.to_s)
+ end
+
+ test 'came_pair_work with params' do
+ pair_work = pair_works(:pair_work1)
+ mentor = users(:komagata)
+
+ mailer = ActivityMailer.with(
+ receiver: mentor,
+ pair_work:
+ ).came_pair_work
+
+ perform_enqueued_jobs do
+ mailer.deliver_later
+ end
+
+ assert_not ActionMailer::Base.deliveries.empty?
+ email = ActionMailer::Base.deliveries.last
+ query = CGI.escapeHTML({ kind: 28, link: "/pair_works/#{pair_work.id}" }.to_param)
+ assert_equal ['noreply@bootcamp.fjord.jp'], email.from
+ assert_equal ['komagata@fjord.jp'], email.to
+ assert_equal '[FBC] kimuraさんからペアワーク依頼「募集中のペアワークです(タイトル)」が投稿されました。', email.subject
+ assert_match(%r{ペアワークのページへ}, email.body.to_s)
+ end
+
+ test 'matching_pair_work' do
+ pair_work = pair_works(:pair_work2)
+ mentor = users(:komagata)
+
+ ActivityMailer.matching_pair_work(
+ receiver: mentor,
+ pair_work:
+ ).deliver_now
+
+ assert_not ActionMailer::Base.deliveries.empty?
+ email = ActionMailer::Base.deliveries.last
+ query = CGI.escapeHTML({ kind: 29, link: "/pair_works/#{pair_work.id}" }.to_param)
+ assert_equal ['noreply@bootcamp.fjord.jp'], email.from
+ assert_equal ['komagata@fjord.jp'], email.to
+ assert_equal '[FBC] kimuraさんのペアワーク【 ペア確定済みのペアワークです(タイトル) 】のペアがsotugyouさんに決定しました。', email.subject
+ assert_match(%r{ペアワークのページへ}, email.body.to_s)
+ end
+
+ test 'matching_pair_work with params' do
+ pair_work = pair_works(:pair_work2)
+ mentor = users(:komagata)
+
+ mailer = ActivityMailer.with(
+ receiver: mentor,
+ pair_work:
+ ).matching_pair_work
+
+ perform_enqueued_jobs do
+ mailer.deliver_later
+ end
+
+ assert_not ActionMailer::Base.deliveries.empty?
+ email = ActionMailer::Base.deliveries.last
+ query = CGI.escapeHTML({ kind: 29, link: "/pair_works/#{pair_work.id}" }.to_param)
+ assert_equal ['noreply@bootcamp.fjord.jp'], email.from
+ assert_equal ['komagata@fjord.jp'], email.to
+ assert_equal '[FBC] kimuraさんのペアワーク【 ペア確定済みのペアワークです(タイトル) 】のペアがsotugyouさんに決定しました。', email.subject
+ assert_match(%r{ペアワークのページへ}, email.body.to_s)
+ end
+
+ test 'matching_pair_work to mute email notification or retired user' do
+ pair_work = pair_works(:pair_work2)
+ receiver = users(:hatsuno)
+ Watch.create!(user: receiver, watchable: pair_work)
+
+ receiver.update_columns(mail_notification: false, retired_on: nil) # rubocop:disable Rails/SkipsModelValidations
+ ActivityMailer.matching_pair_work(receiver:, pair_work: pair_work.reload).deliver_now
+ assert_empty ActionMailer::Base.deliveries
+
+ receiver.update_columns(mail_notification: false, retired_on: Date.current) # rubocop:disable Rails/SkipsModelValidations
+ ActivityMailer.matching_pair_work(receiver:, pair_work: pair_work.reload).deliver_now
+ assert_empty ActionMailer::Base.deliveries
+
+ receiver.update_columns(mail_notification: true, retired_on: Date.current) # rubocop:disable Rails/SkipsModelValidations
+ ActivityMailer.matching_pair_work(receiver:, pair_work: pair_work.reload).deliver_now
+ assert_empty ActionMailer::Base.deliveries
+
+ receiver.update_columns(mail_notification: true, retired_on: nil) # rubocop:disable Rails/SkipsModelValidations
+ ActivityMailer.matching_pair_work(receiver:, pair_work: pair_work.reload).deliver_now
+ assert_not ActionMailer::Base.deliveries.empty?
+ end
+
private
def mailer_url_options
diff --git a/test/mailers/previews/activity_mailer_preview.rb b/test/mailers/previews/activity_mailer_preview.rb
index c5767e6b661..f364cfc2789 100644
--- a/test/mailers/previews/activity_mailer_preview.rb
+++ b/test/mailers/previews/activity_mailer_preview.rb
@@ -186,4 +186,18 @@ def added_work
ActivityMailer.with(work:, sender: user, receiver:).added_work
end
+
+ def came_pair_work
+ receiver = User.find(ActiveRecord::FixtureSet.identify(:mentormentaro))
+ pair_work = PairWork.find(ActiveRecord::FixtureSet.identify(:pair_work1))
+
+ ActivityMailer.with(receiver:, pair_work:).came_pair_work
+ end
+
+ def matching_pair_work
+ receiver = User.find(ActiveRecord::FixtureSet.identify(:mentormentaro))
+ pair_work = PairWork.find(ActiveRecord::FixtureSet.identify(:pair_work2))
+
+ ActivityMailer.with(receiver:, pair_work:).matching_pair_work
+ end
end
diff --git a/test/models/pair_work_test.rb b/test/models/pair_work_test.rb
new file mode 100644
index 00000000000..755836068a7
--- /dev/null
+++ b/test/models/pair_work_test.rb
@@ -0,0 +1,170 @@
+# frozen_string_literal: true
+
+require 'test_helper'
+
+class PairWorkTest < ActiveSupport::TestCase
+ test '.by_target' do
+ solved_pair_work = pair_works(:pair_work2)
+ not_solved_pair_work = pair_works(:pair_work1)
+ assert_includes PairWork.by_target('solved'), solved_pair_work
+ assert_not_includes PairWork.by_target('solved'), not_solved_pair_work
+
+ assert_includes PairWork.by_target('not_solved'), not_solved_pair_work
+ assert_not_includes PairWork.by_target('not_solved'), solved_pair_work
+
+ assert_includes PairWork.by_target(nil), solved_pair_work
+ assert_includes PairWork.by_target(nil), not_solved_pair_work
+ end
+
+ test '.generate_pair_works_property' do
+ solved_pair_works_property = PairWork.generate_pair_works_property('solved')
+ assert_equal 'ペア確定済みのペアワーク', solved_pair_works_property.title
+ assert_equal 'ペア確定済みのペアワークはありません。', solved_pair_works_property.empty_message
+
+ not_solved_pair_works_property = PairWork.generate_pair_works_property('not_solved')
+ assert_equal '募集中のペアワーク', not_solved_pair_works_property.title
+ assert_equal '募集中のペアワークはありません。', not_solved_pair_works_property.empty_message
+
+ all_pair_works_property = PairWork.generate_pair_works_property(nil)
+ assert_equal '全てのペアワーク', all_pair_works_property.title
+ assert_equal 'ペアワークはありません。', all_pair_works_property.empty_message
+ end
+
+ test '#generate_notice_message' do
+ wip_pair_work = pair_works(:pair_work3)
+ assert_equal 'ペアワークをWIPとして保存しました。', wip_pair_work.generate_notice_message(:create)
+ assert_equal 'ペアワークをWIPとして保存しました。', wip_pair_work.generate_notice_message(:update)
+
+ published_pair_work = pair_works(:pair_work1)
+ assert_equal 'ペアワークを作成しました。', published_pair_work.generate_notice_message(:create)
+ assert_equal 'ペアワークを更新しました。', published_pair_work.generate_notice_message(:update)
+ end
+
+ test '.unsolved_badge' do
+ assert_equal 1, PairWork.unsolved_badge(current_user: users(:komagata))
+
+ assert_nil PairWork.unsolved_badge(current_user: users(:hatsuno))
+ end
+
+ test '.upcoming_pair_works' do
+ user = users(:hajime)
+
+ travel_to Time.zone.local(2025, 1, 15, 12, 0, 0) do
+ pair_work_template = {
+ user:,
+ title: 'ペアが確定していて、近日開催されるペアワーク',
+ description: 'ペアが確定していて、近日開催されるペアワーク',
+ buddy: users(:komagata),
+ channel: 'ペアワーク・モブワーク1',
+ wip: false
+ }
+ upcoming_pair_work_tomorrow = PairWork.create!(pair_work_template.merge(
+ reserved_at: Time.current.beginning_of_day + 1.day,
+ schedules_attributes: [{ proposed_at: Time.current.beginning_of_day + 1.day }]
+ ))
+ upcoming_pair_work_day_after_tomorrow = PairWork.create!(pair_work_template.merge(
+ reserved_at: Time.current.beginning_of_day + 2.days,
+ schedules_attributes: [{ proposed_at: Time.current.beginning_of_day + 2.days }]
+ ))
+ assert_includes PairWork.upcoming_pair_works(user), upcoming_pair_work_tomorrow
+ assert_includes PairWork.upcoming_pair_works(user), upcoming_pair_work_day_after_tomorrow
+
+ held_today_pair_work = PairWork.create!(pair_work_template.merge(
+ reserved_at: Time.current.beginning_of_day,
+ schedules_attributes: [{ proposed_at: Time.current.beginning_of_day }]
+ ))
+ assert_not_includes PairWork.upcoming_pair_works(user), held_today_pair_work
+
+ unrelated_pair_work = pair_works(:pair_work2)
+ assert_not_includes PairWork.upcoming_pair_works(user), unrelated_pair_work
+ end
+ end
+
+ test '.not_held' do
+ travel_to Time.zone.local(2025, 1, 15, 12, 0, 0) do
+ not_held_pair_work = PairWork.create!({
+ user: users(:kimura),
+ title: 'ペア確定したが、まだ実施されてないペアワーク',
+ description: 'ペア確定したが、まだ実施されてないペアワーク',
+ buddy: users(:komagata),
+ channel: 'ペアワーク・モブワーク1',
+ wip: false,
+ reserved_at: Time.current.beginning_of_day + 1.day,
+ schedules_attributes: [{ proposed_at: Time.current.beginning_of_day + 1.day }]
+ })
+ not_solved_pair_work = pair_works(:pair_work1)
+ wip_pair_work = pair_works(:pair_work3)
+
+ held_on_pair_work = pair_works(:pair_work2)
+
+ assert_includes PairWork.not_held, not_held_pair_work
+ assert_includes PairWork.not_held, not_solved_pair_work
+ assert_includes PairWork.not_held, wip_pair_work
+
+ assert_not_includes PairWork.not_held, held_on_pair_work
+ end
+ end
+
+ test '#solved?' do
+ solved_pair_work = pair_works(:pair_work2)
+ assert solved_pair_work.solved?
+
+ not_solved_pair_work = pair_works(:pair_work1)
+ assert_not not_solved_pair_work.solved?
+ end
+
+ test '#reserve' do
+ valid_params = {
+ reserved_at: Time.zone.parse('2025-01-01 00:00:00'),
+ buddy_id: users(:komagata).id
+ }
+ pair_work = pair_works(:pair_work1)
+
+ assert pair_work.reserve(valid_params)
+ end
+
+ test '#reserve fails when buddy_id is nil' do
+ invalid_params = {
+ reserved_at: Time.zone.parse('2025-01-02 01:00:00'),
+ buddy_id: nil
+ }
+ pair_work = pair_works(:pair_work1)
+
+ assert_not pair_work.reserve(invalid_params)
+ assert_includes pair_work.errors.full_messages, 'ペアが選択されていません'
+ end
+
+ test '#reserve fails when buddy_id does not exist' do
+ invalid_params = {
+ reserved_at: Time.zone.parse('2025-01-02 01:00:00'),
+ buddy_id: 999_999
+ }
+ pair_work = pair_works(:pair_work1)
+
+ assert_not pair_work.reserve(invalid_params)
+ assert_includes pair_work.errors.full_messages, 'ペアが選択されていません'
+ end
+
+ test '#reserve fails when reserved_at is nil' do
+ invalid_params = {
+ reserved_at: nil,
+ buddy_id: users(:komagata).id
+ }
+ pair_work = pair_works(:pair_work1)
+
+ assert_not pair_work.reserve(invalid_params)
+ assert_includes pair_work.errors.full_messages, '希望した日時が選択されていません'
+ end
+
+ test '#reserve fails when reserved_at is not in proposed schedules' do
+ unscheduled_reserved_at = Time.zone.parse('2025-01-04 00:00:00')
+ invalid_params_reserved_at = {
+ reserved_at: unscheduled_reserved_at,
+ buddy_id: users(:komagata).id
+ }
+ pair_work = pair_works(:pair_work1)
+
+ assert_not pair_work.reserve(invalid_params_reserved_at)
+ assert_includes pair_work.errors.full_messages, '希望した日時は提案されたスケジュールに含まれていません'
+ end
+end
diff --git a/test/models/searcher/configuration_test.rb b/test/models/searcher/configuration_test.rb
index 3a94fe3103f..e25b708ea19 100644
--- a/test/models/searcher/configuration_test.rb
+++ b/test/models/searcher/configuration_test.rb
@@ -19,6 +19,7 @@ class Searcher::ConfigurationTest < ActiveSupport::TestCase
assert_includes configs.keys, :comment
assert_includes configs.keys, :event
assert_includes configs.keys, :regular_event
+ assert_includes configs.keys, :pair_work
end
test 'get returns specific configuration' do
@@ -50,7 +51,7 @@ class Searcher::ConfigurationTest < ActiveSupport::TestCase
test 'available_types returns all configuration keys' do
types = Searcher::Configuration.available_types
- expected_types = %i[practice user report product announcement page question answer correct_answer comment event regular_event]
+ expected_types = %i[practice user report product announcement page question answer correct_answer comment event regular_event pair_work]
assert_equal expected_types.sort, types.sort
end
@@ -74,6 +75,7 @@ class Searcher::ConfigurationTest < ActiveSupport::TestCase
assert_includes options, ['コメント', :comment]
assert_includes options, ['イベント', :event]
assert_includes options, ['定期イベント', :regular_event]
+ assert_includes options, ['ペアワーク', :pair_work]
end
test 'configurations have required keys' do
diff --git a/test/models/searcher_test.rb b/test/models/searcher_test.rb
index 397ca645578..ae822cd0ba5 100644
--- a/test/models/searcher_test.rb
+++ b/test/models/searcher_test.rb
@@ -411,7 +411,7 @@ def strip_html(text)
end
test 'available_types returns all configured types' do
- expected_types = %i[practice user report product announcement page question answer correct_answer comment event regular_event]
+ expected_types = %i[practice user report product announcement page question answer correct_answer comment event regular_event pair_work]
assert_equal expected_types.sort, Searcher::Configuration.available_types.sort
end
diff --git a/test/system/notification/pair_works_test.rb b/test/system/notification/pair_works_test.rb
new file mode 100644
index 00000000000..8c70511524d
--- /dev/null
+++ b/test/system/notification/pair_works_test.rb
@@ -0,0 +1,90 @@
+# frozen_string_literal: true
+
+require 'notification_system_test_case'
+
+class Notification::PairWorksTest < NotificationSystemTestCase
+ setup do
+ @delivery_mode = AbstractNotifier.delivery_mode
+ AbstractNotifier.delivery_mode = :normal
+ end
+
+ teardown do
+ AbstractNotifier.delivery_mode = @delivery_mode
+ end
+
+ test 'mentor receive notification when pair_work is posted' do
+ travel_to Time.zone.local(2025, 3, 2, 0, 0, 0) do
+ visit_with_auth '/pair_works/new', 'kimura'
+ within 'form[name=pair_work]' do
+ fill_in 'pair_work[title]', with: 'テストのペアワーク募集'
+ fill_in 'pair_work[description]', with: 'テストのペアワーク募集です。'
+ within '.form-table' do
+ check 'schedule_ids_202503030000', allow_label_click: true
+ end
+ end
+
+ click_button '登録する'
+ assert_text 'ペアワークを作成しました。'
+
+ assert_user_has_notification(user: users(:mentormentaro), kind: Notification.kinds[:came_pair_work], text: 'kimuraさんからペアワーク依頼「テストのペアワーク募集」が投稿されました。')
+ end
+ end
+
+ test 'watcher receive notification when pair_work is matched' do
+ travel_to Time.zone.local(2025, 3, 2, 0, 0, 0) do
+ visit_with_auth '/pair_works/new', 'kimura'
+ within 'form[name=pair_work]' do
+ fill_in 'pair_work[title]', with: 'テストのペアワーク募集'
+ fill_in 'pair_work[description]', with: 'テストのペアワーク募集です。'
+ within '.form-table' do
+ check 'schedule_ids_202503030000', allow_label_click: true
+ end
+ end
+ click_button '登録する'
+ assert_text 'ペアワークを作成しました。'
+ assert_text 'Watch中'
+ logout
+
+ visit_with_auth pair_works_path(target: 'not_solved'), 'mentormentaro'
+ click_on 'テストのペアワーク募集'
+ assert_text 'Watch中'
+ within '.a-table' do
+ accept_alert do
+ find_button(id: '2025-03-03T00:00:00+09:00').click
+ end
+ end
+ assert_text 'ペアが確定しました。'
+
+ assert_user_has_notification(user: users(:kimura), kind: Notification.kinds[:matching_pair_work],
+ text: 'kimuraさんのペアワーク【 テストのペアワーク募集 】のペアがmentormentaroさんに決定しました。')
+ assert_user_has_notification(user: users(:mentormentaro), kind: Notification.kinds[:matching_pair_work],
+ text: 'kimuraさんのペアワーク【 テストのペアワーク募集 】のペアがあなたに決定しました。')
+ end
+ end
+
+ test 'notify when a WIP pair_work is published' do
+ travel_to Time.zone.local(2025, 3, 2, 0, 0, 0) do
+ visit_with_auth '/pair_works/new', 'kimura'
+ within 'form[name=pair_work]' do
+ fill_in 'pair_work[title]', with: 'WIPで保存時は通知が飛ばない'
+ fill_in 'pair_work[description]', with: 'WIPで保存時は通知が飛ばない'
+ within '.form-table' do
+ check 'schedule_ids_202503030000', allow_label_click: true
+ end
+ end
+ click_button 'WIP'
+ assert_text 'ペアワークをWIPとして保存しました。'
+ assert_user_has_no_notification(user: users(:mentormentaro), kind: Notification.kinds[:came_pair_work],
+ text: 'WIPで保存時は通知が飛ばない')
+
+ within 'form[name=pair_work]' do
+ fill_in 'pair_work[title]', with: '公開された時に通知が飛ぶ'
+ fill_in 'pair_work[description]', with: '公開された時に通知が飛ぶ'
+ end
+ click_button 'ペアワークを公開'
+ assert_text 'ペアワークを更新しました。'
+
+ assert_user_has_notification(user: users(:mentormentaro), kind: Notification.kinds[:came_pair_work], text: '公開された時に通知が飛ぶ')
+ end
+ end
+end
diff --git a/test/system/pair_works_test.rb b/test/system/pair_works_test.rb
new file mode 100644
index 00000000000..6221c2b8361
--- /dev/null
+++ b/test/system/pair_works_test.rb
@@ -0,0 +1,127 @@
+# frozen_string_literal: true
+
+require 'application_system_test_case'
+
+class PairWorksTest < ApplicationSystemTestCase
+ test 'show listing unsolved pair works' do
+ visit_with_auth pair_works_path(target: 'not_solved'), 'kimura'
+ assert_equal '募集中のペアワーク | FBC', title
+ end
+
+ test 'show listing solved pair works' do
+ visit_with_auth pair_works_path(target: 'solved'), 'kimura'
+ assert_equal 'ペア確定済みのペアワーク | FBC', title
+ end
+
+ test 'show listing all pair works' do
+ visit_with_auth pair_works_path, 'kimura'
+ assert_equal '全てのペアワーク | FBC', title
+ end
+
+ test 'create a pair_work' do
+ travel_to Time.zone.local(2025, 3, 2, 0, 0, 0) do
+ visit_with_auth new_pair_work_path, 'kimura'
+ within 'form[name=pair_work]' do
+ within '.select-practices' do
+ find('.choices__inner').click
+ find('#choices--js-choices-practice-item-choice-12', text: 'sshdでパスワード認証を禁止にする').click
+ end
+ fill_in 'pair_work[title]', with: 'テストのペアワーク募集'
+ fill_in 'pair_work[description]', with: 'テストのペアワーク募集です。'
+ within '.form-table' do
+ check 'schedule_ids_202503030000', allow_label_click: true
+ end
+ click_button '登録する'
+ end
+ assert_text 'ペアワークを作成しました。'
+ assert_selector '.a-title-label.is-solved.is-danger', text: '募集中'
+ assert_text 'Watch中'
+
+ visit_with_auth pair_works_path(target: 'not_solved'), 'mentormentaro'
+ click_on 'テストのペアワーク募集'
+ assert_text 'Watch中'
+ end
+ end
+
+ test 'create a pair_work matching' do
+ travel_to Time.zone.local(2025, 1, 1, 0, 0, 0) do
+ pair_work = pair_works(:pair_work1)
+ visit_with_auth pair_work_path(pair_work), 'mentormentaro'
+ within '.a-table' do
+ accept_alert do
+ find_button(id: '2025-01-01T00:00:00+09:00').click
+ end
+ end
+ assert_selector '.a-title-label.is-solved.is-success', text: 'ペア確定'
+ within 'header.event-main-actions__header' do
+ assert_text 'ペアが確定しました'
+ assert_selector 'a', text: 'カレンダーに登録'
+ end
+ within '.event-main-actions__body' do
+ assert_selector "img[title*='mentormentaro']"
+ assert_selector 'a', text: 'mentormentaro (メンタ メンタロウ)'
+ assert_text '2025年01月01日(水) 00:00'
+ assert_text 'ペアワーク・モブワーク1'
+ end
+ end
+ end
+
+ test 'cannot create a pair_work matching with a past date' do
+ travel_to Time.zone.local(2025, 1, 10, 0, 0, 0) do
+ pair_work = pair_works(:pair_work1)
+ visit_with_auth pair_work_path(pair_work), 'komagata'
+ assert_text '募集中'
+ within '.a-table' do
+ find_button(id: '2025-01-01T00:00:00+09:00', disabled: true)
+ end
+ end
+ end
+
+ test 'update a pair_work' do
+ pair_work = pair_works(:pair_work1)
+ visit_with_auth pair_work_path(pair_work), 'kimura'
+ click_link '内容修正'
+ fill_in 'pair_work[title]', with: 'ペアワークのテスト(修正)'
+ fill_in 'pair_work[description]', with: 'ペアワークのテストです。(修正)'
+ within '.select-practices' do
+ find('.choices__inner').click
+ find('#choices--js-choices-practice-item-choice-12', text: 'sshdでパスワード認証を禁止にする').click
+ end
+ click_button '更新する'
+
+ assert_text 'ペアワークを更新しました。'
+ assert_text 'ペアワークのテスト(修正)'
+ assert_text 'ペアワークのテストです。(修正)'
+ assert_selector 'a.a-category-link', text: 'sshdでパスワード認証を禁止にする'
+ end
+
+ test 'delete a pair_work' do
+ pair_work = pair_works(:pair_work1)
+ visit_with_auth pair_work_path(pair_work), 'kimura'
+ accept_confirm do
+ click_link '削除'
+ end
+
+ assert_text 'ペアワークを削除しました。'
+ assert_equal '全てのペアワーク | FBC', title
+ end
+
+ test 'only authorized users can update and delete pair_works' do
+ pair_work = pair_works(:pair_work1)
+ pair_work_user = 'kimura'
+ admin_user = 'komagata'
+ user = 'hatsuno'
+
+ visit_with_auth pair_work_path(pair_work), pair_work_user
+ assert_text '内容修正'
+ assert_text '削除'
+
+ visit_with_auth pair_work_path(pair_work), admin_user
+ assert_text '内容修正'
+ assert_text '削除'
+
+ visit_with_auth pair_work_path(pair_work), user
+ assert_no_text '内容修正'
+ assert_no_text '削除'
+ end
+end