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"