From 24b5f1be7bc2680d54a7c493f97039feaca8a6e4 Mon Sep 17 00:00:00 2001 From: Cory McDonald Date: Fri, 19 Jul 2019 13:00:40 -0500 Subject: [PATCH] Case system improvements (#2054) * Mass assign * Add case replies * Thumbnails * Lint * Fix issue with mass update * replies -> templates --- app/assets/stylesheets/admin/style.scss | 67 +++++++++++ .../admin/case_notes_controller.rb | 4 +- .../admin/case_replies_controller.rb | 38 ++++++ app/controllers/admin/cases_controller.rb | 2 + app/javascript/packs/admin_case.js | 90 ++++++++++++++ app/javascript/packs/admin_case_replies.jsx | 113 ++++++++++++++++++ app/models/case_reply.rb | 5 + app/views/admin/case_replies/_form.html.slim | 9 ++ app/views/admin/case_replies/edit.html.slim | 7 ++ app/views/admin/case_replies/index.html.slim | 29 +++++ app/views/admin/cases/_sidebar.html.slim | 14 ++- app/views/admin/cases/_table.html.slim | 29 +++-- app/views/admin/cases/show.html.slim | 24 ++-- app/views/admin/publishers/_case.html.slim | 2 +- config/routes.rb | 2 + .../20190715142513_create_case_replies.rb | 9 ++ db/schema.rb | 9 +- 17 files changed, 426 insertions(+), 27 deletions(-) create mode 100644 app/controllers/admin/case_replies_controller.rb create mode 100644 app/javascript/packs/admin_case_replies.jsx create mode 100644 app/models/case_reply.rb create mode 100644 app/views/admin/case_replies/_form.html.slim create mode 100644 app/views/admin/case_replies/edit.html.slim create mode 100644 app/views/admin/case_replies/index.html.slim create mode 100644 db/migrate/20190715142513_create_case_replies.rb diff --git a/app/assets/stylesheets/admin/style.scss b/app/assets/stylesheets/admin/style.scss index 7b6688063a..0ff672d189 100644 --- a/app/assets/stylesheets/admin/style.scss +++ b/app/assets/stylesheets/admin/style.scss @@ -446,3 +446,70 @@ a.logo span { left: 35px; position: absolute; } + +.checkbox_1 { + height: 70px; + display: inline-flex; + align-items: center; + opacity: 0.9; + cursor: pointer; + position: relative; +} + +.checkbox_1 > input { + width: 21px; + height: 21px; + + appearance: none; + + /* custom styling */ + background-color: #fff; + border: 2px solid $braveGray-8; + border-radius: 5px; + cursor: pointer; + outline: none; + + transition-duration: 0.1s; +} + +.checkbox_1 > input:checked { + border: 3px solid #4c54d2; + background-color: #f3f3f6; +} + +/* style checkmark symbol */ +.checkbox_1 > input:checked + span::before { + color: #343546; + content: "\2713"; + + text-align: center; + + display: block; + position: absolute; + left: 16px; + top: 22px; +} + +.checkbox_1 > input:active { + border: 2px solid #7e47a8; +} + +.checkbox_1 > input:focus + label::before { + outline: #fb542b solid 1px; + box-shadow: 0 0px 8px #7e47a8; +} + +.reply:hover { + background: $braveBrand-Light; + cursor: pointer; + * { + color: $braveGray-10 !important; + } +} + +#replySection { + left: 300px; + top: 0; + position: absolute; + z-index: 999999999; +} diff --git a/app/controllers/admin/case_notes_controller.rb b/app/controllers/admin/case_notes_controller.rb index 0518f53f82..29c12b7264 100644 --- a/app/controllers/admin/case_notes_controller.rb +++ b/app/controllers/admin/case_notes_controller.rb @@ -7,7 +7,9 @@ def create end def update - CaseNote.find(params[:id]).update(public: false) + note = CaseNote.find(params[:id]) + note.update(public: false) + redirect_to admin_case_path(note.case) end private diff --git a/app/controllers/admin/case_replies_controller.rb b/app/controllers/admin/case_replies_controller.rb new file mode 100644 index 0000000000..0538cd4ed2 --- /dev/null +++ b/app/controllers/admin/case_replies_controller.rb @@ -0,0 +1,38 @@ +module Admin + class CaseRepliesController < AdminController + def index + @open_cases = Case.where(status: Case::OPEN) + @assigned_cases = Case.where(assignee: current_user, status: Case::IN_PROGRESS) + + @replies = CaseReply.all + end + + def create + CaseReply.create(reply_params) + redirect_to admin_case_replies_path, flash: { notice: "Your saved reply was created successfully."} + end + + def edit + @reply = CaseReply.find(params[:id]) + @open_cases = Case.where(status: Case::OPEN) + @assigned_cases = Case.where(assignee: current_user, status: Case::IN_PROGRESS) + end + + def update + CaseReply.find(params[:id]).update(reply_params) + redirect_to admin_case_replies_path, flash: { notice: "Your saved reply was updated successfully."} + end + + def destroy + CaseReply.find(params[:id]).destroy + redirect_to admin_case_replies_path, flash: { notice: "Deleted the reply"} + end + + + private + + def reply_params + params.require(:case_reply).permit(:id, :title, :body) + end + end +end diff --git a/app/controllers/admin/cases_controller.rb b/app/controllers/admin/cases_controller.rb index 30161d962a..e2a6cfa1f3 100644 --- a/app/controllers/admin/cases_controller.rb +++ b/app/controllers/admin/cases_controller.rb @@ -72,6 +72,8 @@ def show last_note = @notes.where(public: true).first @answered = last_note&.created_by&.admin? + + @replies = CaseReply.all end def assign diff --git a/app/javascript/packs/admin_case.js b/app/javascript/packs/admin_case.js index 2e16df15c9..d56832577d 100644 --- a/app/javascript/packs/admin_case.js +++ b/app/javascript/packs/admin_case.js @@ -1,5 +1,65 @@ import Rails from "rails-ujs"; +const shiftClick = () => { + // get array of items + var list = document.querySelector(".dynamic-table"); + var items = list.querySelectorAll(".gradeX"); + + // create vars for tracking clicked items + var firstItem, lastItem; + + // method for ticking all items between first and last + function tick(first, last) { + // items is a nodeList, so we do some prototype trickery + Array.prototype.forEach.call(items, function(el, i) { + // find each checkbox + var checkbox = el.getElementsByTagName("input")[0]; + // tick all within first to last range + if ((i >= first && i <= last) || (i <= first && i >= last)) { + checkbox.checked = true; + } + }); + } + + // method for unticking all items except current item + function untickAllExcept(first) { + Array.prototype.forEach.call(items, function(el, i) { + var cb = el.querySelectorAll("input[type='checkbox']"); + if (i !== first) { + cb[0].checked = false; + } + }); + } + + // click listener on list + list.addEventListener("click", function(e) { + if (e.target.type === "checkbox" || e.target.nodeName === "SPAN") { + var item = e.target.parentNode.parentNode; + if (e.target.nodeName === "SPAN") { + const checked = e.target.parentNode.firstChild.checked; + e.target.parentNode.firstChild.checked = !checked; + } + + if (e.shiftKey) { + // store as last item clicked + lastItem = Array.prototype.indexOf.call(items, item); + } else { + // store as first item clicked + firstItem = Array.prototype.indexOf.call(items, item); + // unset last item + lastItem = null; + } + + // do magic + if (lastItem != null) { + tick(firstItem, lastItem); + } else { + untickAllExcept(firstItem); + } + } + }); +}; + function selected(e) { console.log( "Original event that triggered text replacement:", @@ -23,6 +83,8 @@ function selected(e) { e.detail.item.original.key }`; + assignCheckboxes(e, event.target, assignedHTML); + const parent = event.target.closest("div"); if (parent.id) { parent.classList.toggle("w-100"); @@ -39,6 +101,32 @@ function selected(e) { } } +function assignCheckboxes(e, target, assignedHTML) { + const checkbox = target + .closest("tr") + .querySelectorAll("input[type='checkbox']"); + + if (checkbox && checkbox[0].checked) { + const inputChecked = target + .closest("table") + .querySelectorAll("input[type='checkbox']:checked"); + + inputChecked.forEach(checked => { + const checkedForm = checked.closest("tr").querySelector("form"); + + checkedForm.querySelector(".assignee-input").value = + e.detail.item.original.value; + Rails.fire(checkedForm, "submit"); + + let parentDiv = checkedForm.closest("div"); + if (parentDiv.id) { + parentDiv.classList.toggle("w-100"); + parentDiv.closest("td").innerHTML = assignedHTML; + } + }); + } +} + function toggleForm(event) { const form = event.target.parentElement.parentElement.querySelector("form"); form.classList.toggle("d-none"); @@ -58,6 +146,8 @@ document.addEventListener("DOMContentLoaded", function() { }; } + shiftClick(); + document .querySelectorAll(".filter") .forEach(element => element.addEventListener("click", toggleForm)); diff --git a/app/javascript/packs/admin_case_replies.jsx b/app/javascript/packs/admin_case_replies.jsx new file mode 100644 index 0000000000..38c1926a25 --- /dev/null +++ b/app/javascript/packs/admin_case_replies.jsx @@ -0,0 +1,113 @@ +import React from "react"; +import * as ReactDOM from "react-dom"; + +export default class CaseReply extends React.Component { + constructor(props) { + super(props); + this.state = { + filterText: "", + isVisible: false + }; + } + + setText = e => { + this.setState({ filterText: e.target.value }); + }; + + getReplies = () => { + if (this.state.filterText === "") { + return this.props.replies; + } + + return this.props.replies.filter(reply => { + let query = this.state.filterText.toLowerCase(); + let title = reply.title.toLowerCase(); + let body = reply.body.toLowerCase(); + + return title.includes(query) || body.includes(query); + }); + }; + + renderDropdown = () => { + this.setState(prevState => ({ + isVisible: !prevState.isVisible + })); + }; + + clickReply = body => { + const textarea = document.getElementsByName("case_note[note]")[0]; + + textarea.value += body + "\n"; + + this.setState({ isVisible: false }); + }; + + render() { + const button = ( +
+ + + Saved templates +
+ ); + let replies = ( +
+ + No replies +
+ ); + + const savedReplies = this.getReplies(); + if (savedReplies.length > 0) { + replies = savedReplies.map(reply => ( +
this.clickReply(reply.body)} + > +
+ {reply.title} +
+
{reply.body}
+
+ )); + } + + return ( +
+ {button} + {this.state.isVisible && ( +
+ Select a reply +
+ +
+
+ {replies} +
+
+ )} +
+ ); + } +} + +document.addEventListener("DOMContentLoaded", () => { + const element = document.querySelector("#replySection"); + const replies = JSON.parse(element.dataset.replies); + ReactDOM.render(, element); +}); diff --git a/app/models/case_reply.rb b/app/models/case_reply.rb new file mode 100644 index 0000000000..a9f1501e18 --- /dev/null +++ b/app/models/case_reply.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class CaseReply < ApplicationRecord + validates :title, :body, presence: true, allow_blank: false +end diff --git a/app/views/admin/case_replies/_form.html.slim b/app/views/admin/case_replies/_form.html.slim new file mode 100644 index 0000000000..bcd8bac47f --- /dev/null +++ b/app/views/admin/case_replies/_form.html.slim @@ -0,0 +1,9 @@ += form_with model: [:admin, reply], local: true do |f| + .form-group + label.font-weight-bold Saved reply title + = f.text_field :title, { class:'form-control', placeholder: "Add a short title to your reply"} + .form-group + = f.text_area :body, { class:'form-control', rows:'4', placeholder: "Add your saved reply" } + =submit_tag(reply.persisted? ? "Update saved reply" : "Add saved reply", class: "btn btn-primary") + - if reply.persisted? + =link_to "Cancel", admin_case_replies_path, class: 'btn btn-dark ml-3' diff --git a/app/views/admin/case_replies/edit.html.slim b/app/views/admin/case_replies/edit.html.slim new file mode 100644 index 0000000000..4f227deef1 --- /dev/null +++ b/app/views/admin/case_replies/edit.html.slim @@ -0,0 +1,7 @@ +.row + .col-2.shadow-sm.pb-3 + =render partial: 'admin/cases/sidebar' + .col-10 + h4 Edit saved reply + hr + = render partial: 'form', locals: { reply: @reply } diff --git a/app/views/admin/case_replies/index.html.slim b/app/views/admin/case_replies/index.html.slim new file mode 100644 index 0000000000..cea0dde98c --- /dev/null +++ b/app/views/admin/case_replies/index.html.slim @@ -0,0 +1,29 @@ +.row + .col-2.shadow-sm.pb-3 + =render partial: 'admin/cases/sidebar' + .col-10 + .mb-3 + h3 Saved templates + p Saved templates are re-usable text snippets that you can use throughout the Case workflow. Saved templates can save you time if you’re often typing similar responses. + + - @replies.each do |reply| + .border.rounded.my-2.d-flex.justify-content-between.align-items-center + .ml-2.p-2 + strong=reply.title + .text-muted.text-truncate style="max-width: 500px;"= reply.body + + .btn-group + = link_to fa_icon("pencil"), edit_admin_case_reply_path(id: reply.id), class: 'btn border-right' + = link_to fa_icon("times"), admin_case_reply_path(id: reply.id), method: :delete, class: 'btn text-danger' + + + - if @replies.size.zero? + .text-muted No saved templates + + + .mt-5 + h4 Add a saved reply + hr + = render partial: 'form', locals: { reply: CaseReply.new } + + diff --git a/app/views/admin/cases/_sidebar.html.slim b/app/views/admin/cases/_sidebar.html.slim index 5392aca260..4f9f880bc9 100644 --- a/app/views/admin/cases/_sidebar.html.slim +++ b/app/views/admin/cases/_sidebar.html.slim @@ -1,5 +1,5 @@ .mb-3 - = fa_icon "inbox", class:'mr-2' + = fa_icon "inbox fw", class:'mr-2' strong CASES .d-flex.justify-content-between.mb-2 @@ -15,8 +15,18 @@ .mt-5 .mb-3 - = fa_icon "bar-chart", class:'mr-2' + = fa_icon "bar-chart fw", class:'mr-2' strong REPORTS .d-flex.justify-content-between.mb-2 = case_link("Overview", overview_admin_cases_path) + + + +.mt-5 +.mb-3 + = fa_icon "cog fw", class:'mr-2' + strong SETTINGS + +.d-flex.justify-content-between.mb-2 + = case_link("Saved templates", admin_case_replies_path) diff --git a/app/views/admin/cases/_table.html.slim b/app/views/admin/cases/_table.html.slim index cd872530fe..c0e95c48ac 100644 --- a/app/views/admin/cases/_table.html.slim +++ b/app/views/admin/cases/_table.html.slim @@ -1,20 +1,25 @@ table.display.table.table-hover.dynamic-table id="dynamic-table" - tr - th width="20%" Case ID - th width="25%" Publisher - -if params[:status] != Case::OPEN - th width="25%" - .d-flex.align-items-center - = sort_link(:assignee_id, "Assigned") - =fa_icon "filter", class: 'filter px-2' - = form_tag admin_cases_path, method: :get, remote: false, class: 'd-none' - input.w-75.p-1.mt-1.assignee-input#tableheader placeholder="User" type="text" name="q" autocomplete="off" - th width="15%"= sort_link(:status, "Status") - th= sort_link(:open_at, "Open") + thead + tr + th width="1%" + th width="20%" Case ID + th width="25%" Publisher + -if params[:status] != Case::OPEN + th width="25%" + .d-flex.align-items-center + = sort_link(:assignee_id, "Assigned") + =fa_icon "filter", class: 'filter px-2' + = form_tag admin_cases_path, method: :get, remote: false, class: 'd-none' + input.w-75.p-1.mt-1.assignee-input#tableheader placeholder="User" type="text" name="q" autocomplete="off" + th width="15%"= sort_link(:status, "Status") + th= sort_link(:open_at, "Open") tbody - cases.each do |case_model| tr.gradeX height="70px" + td.checkbox_1 + input type="checkbox" + span td= link_to("Case ##{case_model.number}", admin_case_path(case_model)) td= link_to(case_model.publisher, admin_publisher_path(case_model.publisher), target: "_blank", class: "text-break text-body") -if params[:status] != Case::OPEN diff --git a/app/views/admin/cases/show.html.slim b/app/views/admin/cases/show.html.slim index c9189cce38..6a57aad806 100644 --- a/app/views/admin/cases/show.html.slim +++ b/app/views/admin/cases/show.html.slim @@ -2,7 +2,7 @@ = javascript_pack_tag 'tribute' = stylesheet_pack_tag 'tribute' = javascript_pack_tag 'admin_case' - += javascript_pack_tag 'admin_case_replies' .row.p-3.mb-4.shadow-sm.mx-1.rounded .col-4 @@ -52,13 +52,14 @@ h6 =fa_icon "paperclip", class: "mr-2" span= pluralize(@case.files.size, "Attachment") - - @case.files.each do |file| - .c-4.m-3.border.rounded.p-3 - - if file.blob.image? - =fa_icon "file-image-o", class: 'mx-3' - - else - =fa_icon "fa-file-text-o", class: 'mx-3' - =link_to(file.blob.filename, url_for(file), target: "_blank") + .d-flex.flex-wrap.p-4 + - @case.files.each do |file| + .p-3 style="max-width: 175px;" + - unless file.blob.image? + =fa_icon "fa-file-text-o", class: 'mx-3' + a href=url_for(file) target="_blank" class="d-flex flex-column justify-content-between" + = image_tag file, class: 'img-thumbnail' + .mt-auto.font-weight-bold.text-dark.text-truncate.py-2= file.blob.filename .col-2 .border-bottom.pb-2.mb-2 @@ -107,12 +108,15 @@ label for="panel_id" .question h3.text-dark Send a message + #replySection data-replies="#{@replies.to_json}" - else input type="checkbox" name="panels" id="panel_id" checked="checked" label for="panel_id" .question - h3.text-dark Send a message + .d-flex + h3.text-dark Send a message + #replySection data-replies="#{@replies.to_json}" .panel-content - if @case.assignee_id.blank? .mb-2 @@ -122,7 +126,7 @@ = form_with model: [:admin, CaseNote.new(case: @case)], local: true, id: "case-form" do |f| = f.hidden_field :case_id .form-group - = f.text_area :note, { class:'form-control', rows:'6', minlength: "5", } + = f.text_area :note, { class:'form-control', rows:'7', minlength: "5", } .form-group = f.file_field :files, multiple: true diff --git a/app/views/admin/publishers/_case.html.slim b/app/views/admin/publishers/_case.html.slim index e5d3e42f6c..8f30304f50 100644 --- a/app/views/admin/publishers/_case.html.slim +++ b/app/views/admin/publishers/_case.html.slim @@ -1,7 +1,7 @@ - if history.object_changes.present? && (changes = YAML.load(history.object_changes)) && changes.keys.any? { |k| ["assignee_id", "status"].include? k } .note-header - span.mr-2= link_to("Case ##{history.number}", class:'font-weight-bold text-dark') + span.mr-2= link_to("Case ##{history.number}", admin_case_path(history.item_id), class:'font-weight-bold text-dark') small.text-muted.mx-2 • span.date data-tooltip=history.created_at.strftime("%B %d, %Y %k:%M %Z") = time_ago_in_words(history.created_at) diff --git a/config/routes.rb b/config/routes.rb index 0b9cf8f56a..4bb1db1af7 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -175,8 +175,10 @@ patch :assign collection do get :overview + resources :case_replies end end + resources :case_notes resources :faq_categories, except: [:show] diff --git a/db/migrate/20190715142513_create_case_replies.rb b/db/migrate/20190715142513_create_case_replies.rb new file mode 100644 index 0000000000..dcb946e6b7 --- /dev/null +++ b/db/migrate/20190715142513_create_case_replies.rb @@ -0,0 +1,9 @@ +class CreateCaseReplies < ActiveRecord::Migration[5.2] + def change + create_table :case_replies, id: :uuid, default: -> { "uuid_generate_v4()"}, force: :cascade do |t| + t.string :title + t.text :body + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 820d4bd370..30859c1011 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2019_07_10_170355) do +ActiveRecord::Schema.define(version: 2019_07_15_142513) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -48,6 +48,13 @@ t.index ["created_by_id"], name: "index_case_notes_on_created_by_id" end + create_table "case_replies", id: :uuid, default: -> { "uuid_generate_v4()" }, force: :cascade do |t| + t.string "title" + t.text "body" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + create_table "cases", id: :uuid, default: -> { "uuid_generate_v4()" }, force: :cascade do |t| t.text "solicit_question" t.text "accident_question"