diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 7a041441082..4fef301af23 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -102,6 +102,7 @@ @import "./application/blocks/user/_completed-practices-progress.css"; @import "./application/blocks/user/_following.css"; @import "./application/blocks/user/_group-company-name.css"; +@import "./application/blocks/user/_mentor-memos.css"; @import "./application/blocks/user/_niconico-calendar.css"; @import "./application/blocks/user/_sns-links.css"; @import "./application/blocks/user/_user-data.css"; diff --git a/app/assets/stylesheets/application/blocks/user/_mentor-memos.css b/app/assets/stylesheets/application/blocks/user/_mentor-memos.css new file mode 100644 index 00000000000..f13aa717d7d --- /dev/null +++ b/app/assets/stylesheets/application/blocks/user/_mentor-memos.css @@ -0,0 +1,82 @@ +.mentor-memo-list { + display: flex; + flex-direction: column; +} + +.mentor-memo { + font-size: 0.875rem; + line-height: 1.6; +} + +.mentor-memo__inner { + padding: 1rem; +} + +@media (width <= 47.9375em) { + .mentor-memo__inner { + padding: 0.75rem; + } +} + +.mentor-memo:not(:last-child) { + border-bottom: solid 1px var(--border-tint); +} + +.mentor-memo__meta { + display: flex; + align-items: center; + gap: 0.375rem; + margin-bottom: 0.5rem; +} + +.mentor-memo__writer-link { + display: inline-flex; + flex: 0 0 auto; + line-height: 1; +} + +.mentor-memo__writer { + font-size: 0.875rem; + line-height: 1.4; + font-weight: 600; +} + +.mentor-memo__created-at { + margin-left: auto; + font-size: 0.75rem; + line-height: 1.4; + white-space: nowrap; + color: var(--muted-text); +} + +.mentor-memo__body { + margin-bottom: 0.5rem; +} + +.mentor-memo__actions { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.mentor-memo__action { + font-size: 0.75rem; + line-height: 1; + display: inline-flex; + align-items: center; + gap: 0.25rem; + cursor: pointer; + background-color: transparent; + border: none; + padding: 0; + color: var(--muted-text); + transition: color 0.2s ease-in; +} + +.mentor-memo__action:hover { + color: var(--link-text); +} + +.mentor-memo__action.is-danger:hover { + color: var(--danger); +} diff --git a/app/controllers/api/mentor_memos_controller.rb b/app/controllers/api/mentor_memos_controller.rb index 8d94f15fcd4..b8ff2647ed1 100644 --- a/app/controllers/api/mentor_memos_controller.rb +++ b/app/controllers/api/mentor_memos_controller.rb @@ -2,25 +2,55 @@ class API::MentorMemosController < API::BaseController before_action :require_mentor_login_for_api - before_action -> { doorkeeper_authorize! :write }, only: %i[update], if: -> { doorkeeper_token.present? } - before_action -> { doorkeeper_authorize! :mentor }, only: %i[update], if: -> { doorkeeper_token.present? } - before_action :set_user, only: %i[update] + before_action -> { doorkeeper_authorize! :write }, only: %i[create update destroy], if: -> { doorkeeper_token.present? } + before_action -> { doorkeeper_authorize! :mentor }, only: %i[create update destroy], if: -> { doorkeeper_token.present? } + before_action :set_user, only: %i[create update destroy] + before_action :set_memo, only: %i[update destroy] + before_action :authorize_memo, only: %i[update destroy] + + def create + memo = MentorMemo.new(mentor_memo_params) + memo.writer = current_user + memo.recipient = @user + + if memo.save + render partial: 'users/mentor_memo', locals: { memo: }, status: :created + else + head :bad_request + end + end def update - if @user.update_mentor_memo(user_params[:mentor_memo]) + if @mentor_memo.update(mentor_memo_params) head :ok else head :bad_request end end + def destroy + @mentor_memo.destroy + + head :no_content + end + private def set_user - @user = User.find(params[:id]) + @user = User.find(params[:user_id]) + end + + def set_memo + @mentor_memo = @user.received_memos.find(params[:id]) end - def user_params - params.require(:user).permit(:mentor_memo) + def mentor_memo_params + params.require(:mentor_memo).permit(:body) + end + + def authorize_memo + return if @mentor_memo.writer == current_user || current_user.admin? + + head :forbidden end end diff --git a/app/controllers/products_controller.rb b/app/controllers/products_controller.rb index 0b4df8b93e9..ec8253d3805 100644 --- a/app/controllers/products_controller.rb +++ b/app/controllers/products_controller.rb @@ -28,6 +28,7 @@ def show Footprint.find_or_create_for(@product, current_user) @footprints = Footprint.fetch_for_resource(@product) @comments = @product.comments.order(:created_at) + @mentor_memos = @product.user.received_memos.includes(:writer).order(created_at: :desc) respond_to do |format| format.html format.md diff --git a/app/controllers/reports_controller.rb b/app/controllers/reports_controller.rb index f0fb7ed671c..e3b67bd084f 100644 --- a/app/controllers/reports_controller.rb +++ b/app/controllers/reports_controller.rb @@ -25,6 +25,7 @@ def show @recent_reports = Report.list.where(user_id: @report.user.id).limit(10) Footprint.find_or_create_for(@report, current_user) @footprints = Footprint.fetch_for_resource(@report) + @mentor_memos = @report.user.received_memos.includes(:writer).order(created_at: :desc) respond_to do |format| format.html format.md diff --git a/app/controllers/talks_controller.rb b/app/controllers/talks_controller.rb index 31534a88ec9..481ad8d05e2 100644 --- a/app/controllers/talks_controller.rb +++ b/app/controllers/talks_controller.rb @@ -32,6 +32,7 @@ def index def show @comments = @talk.comments.order(:created_at) @reports = @user.reports.list.limit(20) + @mentor_memos = @user.received_memos.includes(:writer).order(created_at: :desc) end private diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 82f06aaa98b..8307c47332f 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -48,6 +48,8 @@ def show reports = @user.reports_with_learning_times @study_streak = StudyStreak.new(reports, include_wip: false) + @mentor_memos = @user.received_memos.includes(:writer).order(created_at: :desc) + if logged_in? render :show else diff --git a/app/javascript/importmap_application.js b/app/javascript/importmap_application.js index 692368516d5..1c106e25421 100644 --- a/app/javascript/importmap_application.js +++ b/app/javascript/importmap_application.js @@ -49,7 +49,7 @@ import 'upload-image-to-article' import 'header-dropdown' import 'postal-code-address' import 'editor-selection-form' -import 'user_mentor_memo' +import 'mentor-memos' import 'invitation-url-updater' import 'movie-fileinput' import 'payment-methods-check-boxes' diff --git a/app/javascript/initializeMentorMemo.js b/app/javascript/initializeMentorMemo.js new file mode 100644 index 00000000000..9075f244267 --- /dev/null +++ b/app/javascript/initializeMentorMemo.js @@ -0,0 +1,134 @@ +import TextareaInitializer from 'textarea-initializer' +import MarkdownInitializer from 'markdown-initializer' +import { put, destroy } from '@rails/request.js' + +export default function initializeMemo(memo, userId) { + const memoId = memo.dataset.memo_id + const memoBody = memo.dataset.memo_body + let savedMemo = memoBody || '' + + TextareaInitializer.initialize(`#js-memo-${memoId}`) + const markdownInitializer = new MarkdownInitializer() + + const memoDisplay = memo.querySelector('.memo-display') + const memoDisplayContent = memo.querySelector('.memo-text') + + const memoEditor = memo.querySelector('.memo-editor') + const editorTextarea = memo.querySelector('.a-markdown-input__textarea') + const editorPreview = memo.querySelector('.a-markdown-input__preview') + + if (memoBody) { + memoDisplayContent.innerHTML = markdownInitializer.render(savedMemo) + editorPreview.innerHTML = markdownInitializer.render(savedMemo) + } + + const editButton = memo.querySelector('.js-edit-memo') + if (editButton) { + editButton.addEventListener('click', () => { + toggleEditor() + resetEditorTabs() + }) + } + + const saveButton = memo.querySelector('.js-save-memo') + editorTextarea.addEventListener('input', () => { + saveButton.disabled = editorTextarea.value.trim().length === 0 + }) + + saveButton.addEventListener('click', async () => { + try { + const editedMemo = editorTextarea.value + await updateMemo(memoId, editedMemo, userId) + savedMemo = editedMemo + toggleEditor() + memoDisplayContent.innerHTML = markdownInitializer.render(savedMemo) + } catch (error) { + console.warn(error) + } + }) + + const cancelButton = memo.querySelector('.js-cancel-memo') + cancelButton.addEventListener('click', () => { + toggleEditor() + editorTextarea.value = savedMemo + editorPreview.innerHTML = markdownInitializer.render(savedMemo) + saveButton.disabled = editorTextarea.value.trim().length === 0 + }) + + const deleteButton = memo.querySelector('.js-delete-memo') + if (deleteButton) { + deleteButton.addEventListener('click', async () => { + if (window.confirm('本当によろしいですか?')) { + try { + await deleteMemo(memoId, userId) + memo.remove() + + const mentorMemos = document.querySelector('.mentor-memos') + const emptyMessage = mentorMemos.querySelector('.o-empty-message') + const memoList = mentorMemos.querySelectorAll('.mentor-memo') + + if (!memoList.length) emptyMessage.classList.remove('is-hidden') + } catch (error) { + console.warn(error) + } + } + }) + } + + const editorTab = memoEditor.querySelector('.editor-tab') + const editorTabContent = memoEditor.querySelector('.is-editor') + const previewTab = memoEditor.querySelector('.preview-tab') + const previewTabContent = memoEditor.querySelector('.is-preview') + + const tabElements = [ + editorTab, + editorTabContent, + previewTab, + previewTabContent + ] + editorTab.addEventListener('click', () => + toggleClass(tabElements, 'is-active') + ) + previewTab.addEventListener('click', () => { + editorPreview.innerHTML = markdownInitializer.render(editorTextarea.value) + toggleClass(tabElements, 'is-active') + }) + + async function updateMemo(memoId, memoContent, userId) { + const params = { + mentor_memo: { + body: memoContent + } + } + const response = await put(`/api/users/${userId}/mentor_memos/${memoId}`, { + body: params + }) + + if (!response.ok) throw new Error('Failed to update') + } + + async function deleteMemo(memoId, userId) { + const response = await destroy( + `/api/users/${userId}/mentor_memos/${memoId}` + ) + + if (!response.ok) throw new Error('Failed to delete') + } + + function toggleEditor() { + toggleClass([memoDisplay, memoEditor], 'is-hidden') + } + + function toggleClass(elements, className) { + elements.forEach((element) => { + element.classList.toggle(className) + }) + } + + function resetEditorTabs() { + editorTab.classList.add('is-active') + editorTabContent.classList.add('is-active') + previewTab.classList.remove('is-active') + previewTabContent.classList.remove('is-active') + } +} diff --git a/app/javascript/initializeNewMentorMemo.js b/app/javascript/initializeNewMentorMemo.js new file mode 100644 index 00000000000..658d60e4cb1 --- /dev/null +++ b/app/javascript/initializeNewMentorMemo.js @@ -0,0 +1,128 @@ +import TextareaInitializer from 'textarea-initializer' +import MarkdownInitializer from 'markdown-initializer' +import initializeMemo from './initializeMentorMemo' +import { post } from '@rails/request.js' + +export default function initializeNewMemo(userId) { + TextareaInitializer.initialize('#js-new-memo') + const markdownInitializer = new MarkdownInitializer() + + const mentorMemo = document.querySelector('.mentor-memos') + + const memoDisplay = mentorMemo.querySelector('.memos-display') + const memoEditor = mentorMemo.querySelector('.new-memo-editor') + + const memoEditorPreview = memoEditor.querySelector( + '.a-markdown-input__preview' + ) + const editorTextarea = memoEditor.querySelector('.a-markdown-input__textarea') + + const addButton = memoDisplay.querySelector('.js-add-memo') + const addContainer = memoDisplay.querySelector('.js-add-action') + const modalElements = [addContainer, memoEditor] + addButton.addEventListener('click', () => { + toggleClass(modalElements, 'is-hidden') + resetEditorTabs() + editorTextarea.focus() + }) + + const saveButton = memoEditor.querySelector('.js-save-memo') + saveButton.disabled = true + editorTextarea.addEventListener('input', () => { + saveButton.disabled = editorTextarea.value.trim().length === 0 + }) + + const emptyMessage = memoDisplay.querySelector('.o-empty-message') + saveButton.addEventListener('click', async () => { + const memoContent = editorTextarea.value + const html = await createMemo(memoContent, userId) + if (!html) return + + const newMemo = addNewMemo(html, userId) + newMemo.scrollIntoView() + closeEditor() + + if (emptyMessage) { + emptyMessage.classList.add('is-hidden') + } + }) + + const cancelButton = memoEditor.querySelector('.js-cancel-memo') + cancelButton.addEventListener('click', () => { + closeEditor() + }) + + const editorTab = memoEditor.querySelector('.editor-tab') + const editorTabContent = memoEditor.querySelector('.is-editor') + const previewTab = memoEditor.querySelector('.preview-tab') + const previewTabContent = memoEditor.querySelector('.is-preview') + + const tabElements = [ + editorTab, + editorTabContent, + previewTab, + previewTabContent + ] + + editorTab.addEventListener('click', () => + toggleClass(tabElements, 'is-active') + ) + previewTab.addEventListener('click', () => { + memoEditorPreview.innerHTML = markdownInitializer.render( + editorTextarea.value + ) + toggleClass(tabElements, 'is-active') + }) + + function resetEditorTabs() { + editorTab.classList.add('is-active') + editorTabContent.classList.add('is-active') + previewTab.classList.remove('is-active') + previewTabContent.classList.remove('is-active') + } + + function closeEditor() { + toggleClass(modalElements, 'is-hidden') + editorTextarea.value = '' + memoEditorPreview.innerHTML = '' + saveButton.disabled = true + } +} + +async function createMemo(memo, userId) { + const params = { + mentor_memo: { + body: memo + } + } + try { + const response = await post(`/api/users/${userId}/mentor_memos/`, { + body: params + }) + + if (!response.ok) throw new Error('Failed to fetch') + + return await response.text + } catch (error) { + console.warn(error) + + return false + } +} + +function addNewMemo(html, userId) { + const memoList = document.querySelector('.mentor-memo-list') + const memoDiv = document.createElement('div') + memoDiv.innerHTML = html + const newMemoElement = memoDiv.firstElementChild + memoList.prepend(newMemoElement) + initializeMemo(newMemoElement, userId) + + return newMemoElement +} + +function toggleClass(elements, className) { + elements.forEach((element) => { + element.classList.toggle(className) + }) +} diff --git a/app/javascript/mentor-memos.js b/app/javascript/mentor-memos.js new file mode 100644 index 00000000000..a5e68130196 --- /dev/null +++ b/app/javascript/mentor-memos.js @@ -0,0 +1,21 @@ +import initializeMemo from './initializeMentorMemo' +import initializeNewMemo from './initializeNewMentorMemo' + +document.addEventListener('DOMContentLoaded', () => { + const mentorMemo = document.querySelector('.mentor-memos') + if (!mentorMemo) return + + const userId = mentorMemo.dataset.user_id + + const memos = mentorMemo.querySelectorAll('.mentor-memo') + const emptyMessage = mentorMemo.querySelector('.o-empty-message') + if (memos.length) { + memos.forEach((memo) => { + initializeMemo(memo, userId) + }) + } else { + emptyMessage.classList.remove('is-hidden') + } + + initializeNewMemo(userId) +}) diff --git a/app/javascript/user_mentor_memo.js b/app/javascript/user_mentor_memo.js deleted file mode 100644 index c124627f747..00000000000 --- a/app/javascript/user_mentor_memo.js +++ /dev/null @@ -1,147 +0,0 @@ -import CSRF from 'csrf' -import TextareaInitializer from 'textarea-initializer' -import MarkdownInitializer from 'markdown-initializer' - -document.addEventListener('DOMContentLoaded', () => { - const mentorMemo = document.querySelector('.user-mentor-memo') - if (mentorMemo) { - const markdownInitializer = new MarkdownInitializer() - const userId = mentorMemo.dataset.user_id - let savedMemo = '' - - const memoDisplay = mentorMemo.querySelector('.memo-display') - const memoEditor = mentorMemo.querySelector('.memo-editor') - - const placeholder = memoDisplay.querySelector('.a-placeholder') - const emptyMessage = memoDisplay.querySelector('.o-empty-message') - const memoDisplayContent = memoDisplay.querySelector( - '.user-mentor-memo-content' - ) - const memoEditorPreview = memoEditor.querySelector( - '.a-markdown-input__preview' - ) - const editorTextarea = memoEditor.querySelector( - '.a-markdown-input__textarea' - ) - - fetch(`/api/users/${userId}.json`, { - method: 'GET', - headers: { - 'X-Requested-With': 'XMLHttpRequest' - }, - credentials: 'same-origin', - redirect: 'manual' - }) - .then((response) => { - return response.json() - }) - .then((json) => { - if (json.mentor_memo) { - savedMemo = json.mentor_memo - } - placeholder.classList.add('is-hidden') - if (savedMemo.length === 0) { - emptyMessage.classList.remove('is-hidden') - } else { - memoDisplayContent.classList.remove('is-hidden') - editorTextarea.value = savedMemo - switchMemoDisplay(memoDisplay, savedMemo) - TextareaInitializer.initialize('#js-user-mentor-memo') - memoDisplayContent.innerHTML = markdownInitializer.render(savedMemo) - memoEditorPreview.innerHTML = markdownInitializer.render(savedMemo) - } - }) - .catch((error) => { - console.warn(error) - }) - - const editButton = memoDisplay.querySelector('.card-footer-actions__action') - const modalElements = [memoDisplay, memoEditor] - editButton.addEventListener('click', () => - toggleClass(modalElements, 'is-hidden') - ) - - const saveButton = memoEditor.querySelector('.is-primary') - saveButton.addEventListener('click', () => { - toggleClass(modalElements, 'is-hidden') - savedMemo = editorTextarea.value - updateMemo(savedMemo, userId) - memoDisplayContent.innerHTML = markdownInitializer.render(savedMemo) - TextareaInitializer.initialize('#js-user-mentor-memo') - switchMemoDisplay(memoDisplay, savedMemo) - }) - - const cancelButton = memoEditor.querySelector('.is-secondary') - cancelButton.addEventListener('click', () => { - toggleClass(modalElements, 'is-hidden') - editorTextarea.value = savedMemo - memoEditorPreview.innerHTML = markdownInitializer.render(savedMemo) - TextareaInitializer.initialize('#js-user-mentor-memo') - }) - - editorTextarea.addEventListener('change', () => { - memoEditorPreview.innerHTML = markdownInitializer.render( - editorTextarea.value - ) - TextareaInitializer.initialize('#js-user-mentor-memo') - }) - - const editorTab = memoEditor.querySelector('.editor-tab') - const editorTabContent = memoEditor.querySelector('.is-editor') - const previewTab = memoEditor.querySelector('.preview-tab') - const previewTabContent = memoEditor.querySelector('.is-preview') - - const tabElements = [ - editorTab, - editorTabContent, - previewTab, - previewTabContent - ] - editorTab.addEventListener('click', () => - toggleClass(tabElements, 'is-active') - ) - previewTab.addEventListener('click', () => - toggleClass(tabElements, 'is-active') - ) - } -}) - -function updateMemo(memo, userId) { - const params = { - user: { - mentor_memo: memo - } - } - fetch(`/api/mentor_memos/${userId}`, { - method: 'PUT', - headers: { - 'X-Requested-With': 'XMLHttpRequest', - 'Content-Type': 'application/json; charset=utf-8', - 'X-CSRF-Token': CSRF.getToken() - }, - credentials: 'same-origin', - redirect: 'manual', - body: JSON.stringify(params) - }) - .then((response) => { - return response - }) - .catch((error) => { - console.warn(error) - }) -} - -function switchMemoDisplay(memoDisplay, memo) { - const memoDisplayContent = memoDisplay.querySelector( - '.user-mentor-memo-content' - ) - const emptyMessage = memoDisplay.querySelector('.o-empty-message') - memoDisplayContent.classList.toggle('is-hidden', memo.length === 0) - emptyMessage.classList.toggle('is-hidden', memo.length !== 0) -} - -function toggleClass(elements, className) { - elements.forEach((element) => { - element.classList.toggle(className) - }) -} diff --git a/app/models/mentor_memo.rb b/app/models/mentor_memo.rb new file mode 100644 index 00000000000..501df03d91d --- /dev/null +++ b/app/models/mentor_memo.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class MentorMemo < ApplicationRecord + belongs_to :recipient, class_name: 'User' + belongs_to :writer, class_name: 'User' + + validates :body, presence: true +end diff --git a/app/models/user.rb b/app/models/user.rb index 353b06f6320..512096f2389 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -214,6 +214,18 @@ class User < ApplicationRecord # rubocop:todo Metrics/ClassLength dependent: :delete_all, inverse_of: 'user' + has_many :received_memos, + class_name: 'MentorMemo', + foreign_key: 'recipient_id', + inverse_of: :recipient, + dependent: :destroy + + has_many :authored_memos, + class_name: 'MentorMemo', + foreign_key: 'writer_id', + inverse_of: :writer, + dependent: :destroy + has_one_attached :avatar has_one_attached :profile_image has_one_attached :diploma_file diff --git a/app/views/products/show.html.slim b/app/views/products/show.html.slim index 6bbeb7caa13..ee52d3ab136 100644 --- a/app/views/products/show.html.slim +++ b/app/views/products/show.html.slim @@ -116,7 +116,7 @@ header.page-header = render partial: 'reports/recent_reports', locals: { header_display: true } .side-tabs-contents__item#side-tabs-content-2 - = render 'users/user_mentor_memo', user_id: @product.user.id + = render 'users/user_mentor_memos', user_id: @product.user.id, memos: @mentor_memos .side-tabs-contents__item#side-tabs-content-3 .a-card .user-data diff --git a/app/views/reports/show.html.slim b/app/views/reports/show.html.slim index f9be55fdbbb..91d2190b7e2 100644 --- a/app/views/reports/show.html.slim +++ b/app/views/reports/show.html.slim @@ -70,7 +70,7 @@ .side-tabs-contents__item#side-tabs-content-1 = render partial: 'reports/recent_reports', locals: { header_display: false } .side-tabs-contents__item#side-tabs-content-2.is-only-mentor - = render 'users/user_mentor_memo', user_id: @report.user_id + = render 'users/user_mentor_memos', user_id: @report.user_id, memos: @mentor_memos .side-tabs-contents__item#side-tabs-content-3.is-only-mentor .a-card .user-data diff --git a/app/views/talks/show.html.slim b/app/views/talks/show.html.slim index 0b0c12c2b80..fde136f3ce0 100644 --- a/app/views/talks/show.html.slim +++ b/app/views/talks/show.html.slim @@ -81,7 +81,7 @@ .a-card .user-data .user-data__row - = render 'users/user_mentor_memo', user_id: @user.id + = render 'users/user_mentor_memos', user_id: @user.id, memos: @mentor_memos .user-data__row = render 'users/user_secret_attributes', user: @user .user-data__row diff --git a/app/views/users/_mentor_memo.html.slim b/app/views/users/_mentor_memo.html.slim new file mode 100644 index 00000000000..414c331f976 --- /dev/null +++ b/app/views/users/_mentor_memo.html.slim @@ -0,0 +1,43 @@ +.mentor-memo data-memo_body="#{memo.body}" data-memo_id="#{memo.id}" + .memo-display + .mentor-memo__inner + .mentor-memo__meta + = render 'users/icon', user: memo.writer, image_class: 'mentor-memo__writer-icon is-xs', link_class: 'mentor-memo__writer-link' + a.mentor-memo__writer.a-text-link href="#{memo.writer.url}" + = memo.writer.login_name + time.mentor-memo__created-at + = l memo.created_at + .a-long-text.is-sm.memo-text.mentor-memo__body + = memo.body + - if memo.writer == current_user || current_user.admin? + .mentor-memo__actions + button.mentor-memo__action.js-edit-memo + i.fa-solid.fa-pen + | 編集 + button.mentor-memo__action.is-danger.js-delete-memo + i.fa-solid.fa-trash-can + | 削除 + .memo-editor.is-hidden + .form-tabs.js-tabs + .form-tabs__tab.js-tabs__tab.editor-tab.is-active + | メモ + .form-tabs__tab.js-tabs__tab.preview-tab + | プレビュー + .card-body + .card-body__description + .a-markdown-input.js-markdown-parent + .a-markdown-input__inner.is-editor.js-tabs__content.is-active + textarea.a-text-input.a-markdown-input__textarea id="js-memo-#{memo.id}" data-preview="#mentor-memo-preview-#{memo.id}" name='mentor_memo[body]' + = memo.body + .a-markdown-input__inner.is-preview.js-tabs__content + .a-long-text.is-md.a-markdown-input__preview + hr.a-border-tint + .card-footer + .card-main-actions + .card-main-actions__items + .card-main-actions__item + button.a-button.is-sm.is-primary.is-block.js-save-memo + | 保存する + .card-main-actions__item + button.a-button.is-sm.is-secondary.is-block.js-cancel-memo + | キャンセル diff --git a/app/views/users/_user_mentor_memo.html.slim b/app/views/users/_user_mentor_memos.html.slim similarity index 55% rename from app/views/users/_user_mentor_memo.html.slim rename to app/views/users/_user_mentor_memos.html.slim index 1b6958ad946..5ca9a80915c 100644 --- a/app/views/users/_user_mentor_memo.html.slim +++ b/app/views/users/_user_mentor_memos.html.slim @@ -1,34 +1,28 @@ -section.a-card.is-memo.is-only-mentor.user-mentor-memo data-user_id=user_id - .memo-display +section.a-card.is-memo.is-only-mentor.mentor-memos data-user_id=user_id + .memos-display .description header.card-header.is-sm h2.card-header__title | メンター向けユーザーメモ hr.a-border-tint .card-body - .card-body__description - .a-long-text.is-sm.a-placeholder - p - p - p - p - p - p - .o-empty-message.is-hidden - .o-empty-message__icon - i.fa-regular.fa-sad-tear - .o-empty-message__text - | ユーザーメモはまだありません。 - .a-long-text.is-sm.user-mentor-memo-content.is-hidden + .mentor-memo-list + - memos.each do |memo| + = render partial: 'users/mentor_memo', locals: { memo: memo } + .o-empty-message.is-hidden + .o-empty-message__icon + i.fa-regular.fa-sad-tear + .o-empty-message__text + | ユーザーメモはまだありません。 hr.a-border-tint - footer.card-footer + footer.card-footer.js-add-action .card-main-actions .card-main-actions__items .card-main-actions__item - button.card-footer-actions__action.a-button.is-sm.is-secondary.is-block + button.a-button.is-sm.is-secondary.is-block.js-add-memo i.fa-solid.fa-pen - | 編集 - .memo-editor.is-hidden + | 追加 + .new-memo-editor.is-hidden .form-tabs.js-tabs .form-tabs__tab.js-tabs__tab.editor-tab.is-active | メモ @@ -38,7 +32,7 @@ section.a-card.is-memo.is-only-mentor.user-mentor-memo data-user_id=user_id .card-body__description .a-markdown-input.js-markdown-parent .a-markdown-input__inner.is-editor.js-tabs__content.is-active - textarea.a-text-input.a-markdown-input__textarea id='js-user-mentor-memo' data-preview='#user-mentor-memo-preview' name='user[memo]' + textarea.a-text-input.a-markdown-input__textarea id='js-new-memo' data-preview='#user-memo-preview' name='mentor_memo[body]' .a-markdown-input__inner.is-preview.js-tabs__content .a-long-text.is-md.a-markdown-input__preview hr.a-border-tint @@ -46,8 +40,8 @@ section.a-card.is-memo.is-only-mentor.user-mentor-memo data-user_id=user_id .card-main-actions .card-main-actions__items .card-main-actions__item - button.a-button.is-sm.is-primary.is-block + button.a-button.is-sm.is-primary.is-block.js-save-memo | 保存する .card-main-actions__item - button.a-button.is-sm.is-secondary.is-block + button.a-button.is-sm.is-secondary.is-block.js-cancel-memo | キャンセル diff --git a/app/views/users/show.html.slim b/app/views/users/show.html.slim index 99a04dbf9ac..197f4f395e5 100644 --- a/app/views/users/show.html.slim +++ b/app/views/users/show.html.slim @@ -122,7 +122,7 @@ .o-empty-message__text | 進路メモはまだありません。 - if admin_or_mentor_login? - = render 'users/user_mentor_memo', user_id: @user.id + = render 'users/user_mentor_memos', user_id: @user.id, memos: @mentor_memos - if visible_learning_time_frames?(@user) .col-xs-12.col-lg-4.col-xxl-3 diff --git a/config/routes/api.rb b/config/routes/api.rb index c3e5b0f57ec..ed4569e2324 100644 --- a/config/routes/api.rb +++ b/config/routes/api.rb @@ -40,6 +40,7 @@ resources :mention_users, only: %i(index) resources :users, only: %i(index show update) do resource :support_context, only: %i(show), controller: 'users/support_contexts' + resources :mentor_memos, only: %i(create update destroy) end get "users/tags/:tag", to: "users#index", as: :users_tag, tag: /.+/ resources :practices, only: %i(index show update) do @@ -66,7 +67,6 @@ namespace 'watches' do resources :toggle, only: %i(index) end - resources :mentor_memos, only: %i(update) resources :tags, only: %i(index update) resources :pages, only: %i(update) resources :questions, only: %i(index show update) diff --git a/db/data/20260622000124_copy_user_mentor_memo_column_to_mentor_memos.rb b/db/data/20260622000124_copy_user_mentor_memo_column_to_mentor_memos.rb new file mode 100644 index 00000000000..7ac32fff9d1 --- /dev/null +++ b/db/data/20260622000124_copy_user_mentor_memo_column_to_mentor_memos.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class CopyUserMentorMemoColumnToMentorMemos < ActiveRecord::Migration[8.1] + def up + pjord = User.find_by!(login_name: "pjord") + User.where.not(mentor_memo: [nil, ""] ).find_each do |user| + MentorMemo.create!(recipient: user, writer: pjord, body: "【2026年6月以前のメモ】\n#{user.mentor_memo}") + end + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/db/migrate/20260126070916_create_mentor_memos.rb b/db/migrate/20260126070916_create_mentor_memos.rb new file mode 100644 index 00000000000..f9970173e07 --- /dev/null +++ b/db/migrate/20260126070916_create_mentor_memos.rb @@ -0,0 +1,11 @@ +class CreateMentorMemos < ActiveRecord::Migration[8.1] + def change + create_table :mentor_memos do |t| + t.references :recipient, null: false, foreign_key: { to_table: :users } + t.references :writer, null: false, foreign_key: { to_table: :users } + t.text :body, null: false + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 2653c7d8e5a..54772193bd6 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -456,6 +456,16 @@ t.index ["survey_question_id"], name: "index_linear_scales_on_survey_question_id" end + create_table "mentor_memos", force: :cascade do |t| + t.text "body", null: false + t.datetime "created_at", null: false + t.bigint "recipient_id", null: false + t.datetime "updated_at", null: false + t.bigint "writer_id", null: false + t.index ["recipient_id"], name: "index_mentor_memos_on_recipient_id" + t.index ["writer_id"], name: "index_mentor_memos_on_writer_id" + end + create_table "micro_reports", force: :cascade do |t| t.bigint "comment_user_id" t.text "content", null: false @@ -1149,6 +1159,8 @@ add_foreign_key "learning_time_frames_users", "users" add_foreign_key "learning_times", "reports" add_foreign_key "linear_scales", "survey_questions" + add_foreign_key "mentor_memos", "users", column: "recipient_id" + add_foreign_key "mentor_memos", "users", column: "writer_id" add_foreign_key "micro_reports", "users" add_foreign_key "micro_reports", "users", column: "comment_user_id" add_foreign_key "movies", "users" diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml index 91889968d96..98e6c1e3d3b 100644 --- a/test/fixtures/users.yml +++ b/test/fixtures/users.yml @@ -224,7 +224,6 @@ kimura: updated_at: "2014-01-01 00:00:07" created_at: "2014-01-01 00:00:07" last_activity_at: "2014-01-01 00:00:07" - mentor_memo: "kimuraさんのメモ" country_code: JP subdivision_code: '13' show_study_streak: true diff --git a/test/models/user_test.rb b/test/models/user_test.rb index 407f448daaa..58758ab7495 100644 --- a/test/models/user_test.rb +++ b/test/models/user_test.rb @@ -367,17 +367,6 @@ class UserTest < ActiveSupport::TestCase assert Following.find_by(follower_id: kimura.id, followed_id: hatsuno.id) end - test '#update_user_mentor_memo' do - user = users(:kimura) - assert_equal 'kimuraさんのメモ', user.mentor_memo - user.updated_at = Time.zone.local(2020, 1, 1, 0, 0, 0) - user.update_mentor_memo('新規メモ') - travel_to Time.zone.local(2020, 1, 1, 0, 0, 0) do - assert user.updated_at - end - assert_equal '新規メモ', user.mentor_memo - end - test '.delayed when there are users within 2 weeks from completion of last practice' do user = users(:nippounashi) practice1 = practices(:practice1) diff --git a/test/system/products/mentor_test.rb b/test/system/products/mentor_test.rb index 5ca1d1ba03b..a89854a4564 100644 --- a/test/system/products/mentor_test.rb +++ b/test/system/products/mentor_test.rb @@ -38,42 +38,6 @@ class MentorTest < ApplicationSystemTestCase end end - test 'display the user memos after click on user-memos tab' do - visit_with_auth "/products/#{products(:product2).id}", 'komagata' - find('#side-tabs-nav-2').click - assert_text 'kimuraさんのメモ' - end - - test 'can cancel editing of user-memos' do - visit_with_auth "/products/#{products(:product2).id}", 'komagata' - find('#side-tabs-nav-2').click - click_button '編集' - fill_in 'js-user-mentor-memo', with: '編集はできないはずです。' - click_button 'キャンセル' - assert_no_text '編集はできないはずです。' - assert_text 'kimuraさんのメモ' - end - - test 'can preview editing of user-memos' do - visit_with_auth "/products/#{products(:product2).id}", 'komagata' - find('#side-tabs-nav-2').click - assert_text 'kimuraさんのメモ' - click_button '編集' - fill_in 'js-user-mentor-memo', with: 'プレビューができます。' - find('.form-tabs__tab', text: 'プレビュー').click - assert_text 'プレビューができます。' - end - - test 'can update user-memos' do - visit_with_auth "/products/#{products(:product2).id}", 'komagata' - find('#side-tabs-nav-2').click - assert_text 'kimuraさんのメモ' - click_button '編集' - fill_in 'js-user-mentor-memo', with: '編集後のユーザーメモです。' - click_button '保存する' - assert_text '編集後のユーザーメモです。', wait: 10 - end - test 'display a list of products in side-column' do user = users(:kimura) visit_with_auth "/products/#{products(:product2).id}", 'mentormentaro' diff --git a/test/system/talks/content_test.rb b/test/system/talks/content_test.rb index 31160218e08..f0fa31e5f83 100644 --- a/test/system/talks/content_test.rb +++ b/test/system/talks/content_test.rb @@ -16,17 +16,6 @@ class ContentTest < ApplicationSystemTestCase assert_text 'ユーザー公開情報' end - test 'update memo' do - user = users(:kimura) - visit_with_auth "/talks/#{user.talk.id}", 'komagata' - assert_text 'kimuraさんのメモ' - click_button '編集' - fill_in 'js-user-mentor-memo', with: '相談部屋テストメモ' - click_button '保存する' - assert_text '相談部屋テストメモ' - assert_no_text 'kimuraさんのメモ' - end - test 'Displays a list of the 10 most recent reports' do user = users(:hajime) visit_with_auth "/talks/#{user.talk.id}", 'komagata' diff --git a/test/system/user/memos_test.rb b/test/system/user/memos_test.rb index 0dcdea6b15e..50db23419df 100644 --- a/test/system/user/memos_test.rb +++ b/test/system/user/memos_test.rb @@ -3,26 +3,88 @@ require 'application_system_test_case' class User::MemoTest < ApplicationSystemTestCase - test 'update memo' do - visit_with_auth user_path(users(:hatsuno)), 'komagata' - assert_text 'ユーザーメモはまだありません。' - click_button '編集' - fill_in 'js-user-mentor-memo', with: 'ユーザーメンターメモ' + setup do + @memo = MentorMemo.create!( + recipient: users(:kimura), + writer: users(:mentormentaro), + body: 'kimuraさんのメモ' + ) + end + + test 'add memo' do + visit_with_auth user_path(users(:kimura)), 'mentormentaro' + click_button '追加' + fill_in 'js-new-memo', with: '新しいメモ' click_button '保存する' - assert_text 'ユーザーメンターメモ' - assert_no_text 'ユーザーメモはまだありません。' + assert_text '新しいメモ' + end + + test 'update memo' do + visit_with_auth user_path(users(:kimura)), 'mentormentaro' + assert_text 'kimuraさんのメモ' + + within(".mentor-memo[data-memo_id='#{@memo.id}']") do + click_button '編集' + fill_in "js-memo-#{@memo.id}", with: '編集されたメモ' + click_button '保存する' + end + assert_text '編集されたメモ' + assert_no_text 'kimuraさんのメモ' + end + + test 'delete memo' do + visit_with_auth user_path(users(:kimura)), 'mentormentaro' + assert_text 'kimuraさんのメモ' + + within(".mentor-memo[data-memo_id='#{@memo.id}']") do + accept_confirm do + click_button '削除' + end + end + assert_no_text 'kimuraさんのメモ' end - test 'do not update memo when cancel' do + test 'admin can edit and delete others memo' do visit_with_auth user_path(users(:kimura)), 'komagata' + assert_button '編集' + assert_button '削除' + end + + test 'non writer cannot edit or delete memo' do + visit_with_auth user_path(users(:kimura)), 'long-id-mentor' + assert_no_button '編集' + assert_no_button '削除' + end + + test 'cancel editing does not update memo' do + visit_with_auth user_path(users(:kimura)), 'mentormentaro' assert_text 'kimuraさんのメモ' - assert_no_text 'ユーザーメモはまだありません。' - click_button '編集' - fill_in 'js-user-mentor-memo', with: 'ユーザーメンターメモ' - click_button 'キャンセル' - assert_no_text 'ユーザーメンターメモ' + + within(".mentor-memo[data-memo_id='#{@memo.id}']") do + click_button '編集' + fill_in "js-memo-#{@memo.id}", with: '編集されたメモ' + click_button 'キャンセル' + end assert_text 'kimuraさんのメモ' - assert_no_text 'ユーザーメモはまだありません。' + assert_no_text '編集されたメモ' + end + + test 'can preview while editing memo' do + visit_with_auth user_path(users(:kimura)), 'mentormentaro' + assert_text 'kimuraさんのメモ' + + within(".mentor-memo[data-memo_id='#{@memo.id}']") do + click_button '編集' + fill_in "js-memo-#{@memo.id}", with: 'プレビューができます。' + find('.form-tabs__tab', text: 'プレビュー').click + end + assert_text 'プレビューができます。' + end + + test 'show empty message when no memos' do + visit_with_auth user_path(users(:hajime)), 'mentormentaro' + assert_text 'ユーザーメモはまだありません。' + assert_no_selector '.mentor-memo' end test 'admin can see memo' do