From d8d14fc4a4180673d62e273b806a19c1d2bc14be Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Sun, 24 May 2020 22:44:26 +0530 Subject: [PATCH] Feature: Rewamp super admin dashboard (#882) --- .scss-lint.yml | 3 + app/assets/config/manifest.js | 1 + app/assets/javascripts/dashboardChart.js | 55 +++ .../stylesheets/administrate/application.scss | 32 ++ .../stylesheets/administrate/base/_forms.scss | 103 ++++ .../administrate/base/_layout.scss | 22 + .../stylesheets/administrate/base/_lists.scss | 19 + .../administrate/base/_tables.scss | 71 +++ .../administrate/base/_typography.scss | 44 ++ .../components/_app-container.scss | 8 + .../administrate/components/_attributes.scss | 26 + .../administrate/components/_buttons.scss | 50 ++ .../administrate/components/_cells.scss | 45 ++ .../administrate/components/_field-unit.scss | 54 +++ .../administrate/components/_flashes.scss | 28 ++ .../components/_form-actions.scss | 3 + .../components/_main-content.scss | 26 + .../administrate/components/_navigation.scss | 72 +++ .../administrate/components/_pagination.scss | 19 + .../administrate/components/_reports.scss | 15 + .../administrate/components/_search.scss | 44 ++ .../administrate/library/_clearfix.scss | 7 + .../administrate/library/_data-label.scss | 8 + .../administrate/library/_variables.scss | 61 +++ .../administrate/reset/_normalize.scss | 447 ++++++++++++++++++ .../administrate/utilities/_text-color.scss | 3 + .../administrate/utilities/_variables.scss | 98 ++++ .../super_admin/account_users_controller.rb | 30 +- .../super_admin/application_controller.rb | 7 + .../super_admin/dashboard_controller.rb | 12 + app/dashboards/account_dashboard.rb | 16 +- app/dashboards/account_user_dashboard.rb | 20 +- app/dashboards/super_admin_dashboard.rb | 8 +- app/dashboards/user_dashboard.rb | 23 +- app/fields/avatar_field.rb | 7 + app/fields/count_field.rb | 7 + .../assets/scss/super_admin/pages.scss | 12 +- app/javascript/packs/sdk.js | 2 +- app/javascript/packs/superadmin_pages.js | 1 + app/models/account_user.rb | 2 +- app/views/fields/avatar_field/_index.html.erb | 1 + app/views/fields/avatar_field/_show.html.erb | 1 + app/views/fields/count_field/_index.html.erb | 1 + app/views/fields/count_field/_show.html.erb | 1 + .../layouts/super_admin/application.html.erb | 41 ++ app/views/super_admin/accounts/show.html.erb | 88 ++++ .../application/_collection.html.erb | 95 ++++ .../super_admin/application/_flashes.html.erb | 20 + .../super_admin/application/_icons.html.erb | 13 + .../application/_javascript.html.erb | 21 + .../application/_navigation.html.erb | 54 ++- .../application/_stylesheet.html.erb | 14 + .../super_admin/application/index.html.erb | 66 +++ .../super_admin/dashboard/index.html.erb | 69 +++ .../super_admin/users/_collection.html.erb | 95 ++++ app/views/super_admin/users/index.html.erb | 66 +++ app/views/super_admin/users/show.html.erb | 88 ++++ config/routes.rb | 8 +- db/schema.rb | 1 + public/admin/avatar.png | Bin 0 -> 8512 bytes .../access_tokens_controller_spec.rb | 1 - .../account_users_controller_spec.rb | 12 +- .../super_admin_controller_spec.rb | 2 +- 63 files changed, 2190 insertions(+), 79 deletions(-) create mode 100644 app/assets/javascripts/dashboardChart.js create mode 100644 app/assets/stylesheets/administrate/application.scss create mode 100644 app/assets/stylesheets/administrate/base/_forms.scss create mode 100644 app/assets/stylesheets/administrate/base/_layout.scss create mode 100644 app/assets/stylesheets/administrate/base/_lists.scss create mode 100644 app/assets/stylesheets/administrate/base/_tables.scss create mode 100644 app/assets/stylesheets/administrate/base/_typography.scss create mode 100644 app/assets/stylesheets/administrate/components/_app-container.scss create mode 100644 app/assets/stylesheets/administrate/components/_attributes.scss create mode 100644 app/assets/stylesheets/administrate/components/_buttons.scss create mode 100644 app/assets/stylesheets/administrate/components/_cells.scss create mode 100644 app/assets/stylesheets/administrate/components/_field-unit.scss create mode 100644 app/assets/stylesheets/administrate/components/_flashes.scss create mode 100644 app/assets/stylesheets/administrate/components/_form-actions.scss create mode 100644 app/assets/stylesheets/administrate/components/_main-content.scss create mode 100644 app/assets/stylesheets/administrate/components/_navigation.scss create mode 100644 app/assets/stylesheets/administrate/components/_pagination.scss create mode 100644 app/assets/stylesheets/administrate/components/_reports.scss create mode 100644 app/assets/stylesheets/administrate/components/_search.scss create mode 100644 app/assets/stylesheets/administrate/library/_clearfix.scss create mode 100644 app/assets/stylesheets/administrate/library/_data-label.scss create mode 100644 app/assets/stylesheets/administrate/library/_variables.scss create mode 100644 app/assets/stylesheets/administrate/reset/_normalize.scss create mode 100644 app/assets/stylesheets/administrate/utilities/_text-color.scss create mode 100644 app/assets/stylesheets/administrate/utilities/_variables.scss create mode 100644 app/controllers/super_admin/dashboard_controller.rb create mode 100644 app/fields/avatar_field.rb create mode 100644 app/fields/count_field.rb create mode 100644 app/views/fields/avatar_field/_index.html.erb create mode 100644 app/views/fields/avatar_field/_show.html.erb create mode 100644 app/views/fields/count_field/_index.html.erb create mode 100644 app/views/fields/count_field/_show.html.erb create mode 100644 app/views/layouts/super_admin/application.html.erb create mode 100644 app/views/super_admin/accounts/show.html.erb create mode 100644 app/views/super_admin/application/_collection.html.erb create mode 100644 app/views/super_admin/application/_flashes.html.erb create mode 100644 app/views/super_admin/application/_icons.html.erb create mode 100644 app/views/super_admin/application/_javascript.html.erb create mode 100644 app/views/super_admin/application/_stylesheet.html.erb create mode 100644 app/views/super_admin/application/index.html.erb create mode 100644 app/views/super_admin/dashboard/index.html.erb create mode 100644 app/views/super_admin/users/_collection.html.erb create mode 100644 app/views/super_admin/users/index.html.erb create mode 100644 app/views/super_admin/users/show.html.erb create mode 100644 public/admin/avatar.png diff --git a/.scss-lint.yml b/.scss-lint.yml index 9f5f4fe10..481d94c70 100644 --- a/.scss-lint.yml +++ b/.scss-lint.yml @@ -177,6 +177,8 @@ linters: allow_element_with_attribute: false allow_element_with_class: false allow_element_with_id: false + exclude: + - 'app/assets/stylesheets/administrate/components/_buttons.scss' SelectorDepth: enabled: true @@ -279,3 +281,4 @@ linters: exclude: - 'app/javascript/widget/assets/scss/_reset.scss' - 'app/javascript/widget/assets/scss/sdk.css' + - 'app/assets/stylesheets/administrate/reset/_normalize.scss' diff --git a/app/assets/config/manifest.js b/app/assets/config/manifest.js index 9b826819b..f5e0f5476 100644 --- a/app/assets/config/manifest.js +++ b/app/assets/config/manifest.js @@ -1,3 +1,4 @@ //= link_tree ../images //= link administrate/application.css //= link administrate/application.js +//= link dashboardChart.js diff --git a/app/assets/javascripts/dashboardChart.js b/app/assets/javascripts/dashboardChart.js new file mode 100644 index 000000000..6bfe56bda --- /dev/null +++ b/app/assets/javascripts/dashboardChart.js @@ -0,0 +1,55 @@ +// eslint-disable-next-line +function prepareData(data) { + var labels = []; + var dataSet = []; + data.forEach(item => { + labels.push(item[0]); + dataSet.push(item[1]); + }); + return { labels, dataSet }; +} + +function getChartOptions() { + var fontFamily = + 'Inter,-apple-system,system-ui,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif'; + return { + responsive: true, + legend: { labels: { fontFamily } }, + scales: { + xAxes: [ + { + barPercentage: 1.26, + ticks: { fontFamily }, + gridLines: { display: false }, + }, + ], + yAxes: [ + { + ticks: { fontFamily }, + gridLines: { display: false }, + }, + ], + }, + }; +} + +// eslint-disable-next-line +function drawSuperAdminDashboard(data) { + var ctx = document.getElementById('dashboard-chart').getContext('2d'); + var chartData = prepareData(data); + // eslint-disable-next-line + new Chart(ctx, { + type: 'bar', + data: { + labels: chartData.labels, + datasets: [ + { + label: 'Conversations', + data: chartData.dataSet, + backgroundColor: '#1f93ff', + }, + ], + }, + options: getChartOptions(), + }); +} diff --git a/app/assets/stylesheets/administrate/application.scss b/app/assets/stylesheets/administrate/application.scss new file mode 100644 index 000000000..79738bbf3 --- /dev/null +++ b/app/assets/stylesheets/administrate/application.scss @@ -0,0 +1,32 @@ +@charset 'utf-8'; + +@import 'reset/normalize'; + +@import 'utilities/variables'; +@import 'utilities/text-color'; + +@import 'selectize'; +@import 'datetime_picker'; + +@import 'library/clearfix'; +@import 'library/data-label'; +@import 'library/variables'; + +@import 'base/forms'; +@import 'base/layout'; +@import 'base/lists'; +@import 'base/tables'; +@import 'base/typography'; + +@import 'components/app-container'; +@import 'components/attributes'; +@import 'components/buttons'; +@import 'components/cells'; +@import 'components/field-unit'; +@import 'components/flashes'; +@import 'components/form-actions'; +@import 'components/main-content'; +@import 'components/navigation'; +@import 'components/pagination'; +@import 'components/search'; +@import 'components/reports'; diff --git a/app/assets/stylesheets/administrate/base/_forms.scss b/app/assets/stylesheets/administrate/base/_forms.scss new file mode 100644 index 000000000..bf014a746 --- /dev/null +++ b/app/assets/stylesheets/administrate/base/_forms.scss @@ -0,0 +1,103 @@ +fieldset { + background-color: transparent; + border: 0; + margin: 0; + padding: 0; +} + +legend { + font-weight: $font-weight-medium; + margin: 0; + padding: 0; +} + +label { + display: block; + font-weight: $font-weight-medium; + margin: 0; +} + +input, +select { + display: block; + font-family: $base-font-family; + font-size: $base-font-size; +} + +input, +select, +textarea { + display: block; + font-family: $base-font-family; + font-size: 16px; +} + +[type="color"], +[type="date"], +[type="datetime-local"], +[type="email"], +[type="month"], +[type="number"], +[type="password"], +[type="search"], +[type="tel"], +[type="text"], +[type="time"], +[type="url"], +[type="week"], +input:not([type]), +textarea { + appearance: none; + background-color: $white; + border: $base-border; + border-radius: $base-border-radius; + padding: 0.5em; + transition: border-color $base-duration $base-timing; + width: 100%; + + &:hover { + border-color: mix($black, $base-border-color, 20%); + } + + &:focus { + border-color: $action-color; + outline: none; + } + + &:disabled { + background-color: mix($black, $white, 5%); + cursor: not-allowed; + + &:hover { + border: $base-border; + } + } +} + +textarea { + resize: vertical; +} + +[type="checkbox"], +[type="radio"] { + display: inline; + margin-right: $small-spacing / 2; +} + +[type="file"] { + width: 100%; +} + +select { + width: 100%; +} + +[type="checkbox"], +[type="radio"], +[type="file"], +select { + &:focus { + outline: $focus-outline; + outline-offset: $focus-outline-offset; + } +} diff --git a/app/assets/stylesheets/administrate/base/_layout.scss b/app/assets/stylesheets/administrate/base/_layout.scss new file mode 100644 index 000000000..c4c081a82 --- /dev/null +++ b/app/assets/stylesheets/administrate/base/_layout.scss @@ -0,0 +1,22 @@ +html { + background-color: $color-white; + box-sizing: border-box; + font-size: 10px; + -webkit-font-smoothing: antialiased; +} + +*, +*::before, +*::after { + box-sizing: inherit; +} + +figure { + margin: 0; +} + +img, +picture { + margin: 0; + max-width: 100%; +} diff --git a/app/assets/stylesheets/administrate/base/_lists.scss b/app/assets/stylesheets/administrate/base/_lists.scss new file mode 100644 index 000000000..70eae5203 --- /dev/null +++ b/app/assets/stylesheets/administrate/base/_lists.scss @@ -0,0 +1,19 @@ +ul, +ol { + list-style-type: none; + margin: 0; + padding: 0; +} + +dl { + margin-bottom: $small-spacing; + + dt { + font-weight: $font-weight-medium; + margin-top: $small-spacing; + } + + dd { + margin: 0; + } +} diff --git a/app/assets/stylesheets/administrate/base/_tables.scss b/app/assets/stylesheets/administrate/base/_tables.scss new file mode 100644 index 000000000..1772e8cf4 --- /dev/null +++ b/app/assets/stylesheets/administrate/base/_tables.scss @@ -0,0 +1,71 @@ +table { + border-collapse: collapse; + font-size: $font-size-default; + text-align: left; + width: 100%; + + a { + color: inherit; + text-decoration: none; + } +} + +tr { + border-bottom: $base-border; + + th { + font-weight: $font-weight-medium; + + &.cell-label--avatar-field { + a { + display: none; + } + } + } +} + +tbody tr { + &:hover { + background-color: $base-background-color; + cursor: pointer; + } + + &:focus { + outline: $focus-outline; + outline-offset: -($focus-outline-width); + } + + td { + &.cell-data--avatar-field { + line-height: 1; + text-align: center; + + img { + border-radius: 50%; + height: $space-large; + max-height: $space-large; + width: $space-large; + } + } + } +} + +td, +th { + padding: $space-slab; + vertical-align: middle; +} + +td:first-child, +th:first-child { + padding-left: 0; +} + +td:last-child, +th:last-child { + padding-right: 0; +} + +td img { + max-height: 2rem; +} diff --git a/app/assets/stylesheets/administrate/base/_typography.scss b/app/assets/stylesheets/administrate/base/_typography.scss new file mode 100644 index 000000000..bf2c2d9c2 --- /dev/null +++ b/app/assets/stylesheets/administrate/base/_typography.scss @@ -0,0 +1,44 @@ +body { + color: $base-font-color; + font-family: $base-font-family; + font-size: $base-font-size; + line-height: $base-line-height; +} + +h1, +h2, +h3, +h4, +h5, +h6 { + font-family: $heading-font-family; + font-size: $base-font-size; + line-height: $heading-line-height; + margin: 0; +} + +p { + margin: 0 0 $small-spacing; +} + +a { + color: $action-color; + transition: color $base-duration $base-timing; + + &:hover { + color: mix($black, $action-color, 25%); + } + + &:focus { + outline: $focus-outline; + outline-offset: $focus-outline-offset; + } +} + +hr { + border-bottom: $base-border; + border-left: 0; + border-right: 0; + border-top: 0; + margin: $base-spacing 0; +} diff --git a/app/assets/stylesheets/administrate/components/_app-container.scss b/app/assets/stylesheets/administrate/components/_app-container.scss new file mode 100644 index 000000000..80873272f --- /dev/null +++ b/app/assets/stylesheets/administrate/components/_app-container.scss @@ -0,0 +1,8 @@ +.app-container { + align-items: stretch; + display: flex; + margin-left: auto; + margin-right: auto; + max-width: 100rem; + min-height: 100vh; +} diff --git a/app/assets/stylesheets/administrate/components/_attributes.scss b/app/assets/stylesheets/administrate/components/_attributes.scss new file mode 100644 index 000000000..713d9f523 --- /dev/null +++ b/app/assets/stylesheets/administrate/components/_attributes.scss @@ -0,0 +1,26 @@ +.attribute-label { + @include data-label; + clear: left; + float: left; + margin-bottom: $base-spacing; + margin-top: 0.25em; + text-align: right; + width: calc(15% - 1rem); +} + +.preserve-whitespace { + white-space: pre-wrap; + word-wrap: break-word; +} + +.attribute-data { + float: left; + margin-bottom: $base-spacing; + margin-left: 2rem; + width: calc(85% - 1rem); +} + +.attribute--nested { + border: $base-border; + padding: $small-spacing; +} diff --git a/app/assets/stylesheets/administrate/components/_buttons.scss b/app/assets/stylesheets/administrate/components/_buttons.scss new file mode 100644 index 000000000..3e021e658 --- /dev/null +++ b/app/assets/stylesheets/administrate/components/_buttons.scss @@ -0,0 +1,50 @@ +button, +input[type="button"], +input[type="reset"], +input[type="submit"], +.button { + appearance: none; + background-color: $color-woot; + border: 0; + border-radius: $base-border-radius; + color: $white; + cursor: pointer; + display: inline-block; + font-size: $font-size-default; + -webkit-font-smoothing: antialiased; + font-weight: $font-weight-medium; + line-height: 1; + padding: $space-one $space-two; + text-decoration: none; + transition: background-color $base-duration $base-timing; + user-select: none; + vertical-align: middle; + white-space: nowrap; + + &:hover { + background-color: mix($black, $color-woot, 20%); + color: $white; + } + + &:focus { + outline: $focus-outline; + outline-offset: $focus-outline-offset; + } + + &:disabled { + cursor: not-allowed; + opacity: 0.5; + + &:hover { + background-color: $color-woot; + } + } +} + +.button--alt { + background-color: transparent; + border: $base-border; + border-color: $blue; + color: $blue; + margin-bottom: $base-spacing; +} diff --git a/app/assets/stylesheets/administrate/components/_cells.scss b/app/assets/stylesheets/administrate/components/_cells.scss new file mode 100644 index 000000000..2f7e27c4a --- /dev/null +++ b/app/assets/stylesheets/administrate/components/_cells.scss @@ -0,0 +1,45 @@ +.cell-label { + &:hover { + a { + color: $action-color; + } + + svg { + fill: $action-color; + transform: rotate(180deg); + } + } + + a { + color: inherit; + display: inline-block; + transition: color $base-duration $base-timing; + width: 100%; + } +} + +.cell-label--asc, +.cell-label--desc { + font-weight: $font-weight-medium; +} + +.cell-label__sort-indicator { + float: right; + margin-left: 5px; + + svg { + fill: $hint-grey; + height: 13px; + transition: transform $base-duration $base-timing; + width: 13px; + } +} + +.cell-label__sort-indicator--desc { + transform: rotate(180deg); +} + +.cell-data--number, +.cell-label--number { + text-align: right; +} diff --git a/app/assets/stylesheets/administrate/components/_field-unit.scss b/app/assets/stylesheets/administrate/components/_field-unit.scss new file mode 100644 index 000000000..856c1872c --- /dev/null +++ b/app/assets/stylesheets/administrate/components/_field-unit.scss @@ -0,0 +1,54 @@ +.field-unit { + @include administrate-clearfix; + align-items: center; + display: flex; + margin-bottom: $base-spacing; + position: relative; + width: 100%; +} + +.field-unit__label { + float: left; + margin-left: 1rem; + text-align: right; + width: calc(15% - 1rem); +} + +.field-unit__field { + float: left; + margin-left: 2rem; + max-width: 50rem; + width: 100%; +} + +.field-unit--nested { + border: $base-border; + margin-left: 7.5%; + max-width: 60rem; + padding: $small-spacing; + width: 100%; + + .field-unit__field { + width: 100%; + } + + .field-unit__label { + width: 10rem; + } +} + +.field-unit--required { + label::after { + color: $red; + content: ' *'; + } +} + +.attribute-data--avatar-field { + height: $space-larger; + width: $space-larger; + + img { + border-radius: 50%; + } +} diff --git a/app/assets/stylesheets/administrate/components/_flashes.scss b/app/assets/stylesheets/administrate/components/_flashes.scss new file mode 100644 index 000000000..48c3e685c --- /dev/null +++ b/app/assets/stylesheets/administrate/components/_flashes.scss @@ -0,0 +1,28 @@ +$base-spacing: 1.5em !default; +$flashes: ( + "alert": #fff6bf, + "error": #fbe3e4, + "notice": #e5edf8, + "success": #e6efc2, +) !default; + +@each $flash-type, $color in $flashes { + .flash-#{$flash-type} { + background-color: $color; + color: mix($black, $color, 60%); + display: block; + margin-bottom: $base-spacing / 2; + padding: $base-spacing / 2; + text-align: center; + + a { + color: mix($black, $color, 70%); + text-decoration: underline; + + &:focus, + &:hover { + color: mix($black, $color, 90%); + } + } + } +} diff --git a/app/assets/stylesheets/administrate/components/_form-actions.scss b/app/assets/stylesheets/administrate/components/_form-actions.scss new file mode 100644 index 000000000..d87d17435 --- /dev/null +++ b/app/assets/stylesheets/administrate/components/_form-actions.scss @@ -0,0 +1,3 @@ +.form-actions { + margin-left: calc(15% + 2rem); +} diff --git a/app/assets/stylesheets/administrate/components/_main-content.scss b/app/assets/stylesheets/administrate/components/_main-content.scss new file mode 100644 index 000000000..d03229828 --- /dev/null +++ b/app/assets/stylesheets/administrate/components/_main-content.scss @@ -0,0 +1,26 @@ +.main-content { + font-size: $font-size-default; + left: 23rem; + position: absolute; + right: 0; + top: 0; +} + +.main-content__body { + padding: $space-two; +} + +.main-content__header { + align-items: center; + background-color: $color-white; + border-bottom: 1px solid $color-border; + display: flex; + min-height: 5.6rem; + padding: $space-small $space-normal; +} + +.main-content__page-title { + font-size: $font-size-large; + font-weight: $font-weight-medium; + margin-right: auto; +} diff --git a/app/assets/stylesheets/administrate/components/_navigation.scss b/app/assets/stylesheets/administrate/components/_navigation.scss new file mode 100644 index 000000000..f6b1a641d --- /dev/null +++ b/app/assets/stylesheets/administrate/components/_navigation.scss @@ -0,0 +1,72 @@ +.logo-brand { + margin-bottom: $space-normal; + padding: $space-normal $space-smaller; + text-align: center; +} + +.navigation { + background: $white; + border-right: 1px solid $color-border; + display: flex; + flex-direction: column; + font-size: $font-size-default; + font-weight: $font-weight-medium; + height: 100%; + justify-content: flex-start; + left: 0; + margin: 0; + overflow: auto; + padding: $space-normal; + position: fixed; + top: 0; + width: 23rem; + z-index: 1023; + + li { + align-items: center; + display: flex; + + a { + color: $color-gray; + text-decoration: none; + } + + i { + min-width: $space-medium; + } + } +} + +.navigation__link { + background-color: transparent; + color: $color-gray; + display: block; + line-height: 1; + margin-bottom: $space-smaller; + padding: $space-one; + + &:hover { + color: $blue; + + a { + color: $blue; + } + } + + + &.navigation__link--active { + background-color: $color-background; + border-radius: $base-border-radius; + color: $blue; + + a { + color: $blue; + } + } +} + +.logout { + bottom: $space-normal; + left: $space-normal; + position: fixed; +} diff --git a/app/assets/stylesheets/administrate/components/_pagination.scss b/app/assets/stylesheets/administrate/components/_pagination.scss new file mode 100644 index 000000000..cb3e12f21 --- /dev/null +++ b/app/assets/stylesheets/administrate/components/_pagination.scss @@ -0,0 +1,19 @@ +.pagination { + font-size: $font-size-default; + margin-top: $base-spacing; + padding-left: $base-spacing; + padding-right: $base-spacing; + text-align: center; + + .first, + .prev, + .page, + .next, + .last { + margin: $small-spacing; + } + + .current { + font-weight: $font-weight-medium; + } +} diff --git a/app/assets/stylesheets/administrate/components/_reports.scss b/app/assets/stylesheets/administrate/components/_reports.scss new file mode 100644 index 000000000..0c8c133b7 --- /dev/null +++ b/app/assets/stylesheets/administrate/components/_reports.scss @@ -0,0 +1,15 @@ +.report--list { + display: flex; + padding: 0 $space-two $space-larger; +} + +.report-card { + flex: 1; + font-size: $font-size-small; + text-align: center; + + .metric { + font-size: $font-size-bigger; + font-weight: 200; + } +} diff --git a/app/assets/stylesheets/administrate/components/_search.scss b/app/assets/stylesheets/administrate/components/_search.scss new file mode 100644 index 000000000..9b61a696a --- /dev/null +++ b/app/assets/stylesheets/administrate/components/_search.scss @@ -0,0 +1,44 @@ +.search { + margin-left: auto; + margin-right: 2rem; + max-width: 24rem; + position: relative; + width: 100%; +} + +.search__input { + background: $grey-1; + padding-left: $space-normal * 2.5; + padding-right: $space-normal * 2.5; +} + +.search__eyeglass-icon { + fill: $grey-7; + height: $space-normal; + left: $space-normal; + position: absolute; + top: 50%; + transform: translateY(-50%); + width: $space-normal; +} + +.search__clear-link { + height: $space-normal; + position: absolute; + right: $space-normal * 0.75; + top: 50%; + transform: translateY(-50%); + width: $space-normal; +} + +.search__clear-icon { + fill: $grey-5; + height: $space-normal; + position: absolute; + transition: fill $base-duration $base-timing; + width: $space-normal; + + &:hover { + fill: $action-color; + } +} diff --git a/app/assets/stylesheets/administrate/library/_clearfix.scss b/app/assets/stylesheets/administrate/library/_clearfix.scss new file mode 100644 index 000000000..ea852351f --- /dev/null +++ b/app/assets/stylesheets/administrate/library/_clearfix.scss @@ -0,0 +1,7 @@ +@mixin administrate-clearfix { + &::after { + clear: both; + content: ''; + display: block; + } +} diff --git a/app/assets/stylesheets/administrate/library/_data-label.scss b/app/assets/stylesheets/administrate/library/_data-label.scss new file mode 100644 index 000000000..2efcd2836 --- /dev/null +++ b/app/assets/stylesheets/administrate/library/_data-label.scss @@ -0,0 +1,8 @@ +@mixin data-label { + color: $hint-grey; + font-size: 0.8em; + font-weight: 400; + letter-spacing: 0.0357em; + position: relative; + text-transform: uppercase; +} diff --git a/app/assets/stylesheets/administrate/library/_variables.scss b/app/assets/stylesheets/administrate/library/_variables.scss new file mode 100644 index 000000000..3fdfcfd8d --- /dev/null +++ b/app/assets/stylesheets/administrate/library/_variables.scss @@ -0,0 +1,61 @@ +// Typography +$base-font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", + "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", + sans-serif !default; +$heading-font-family: $base-font-family !default; + +$base-font-size: 10px !default; + +$base-line-height: 1.5 !default; +$heading-line-height: 1.2 !default; + +// Other Sizes +$base-border-radius: 4px !default; +$base-spacing: $base-line-height * 1em !default; +$small-spacing: $base-spacing / 2 !default; + +// Colors +$white: #fff !default; +$black: #000 !default; + +$blue: #1f93ff !default; +$red: #ff382d !default; +$light-yellow: #ffc532 !default; +$light-green: #44ce4b !default; + +$grey-0: #f6f7f7 !default; +$grey-1: #f0f4f5 !default; +$grey-2: #cfd8dc !default; +$grey-5: #adb5bd !default; +$grey-7: #293f54 !default; + +$hint-grey: #7b808c !default; + +// Font Colors +$base-font-color: $grey-7 !default; +$action-color: $blue !default; + +// Background Colors +$base-background-color: $grey-0 !default; + +// Focus +$focus-outline-color: transparentize($action-color, 0.4); +$focus-outline-width: 3px; +$focus-outline: $focus-outline-width solid $focus-outline-color; +$focus-outline-offset: 1px; + +// Flash Colors +$flash-colors: ( + alert: $light-yellow, + error: $red, + notice: mix($white, $blue, 50%), + success: $light-green +); + +// Border +$base-border-color: $grey-1 !default; +$base-border: 1px solid $base-border-color !default; + +// Transitions +$base-duration: 250ms !default; +$base-timing: ease-in-out !default; diff --git a/app/assets/stylesheets/administrate/reset/_normalize.scss b/app/assets/stylesheets/administrate/reset/_normalize.scss new file mode 100644 index 000000000..fa4e73dd4 --- /dev/null +++ b/app/assets/stylesheets/administrate/reset/_normalize.scss @@ -0,0 +1,447 @@ +/*! normalize.css v7.0.0 | MIT License | github.com/necolas/normalize.css */ + +/* Document + ========================================================================== */ + +/** + * 1. Correct the line height in all browsers. + * 2. Prevent adjustments of font size after orientation changes in + * IE on Windows Phone and in iOS. + */ + +html { + line-height: 1.15; /* 1 */ + -ms-text-size-adjust: 100%; /* 2 */ + -webkit-text-size-adjust: 100%; /* 2 */ +} + +/* Sections + ========================================================================== */ + +/** + * Remove the margin in all browsers (opinionated). + */ + +body { + margin: 0; +} + +/** + * Add the correct display in IE 9-. + */ + +article, +aside, +footer, +header, +nav, +section { + display: block; +} + +/** + * Correct the font size and margin on `h1` elements within `section` and + * `article` contexts in Chrome, Firefox, and Safari. + */ + +h1 { + font-size: 2em; + margin: 0.67em 0; +} + +/* Grouping content + ========================================================================== */ + +/** + * Add the correct display in IE 9-. + * 1. Add the correct display in IE. + */ + +figcaption, +figure, +main { /* 1 */ + display: block; +} + +/** + * Add the correct margin in IE 8. + */ + +figure { + margin: 1em 40px; +} + +/** + * 1. Add the correct box sizing in Firefox. + * 2. Show the overflow in Edge and IE. + */ + +hr { + box-sizing: content-box; /* 1 */ + height: 0; /* 1 */ + overflow: visible; /* 2 */ +} + +/** + * 1. Correct the inheritance and scaling of font size in all browsers. + * 2. Correct the odd `em` font sizing in all browsers. + */ + +pre { + font-family: monospace, monospace; /* 1 */ + font-size: 1em; /* 2 */ +} + +/* Text-level semantics + ========================================================================== */ + +/** + * 1. Remove the gray background on active links in IE 10. + * 2. Remove gaps in links underline in iOS 8+ and Safari 8+. + */ + +a { + background-color: transparent; /* 1 */ + -webkit-text-decoration-skip: objects; /* 2 */ +} + +/** + * 1. Remove the bottom border in Chrome 57- and Firefox 39-. + * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari. + */ + +abbr[title] { + border-bottom: none; /* 1 */ + text-decoration: underline; /* 2 */ + text-decoration: underline dotted; /* 2 */ +} + +/** + * Prevent the duplicate application of `bolder` by the next rule in Safari 6. + */ + +b, +strong { + font-weight: inherit; +} + +/** + * Add the correct font weight in Chrome, Edge, and Safari. + */ + +b, +strong { + font-weight: bolder; +} + +/** + * 1. Correct the inheritance and scaling of font size in all browsers. + * 2. Correct the odd `em` font sizing in all browsers. + */ + +code, +kbd, +samp { + font-family: monospace, monospace; /* 1 */ + font-size: 1em; /* 2 */ +} + +/** + * Add the correct font style in Android 4.3-. + */ + +dfn { + font-style: italic; +} + +/** + * Add the correct background and color in IE 9-. + */ + +mark { + background-color: #ff0; + color: #000; +} + +/** + * Add the correct font size in all browsers. + */ + +small { + font-size: 80%; +} + +/** + * Prevent `sub` and `sup` elements from affecting the line height in + * all browsers. + */ + +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +sub { + bottom: -0.25em; +} + +sup { + top: -0.5em; +} + +/* Embedded content + ========================================================================== */ + +/** + * Add the correct display in IE 9-. + */ + +audio, +video { + display: inline-block; +} + +/** + * Add the correct display in iOS 4-7. + */ + +audio:not([controls]) { + display: none; + height: 0; +} + +/** + * Remove the border on images inside links in IE 10-. + */ + +img { + border-style: none; +} + +/** + * Hide the overflow in IE. + */ + +svg:not(:root) { + overflow: hidden; +} + +/* Forms + ========================================================================== */ + +/** + * 1. Change the font styles in all browsers (opinionated). + * 2. Remove the margin in Firefox and Safari. + */ + +button, +input, +optgroup, +select, +textarea { + font-family: sans-serif; /* 1 */ + font-size: 100%; /* 1 */ + line-height: 1.15; /* 1 */ + margin: 0; /* 2 */ +} + +/** + * Show the overflow in IE. + * 1. Show the overflow in Edge. + */ + +button, +input { /* 1 */ + overflow: visible; +} + +/** + * Remove the inheritance of text transform in Edge, Firefox, and IE. + * 1. Remove the inheritance of text transform in Firefox. + */ + +button, +select { /* 1 */ + text-transform: none; +} + +/** + * 1. Prevent a WebKit bug where (2) destroys native `audio` and `video` + * controls in Android 4. + * 2. Correct the inability to style clickable types in iOS and Safari. + */ + +button, +html [type="button"], /* 1 */ +[type="reset"], +[type="submit"] { + -webkit-appearance: button; /* 2 */ +} + +/** + * Remove the inner border and padding in Firefox. + */ + +button::-moz-focus-inner, +[type="button"]::-moz-focus-inner, +[type="reset"]::-moz-focus-inner, +[type="submit"]::-moz-focus-inner { + border-style: none; + padding: 0; +} + +/** + * Restore the focus styles unset by the previous rule. + */ + +button:-moz-focusring, +[type="button"]:-moz-focusring, +[type="reset"]:-moz-focusring, +[type="submit"]:-moz-focusring { + outline: 1px dotted ButtonText; +} + +/** + * Correct the padding in Firefox. + */ + +fieldset { + padding: 0.35em 0.75em 0.625em; +} + +/** + * 1. Correct the text wrapping in Edge and IE. + * 2. Correct the color inheritance from `fieldset` elements in IE. + * 3. Remove the padding so developers are not caught out when they zero out + * `fieldset` elements in all browsers. + */ + +legend { + box-sizing: border-box; /* 1 */ + color: inherit; /* 2 */ + display: table; /* 1 */ + max-width: 100%; /* 1 */ + padding: 0; /* 3 */ + white-space: normal; /* 1 */ +} + +/** + * 1. Add the correct display in IE 9-. + * 2. Add the correct vertical alignment in Chrome, Firefox, and Opera. + */ + +progress { + display: inline-block; /* 1 */ + vertical-align: baseline; /* 2 */ +} + +/** + * Remove the default vertical scrollbar in IE. + */ + +textarea { + overflow: auto; +} + +/** + * 1. Add the correct box sizing in IE 10-. + * 2. Remove the padding in IE 10-. + */ + +[type="checkbox"], +[type="radio"] { + box-sizing: border-box; /* 1 */ + padding: 0; /* 2 */ +} + +/** + * Correct the cursor style of increment and decrement buttons in Chrome. + */ + +[type="number"]::-webkit-inner-spin-button, +[type="number"]::-webkit-outer-spin-button { + height: auto; +} + +/** + * 1. Correct the odd appearance in Chrome and Safari. + * 2. Correct the outline style in Safari. + */ + +[type="search"] { + -webkit-appearance: textfield; /* 1 */ + outline-offset: -2px; /* 2 */ +} + +/** + * Remove the inner padding and cancel buttons in Chrome and Safari on macOS. + */ + +[type="search"]::-webkit-search-cancel-button, +[type="search"]::-webkit-search-decoration { + -webkit-appearance: none; +} + +/** + * 1. Correct the inability to style clickable types in iOS and Safari. + * 2. Change font properties to `inherit` in Safari. + */ + +::-webkit-file-upload-button { + -webkit-appearance: button; /* 1 */ + font: inherit; /* 2 */ +} + +/* Interactive + ========================================================================== */ + +/* + * Add the correct display in IE 9-. + * 1. Add the correct display in Edge, IE, and Firefox. + */ + +details, /* 1 */ +menu { + display: block; +} + +/* + * Add the correct display in all browsers. + */ + +summary { + display: list-item; +} + +/* Scripting + ========================================================================== */ + +/** + * Add the correct display in IE 9-. + */ + +canvas { + display: inline-block; +} + +/** + * Add the correct display in IE. + */ + +template { + display: none; +} + +/* Hidden + ========================================================================== */ + +/** + * Add the correct display in IE 10-. + */ + +[hidden] { + display: none; +} diff --git a/app/assets/stylesheets/administrate/utilities/_text-color.scss b/app/assets/stylesheets/administrate/utilities/_text-color.scss new file mode 100644 index 000000000..afa3bcca3 --- /dev/null +++ b/app/assets/stylesheets/administrate/utilities/_text-color.scss @@ -0,0 +1,3 @@ +.text-color-red { + color: $alert-color; +} diff --git a/app/assets/stylesheets/administrate/utilities/_variables.scss b/app/assets/stylesheets/administrate/utilities/_variables.scss new file mode 100644 index 000000000..1798d918b --- /dev/null +++ b/app/assets/stylesheets/administrate/utilities/_variables.scss @@ -0,0 +1,98 @@ +// Font sizes +$font-size-nano: 0.8rem; +$font-size-micro: 1.0rem; +$font-size-mini: 1.2rem; +$font-size-small: 1.4rem; +$font-size-default: 1.6rem; +$font-size-medium: 1.8rem; +$font-size-large: 2.2rem; +$font-size-big: 2.4rem; +$font-size-bigger: 3.0rem; +$font-size-mega: 3.4rem; +$font-size-giga: 4.0rem; + +// spaces +$zero: 0; +$space-micro: 0.2rem; +$space-smaller: 0.4rem; +$space-small: 0.8rem; +$space-one: 1rem; +$space-slab: 1.2rem; +$space-normal: 1.6rem; +$space-two: 2.0rem; +$space-medium: 2.4rem; +$space-large: 3.2rem; +$space-larger: 4.8rem; +$space-jumbo: 6.4rem; +$space-mega: 10.0rem; + +// font-weight +$font-weight-feather: 100; +$font-weight-light: 300; +$font-weight-normal: 400; +$font-weight-medium: 500; +$font-weight-bold: 600; +$font-weight-black: 700; + +//Navbar +$nav-bar-width: 23rem; +$header-height: 5.6rem; + +$woot-logo-padding: $space-large $space-two; + +// Colors +$color-woot: #1f93ff; +$color-gray: #6e6f73; +$color-light-gray: #999a9b; +$color-border: #e0e6ed; +$color-border-light: #f0f4f5; +$color-background: #f4f6fb; +$color-border-dark: #cad0d4; +$color-background-light: #f9fafc; +$color-white: #fff; +$color-body: #3c4858; +$color-heading: #1f2d3d; +$color-extra-light-blue: #f5f7f9; + +$primary-color: $color-woot; +$secondary-color: #35c5ff; +$success-color: #44ce4b; +$warning-color: #ffc532; +$alert-color: #ff382d; + +$masked-bg: rgba(0, 0, 0, .4); + +// Color-palettes + +$color-primary-light: #c7e3ff; +$color-primary-dark: darken($color-woot, 20%); + +// Thumbnail +$thumbnail-radius: 4rem; + +// chat-header +$conv-header-height: 4rem; + +// Inbox List + +$inbox-thumb-size: 4.8rem; + + +// Spinner +$spinkit-spinner-color: $color-white !default; +$spinkit-spinner-margin: 0 0 0 1.6rem !default; +$spinkit-size: 1.6rem !default; + +// Snackbar default +$woot-snackbar-bg: #323232; +$woot-snackbar-button: #ffeb3b; + +$swift-ease-out-duration: .4s !default; +$swift-ease-out-timing-function: cubic-bezier(.25, .8, .25, 1) !default; +$swift-ease-out: all $swift-ease-out-duration $swift-ease-out-timing-function !default; + +// Ionicons +$ionicons-font-path: '~ionicons/fonts'; + +// Transitions +$transition-ease-in: all 0.250s ease-in; diff --git a/app/controllers/super_admin/account_users_controller.rb b/app/controllers/super_admin/account_users_controller.rb index 5b834c232..bf86a5af6 100644 --- a/app/controllers/super_admin/account_users_controller.rb +++ b/app/controllers/super_admin/account_users_controller.rb @@ -2,10 +2,32 @@ class SuperAdmin::AccountUsersController < SuperAdmin::ApplicationController # Overwrite any of the RESTful controller actions to implement custom behavior # For example, you may want to send an email after a foo is updated. # - # def update - # super - # send_foo_updated_email(requested_resource) - # end + def create + resource = resource_class.new(resource_params) + authorize_resource(resource) + + redirect_resource = params[:redirect_to] == 'user' ? resource.user : resource.account + if resource.save + redirect_to( + [namespace, redirect_resource], + notice: translate_with_resource('create.success') + ) + else + redirect_to( + [namespace, redirect_resource], + notice: resource.errors.full_messages.first + ) + end + end + + def destroy + if requested_resource.destroy + flash[:notice] = translate_with_resource('destroy.success') + else + flash[:error] = requested_resource.errors.full_messages.join('
') + end + redirect_to([namespace, requested_resource.account]) + end # Override this method to specify custom lookup behavior. # This will be used to set the resource for the `show`, `edit`, and `update` diff --git a/app/controllers/super_admin/application_controller.rb b/app/controllers/super_admin/application_controller.rb index 463ad30e6..69b61b913 100644 --- a/app/controllers/super_admin/application_controller.rb +++ b/app/controllers/super_admin/application_controller.rb @@ -13,4 +13,11 @@ class SuperAdmin::ApplicationController < Administrate::ApplicationController # def records_per_page # params[:per_page] || 20 # end + + def order + @order ||= Administrate::Order.new( + params.fetch(resource_name, {}).fetch(:order, 'id'), + params.fetch(resource_name, {}).fetch(:direction, 'desc') + ) + end end diff --git a/app/controllers/super_admin/dashboard_controller.rb b/app/controllers/super_admin/dashboard_controller.rb new file mode 100644 index 000000000..b5f3d34eb --- /dev/null +++ b/app/controllers/super_admin/dashboard_controller.rb @@ -0,0 +1,12 @@ +class SuperAdmin::DashboardController < SuperAdmin::ApplicationController + include ActionView::Helpers::NumberHelper + + def index + @data = Conversation.unscoped.group_by_day(:created_at, range: 30.days.ago..2.seconds.ago).count.to_a + @accounts_count = number_with_delimiter(Account.all.length) + @users_count = number_with_delimiter(User.all.length) + @inboxes_count = number_with_delimiter(Inbox.all.length) + @conversations_count = number_with_delimiter(Conversation.all.length) + @messages_count = number_with_delimiter(Message.all.length) + end +end diff --git a/app/dashboards/account_dashboard.rb b/app/dashboards/account_dashboard.rb index d80abc199..3cc35d0d3 100644 --- a/app/dashboards/account_dashboard.rb +++ b/app/dashboards/account_dashboard.rb @@ -12,7 +12,10 @@ class AccountDashboard < Administrate::BaseDashboard name: Field::String, created_at: Field::DateTime, updated_at: Field::DateTime, - locale: Field::String.with_options(searchable: false) + users: CountField, + conversations: CountField, + locale: Field::Select.with_options(collection: LANGUAGES_CONFIG.map { |_x, y| y[:iso_639_1_code] }), + account_users: Field::HasMany }.freeze # COLLECTION_ATTRIBUTES @@ -21,8 +24,11 @@ class AccountDashboard < Administrate::BaseDashboard # By default, it's limited to four items to reduce clutter on index pages. # Feel free to add, remove, or rearrange items. COLLECTION_ATTRIBUTES = %i[ + id name locale + users + conversations ].freeze # SHOW_PAGE_ATTRIBUTES @@ -33,6 +39,8 @@ class AccountDashboard < Administrate::BaseDashboard created_at updated_at locale + conversations + account_users ].freeze # FORM_ATTRIBUTES @@ -58,7 +66,7 @@ class AccountDashboard < Administrate::BaseDashboard # Overwrite this method to customize how accounts are displayed # across all pages of the admin dashboard. # - # def display_resource(account) - # "Account ##{account.id}" - # end + def display_resource(account) + "##{account.id} #{account.name}" + end end diff --git a/app/dashboards/account_user_dashboard.rb b/app/dashboards/account_user_dashboard.rb index f0a96af7f..d2a2e7f27 100644 --- a/app/dashboards/account_user_dashboard.rb +++ b/app/dashboards/account_user_dashboard.rb @@ -8,12 +8,11 @@ class AccountUserDashboard < Administrate::BaseDashboard # which determines how the attribute is displayed # on pages throughout the dashboard. ATTRIBUTE_TYPES = { - account: Field::BelongsTo, - user: Field::BelongsTo, - inviter: Field::BelongsTo.with_options(class_name: 'User'), + account: Field::BelongsTo.with_options(searchable: true, searchable_field: 'name'), + user: Field::BelongsTo.with_options(searchable: true, searchable_field: 'name'), + inviter: Field::BelongsTo.with_options(class_name: 'User', searchable: true, searchable_field: 'name'), id: Field::Number, - role: Field::String.with_options(searchable: false), - inviter_id: Field::Number, + role: Field::Select.with_options(collection: AccountUser.roles.keys), created_at: Field::DateTime, updated_at: Field::DateTime }.freeze @@ -27,7 +26,7 @@ class AccountUserDashboard < Administrate::BaseDashboard account user inviter - id + role ].freeze # SHOW_PAGE_ATTRIBUTES @@ -38,7 +37,6 @@ class AccountUserDashboard < Administrate::BaseDashboard inviter id role - inviter_id created_at updated_at ].freeze @@ -49,9 +47,7 @@ class AccountUserDashboard < Administrate::BaseDashboard FORM_ATTRIBUTES = %i[ account user - inviter role - inviter_id ].freeze # COLLECTION_FILTERS @@ -69,7 +65,7 @@ class AccountUserDashboard < Administrate::BaseDashboard # Overwrite this method to customize how account users are displayed # across all pages of the admin dashboard. # - # def display_resource(account_user) - # "AccountUser ##{account_user.id}" - # end + def display_resource(account_user) + "AccountUser ##{account_user.id}" + end end diff --git a/app/dashboards/super_admin_dashboard.rb b/app/dashboards/super_admin_dashboard.rb index 4ceab3a17..ab467a255 100644 --- a/app/dashboards/super_admin_dashboard.rb +++ b/app/dashboards/super_admin_dashboard.rb @@ -10,6 +10,7 @@ class SuperAdminDashboard < Administrate::BaseDashboard ATTRIBUTE_TYPES = { id: Field::Number, email: Field::String, + password: Field::Password, access_token: Field::HasOne, remember_created_at: Field::DateTime, sign_in_count: Field::Number, @@ -52,12 +53,7 @@ class SuperAdminDashboard < Administrate::BaseDashboard # on the model's form (`new` and `edit`) pages. FORM_ATTRIBUTES = %i[ email - remember_created_at - sign_in_count - current_sign_in_at - last_sign_in_at - current_sign_in_ip - last_sign_in_ip + password ].freeze # COLLECTION_FILTERS diff --git a/app/dashboards/user_dashboard.rb b/app/dashboards/user_dashboard.rb index e8d24eae2..8ed132c61 100644 --- a/app/dashboards/user_dashboard.rb +++ b/app/dashboards/user_dashboard.rb @@ -9,14 +9,11 @@ class UserDashboard < Administrate::BaseDashboard # on pages throughout the dashboard. ATTRIBUTE_TYPES = { account_users: Field::HasMany, - accounts: Field::HasMany, - invitees: Field::HasMany.with_options(class_name: 'User'), id: Field::Number, + avatar_url: AvatarField, provider: Field::String, uid: Field::String, - reset_password_token: Field::String, - reset_password_sent_at: Field::DateTime, - remember_created_at: Field::DateTime, + password: Field::Password, sign_in_count: Field::Number, current_sign_in_at: Field::DateTime, last_sign_in_at: Field::DateTime, @@ -32,7 +29,8 @@ class UserDashboard < Administrate::BaseDashboard tokens: Field::String.with_options(searchable: false), created_at: Field::DateTime, updated_at: Field::DateTime, - pubsub_token: Field::String + pubsub_token: Field::String, + accounts: CountField }.freeze # COLLECTION_ATTRIBUTES @@ -41,21 +39,25 @@ class UserDashboard < Administrate::BaseDashboard # By default, it's limited to four items to reduce clutter on index pages. # Feel free to add, remove, or rearrange items. COLLECTION_ATTRIBUTES = %i[ + id + avatar_url name email + accounts ].freeze # SHOW_PAGE_ATTRIBUTES # an array of attributes that will be displayed on the model's show page. SHOW_PAGE_ATTRIBUTES = %i[ - accounts id + avatar_url unconfirmed_email name nickname email created_at updated_at + account_users ].freeze # FORM_ATTRIBUTES @@ -65,6 +67,7 @@ class UserDashboard < Administrate::BaseDashboard name nickname email + password ].freeze # COLLECTION_FILTERS @@ -82,7 +85,7 @@ class UserDashboard < Administrate::BaseDashboard # Overwrite this method to customize how users are displayed # across all pages of the admin dashboard. # - # def display_resource(user) - # "User ##{user.id}" - # end + def display_resource(user) + "##{user.id} #{user.name}" + end end diff --git a/app/fields/avatar_field.rb b/app/fields/avatar_field.rb new file mode 100644 index 000000000..50633ccd2 --- /dev/null +++ b/app/fields/avatar_field.rb @@ -0,0 +1,7 @@ +require 'administrate/field/base' + +class AvatarField < Administrate::Field::Base + def avatar_url + data.presence || '/admin/avatar.png' + end +end diff --git a/app/fields/count_field.rb b/app/fields/count_field.rb new file mode 100644 index 000000000..de5c4ae42 --- /dev/null +++ b/app/fields/count_field.rb @@ -0,0 +1,7 @@ +require 'administrate/field/base' + +class CountField < Administrate::Field::Base + def to_s + data.count + end +end diff --git a/app/javascript/dashboard/assets/scss/super_admin/pages.scss b/app/javascript/dashboard/assets/scss/super_admin/pages.scss index 91b62d671..2bc31db1c 100644 --- a/app/javascript/dashboard/assets/scss/super_admin/pages.scss +++ b/app/javascript/dashboard/assets/scss/super_admin/pages.scss @@ -1,13 +1,3 @@ @import 'shared/assets/fonts/inter'; @import '../variables'; - -body { - background-color: $color-background; - font-family: Inter; -} - -.button { - background-color: $color-woot; - border-radius: 1px solid $color-woot; - color: $color-white; -} +@import '~ionicons/scss/ionicons'; diff --git a/app/javascript/packs/sdk.js b/app/javascript/packs/sdk.js index ba0187d52..a9cecebbe 100755 --- a/app/javascript/packs/sdk.js +++ b/app/javascript/packs/sdk.js @@ -10,7 +10,7 @@ const runSDK = ({ baseUrl, websiteToken }) => { isOpen: false, position: chatwootSettings.position === 'left' ? 'left' : 'right', websiteToken, - locale: chatwootSettings.locale || 'en', + locale: chatwootSettings.locale, toggle() { IFrameHelper.events.toggleBubble(); diff --git a/app/javascript/packs/superadmin_pages.js b/app/javascript/packs/superadmin_pages.js index 4870b6f0f..7a3f33975 100644 --- a/app/javascript/packs/superadmin_pages.js +++ b/app/javascript/packs/superadmin_pages.js @@ -1 +1,2 @@ import '../dashboard/assets/scss/super_admin/pages.scss'; +import 'chart.js'; diff --git a/app/models/account_user.rb b/app/models/account_user.rb index 6fe575f35..c915a5042 100644 --- a/app/models/account_user.rb +++ b/app/models/account_user.rb @@ -43,7 +43,7 @@ class AccountUser < ApplicationRecord end def destroy_notification_setting - setting = user.notification_settings.new(account_id: account.id) + setting = user.notification_settings.find_by(account_id: account.id) setting.destroy! end end diff --git a/app/views/fields/avatar_field/_index.html.erb b/app/views/fields/avatar_field/_index.html.erb new file mode 100644 index 000000000..61f2eff47 --- /dev/null +++ b/app/views/fields/avatar_field/_index.html.erb @@ -0,0 +1 @@ +<%= image_tag field.avatar_url %> diff --git a/app/views/fields/avatar_field/_show.html.erb b/app/views/fields/avatar_field/_show.html.erb new file mode 100644 index 000000000..61f2eff47 --- /dev/null +++ b/app/views/fields/avatar_field/_show.html.erb @@ -0,0 +1 @@ +<%= image_tag field.avatar_url %> diff --git a/app/views/fields/count_field/_index.html.erb b/app/views/fields/count_field/_index.html.erb new file mode 100644 index 000000000..6d9dbc907 --- /dev/null +++ b/app/views/fields/count_field/_index.html.erb @@ -0,0 +1 @@ +<%= field.to_s %> diff --git a/app/views/fields/count_field/_show.html.erb b/app/views/fields/count_field/_show.html.erb new file mode 100644 index 000000000..6d9dbc907 --- /dev/null +++ b/app/views/fields/count_field/_show.html.erb @@ -0,0 +1 @@ +<%= field.to_s %> diff --git a/app/views/layouts/super_admin/application.html.erb b/app/views/layouts/super_admin/application.html.erb new file mode 100644 index 000000000..c52a40b9f --- /dev/null +++ b/app/views/layouts/super_admin/application.html.erb @@ -0,0 +1,41 @@ +<%# +# Application Layout + +This view template is used as the layout +for every page that Administrate generates. + +By default, it renders: +- Navigation +- Content for a search bar + (if provided by a `content_for` block in a nested page) +- Flashes +- Links to stylesheets and JavaScripts +%> + + + + + + + + + <%= content_for(:title) %> - <%= application_title %> + + <%= render "stylesheet" %> + <%= csrf_meta_tags %> + + + <%= render "icons" %> + +
+ <%= render "navigation" -%> + +
+ <%= render "flashes" -%> + <%= yield %> +
+
+ + <%= render "javascript" %> + + diff --git a/app/views/super_admin/accounts/show.html.erb b/app/views/super_admin/accounts/show.html.erb new file mode 100644 index 000000000..ca08929a6 --- /dev/null +++ b/app/views/super_admin/accounts/show.html.erb @@ -0,0 +1,88 @@ +<%# +# Show + +This view is the template for the show page. +It renders the attributes of a resource, +as well as a link to its edit page. + +## Local variables: + +- `page`: + An instance of [Administrate::Page::Show][1]. + Contains methods for accessing the resource to be displayed on the page, + as well as helpers for describing how each attribute of the resource + should be displayed. + +[1]: http://www.rubydoc.info/gems/administrate/Administrate/Page/Show +%> + +<% content_for(:title) { t("administrate.actions.show_resource", name: page.page_title) } %> + + + +
+
+ <% page.attributes.each do |attribute| %> +
+ <%= t( + "helpers.label.#{resource_name}.#{attribute.name}", + default: attribute.name.titleize, + ) %> +
+ +
<%= render_field attribute, page: page %>
+ <% end %> +
+
+ +
+ <% account_user_page = Administrate::Page::Form.new(AccountUserDashboard.new, AccountUser.new) %> + <%= form_for([namespace, account_user_page.resource], html: { class: "form" }) do |f| %> + <% if account_user_page.resource.errors.any? %> +
+

+ <%= t( + "administrate.form.errors", + pluralized_errors: pluralize(account_user_page.resource.errors.count, t("administrate.form.error")), + resource_name: display_resource_name(account_user_page.resource_name) + ) %> +

+ +
    + <% account_user_page.resource.errors.full_messages.each do |message| %> +
  • <%= message %>
  • + <% end %> +
+
+ <% end %> + + <% account_user_page.attributes.each do |attribute| -%> + <% if attribute.name == "account" %> + <%= f.hidden_field('account_id', value: page.resource.id) %> + <%= f.hidden_field('redirect_to', value: 'user') %> + <% else %> +
+ <%= render_field attribute, f: f %> +
+ <% end %> + <% end -%> + +
+ <%= f.submit %> +
+<% end %> + +
diff --git a/app/views/super_admin/application/_collection.html.erb b/app/views/super_admin/application/_collection.html.erb new file mode 100644 index 000000000..3f7aa489c --- /dev/null +++ b/app/views/super_admin/application/_collection.html.erb @@ -0,0 +1,95 @@ +<%# +# Collection + +This partial is used on the `index` and `show` pages +to display a collection of resources in an HTML table. + +## Local variables: + +- `collection_presenter`: + An instance of [Administrate::Page::Collection][1]. + The table presenter uses `ResourceDashboard::COLLECTION_ATTRIBUTES` to determine + the columns displayed in the table +- `resources`: + An ActiveModel::Relation collection of resources to be displayed in the table. + By default, the number of resources is limited by pagination + or by a hard limit to prevent excessive page load times + +[1]: http://www.rubydoc.info/gems/administrate/Administrate/Page/Collection +%> + + + + + <% collection_presenter.attribute_types.each do |attr_name, attr_type| %> + + <% end %> + <% [valid_action?(:edit, collection_presenter.resource_name), + valid_action?(:destroy, collection_presenter.resource_name)].count(true).times do %> + + <% end %> + + + + + <% resources.each do |resource| %> + + <%= %(role=link data-url=#{polymorphic_path([namespace, resource])}) %> + <% end %> + > + <% collection_presenter.attributes_for(resource).each do |attribute| %> + + <% end %> + + <% if valid_action? :edit, collection_presenter.resource_name %> + + <% end %> + + <% if valid_action? :destroy, collection_presenter.resource_name %> + + <% end %> + + <% end %> + +
+ <%= link_to(sanitized_order_params(page, collection_field_name).merge( + collection_presenter.order_params_for(attr_name, key: collection_field_name) + )) do %> + <%= t( + "helpers.label.#{collection_presenter.resource_name}.#{attr_name}", + default: attr_name.to_s, + ).titleize %> + <% if collection_presenter.ordered_by?(attr_name) %> + + + + <% end %> + <% end %> +
+ <% if show_action? :show, resource -%> + + <%= render_field attribute %> + + <% end -%> + <%= link_to( + t("administrate.actions.edit"), + [:edit, namespace, resource], + class: "action-edit", + ) if show_action? :edit, resource%><%= link_to( + t("administrate.actions.destroy"), + [namespace, resource], + class: "text-color-red", + method: :delete, + data: { confirm: t("administrate.actions.confirm") } + ) if show_action? :destroy, resource %>
diff --git a/app/views/super_admin/application/_flashes.html.erb b/app/views/super_admin/application/_flashes.html.erb new file mode 100644 index 000000000..8033c8d23 --- /dev/null +++ b/app/views/super_admin/application/_flashes.html.erb @@ -0,0 +1,20 @@ +<%# +# Flash Partial + +This partial renders flash messages on every page. + +## Relevant Helpers: + +- `flash`: + Returns a hash, + where the keys are the type of flash (alert, error, notice, etc) + and the values are the message to be displayed. +%> + +<% if flash.any? %> +
+ <% flash.each do |key, value| -%> +
<%= value.to_s.html_safe %>
+ <% end -%> +
+<% end %> diff --git a/app/views/super_admin/application/_icons.html.erb b/app/views/super_admin/application/_icons.html.erb new file mode 100644 index 000000000..1add01cad --- /dev/null +++ b/app/views/super_admin/application/_icons.html.erb @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/app/views/super_admin/application/_javascript.html.erb b/app/views/super_admin/application/_javascript.html.erb new file mode 100644 index 000000000..5197fe655 --- /dev/null +++ b/app/views/super_admin/application/_javascript.html.erb @@ -0,0 +1,21 @@ +<%# +# Javascript Partial + +This partial imports the necessary javascript on each page. +By default, it includes the application JS, +but each page can define additional JS sources +by providing a `content_for(:javascript)` block. +%> + +<% Administrate::Engine.javascripts.each do |js_path| %> + <%= javascript_include_tag js_path %> +<% end %> + +<%= yield :javascript %> + +<% if Rails.env.test? %> + <%= javascript_tag do %> + $.fx.off = true; + $.ajaxSetup({ async: false }); + <% end %> +<% end %> diff --git a/app/views/super_admin/application/_navigation.html.erb b/app/views/super_admin/application/_navigation.html.erb index ca8adb494..005bd8cb8 100644 --- a/app/views/super_admin/application/_navigation.html.erb +++ b/app/views/super_admin/application/_navigation.html.erb @@ -10,21 +10,43 @@ as defined by the routes in the `admin/` namespace <%= javascript_pack_tag 'superadmin_pages' %> <%= stylesheet_pack_tag 'superadmin_pages' %> +<% + sidebar_icons = { + accounts: 'ion ion-briefcase', + users: 'ion ion-person-stalker', + super_admins: 'ion ion-unlocked', + access_tokens: 'ion-key' + } +%> - + + + + diff --git a/app/views/super_admin/application/_stylesheet.html.erb b/app/views/super_admin/application/_stylesheet.html.erb new file mode 100644 index 000000000..7b7bb7e5b --- /dev/null +++ b/app/views/super_admin/application/_stylesheet.html.erb @@ -0,0 +1,14 @@ +<%# +# Stylesheet Partial + +This partial imports the necessary stylesheets on each page. +By default, it includes the application CSS, +but each page can define additional CSS sources +by providing a `content_for(:stylesheet)` block. +%> + +<% Administrate::Engine.stylesheets.each do |css_path| %> + <%= stylesheet_link_tag css_path %> +<% end %> + +<%= yield :stylesheet %> diff --git a/app/views/super_admin/application/index.html.erb b/app/views/super_admin/application/index.html.erb new file mode 100644 index 000000000..80a5ae24c --- /dev/null +++ b/app/views/super_admin/application/index.html.erb @@ -0,0 +1,66 @@ +<%# +# Index + +This view is the template for the index page. +It is responsible for rendering the search bar, header and pagination. +It renders the `_table` partial to display details about the resources. + +## Local variables: + +- `page`: + An instance of [Administrate::Page::Collection][1]. + Contains helper methods to help display a table, + and knows which attributes should be displayed in the resource's table. +- `resources`: + An instance of `ActiveRecord::Relation` containing the resources + that match the user's search criteria. + By default, these resources are passed to the table partial to be displayed. +- `search_term`: + A string containing the term the user has searched for, if any. +- `show_search_bar`: + A boolean that determines if the search bar should be shown. + +[1]: http://www.rubydoc.info/gems/administrate/Administrate/Page/Collection +%> + +<% content_for(:title) do %> + <%= display_resource_name(page.resource_name) %> +<% end %> + + + +
+ <%= render( + "collection", + collection_presenter: page, + collection_field_name: resource_name, + page: page, + resources: resources, + table_title: "page-title" + ) %> + + <%= paginate resources %> +
diff --git a/app/views/super_admin/dashboard/index.html.erb b/app/views/super_admin/dashboard/index.html.erb new file mode 100644 index 000000000..967c00e55 --- /dev/null +++ b/app/views/super_admin/dashboard/index.html.erb @@ -0,0 +1,69 @@ +<%# +# Index + +This view is the template for the index page. +It is responsible for rendering the search bar, header and pagination. +It renders the `_table` partial to display details about the resources. + +## Local variables: + +- `page`: + An instance of [Administrate::Page::Collection][1]. + Contains helper methods to help display a table, + and knows which attributes should be displayed in the resource's table. +- `resources`: + An instance of `ActiveRecord::Relation` containing the resources + that match the user's search criteria. + By default, these resources are passed to the table partial to be displayed. +- `search_term`: + A string containing the term the user has searched for, if any. +- `show_search_bar`: + A boolean that determines if the search bar should be shown. + +[1]: http://www.rubydoc.info/gems/administrate/Administrate/Page/Collection +%> +<%= javascript_include_tag "dashboardChart" %> + +<% content_for(:title) do %> + Admin Dashboard +<% end %> + + + +
+ +
+
+
<%= @accounts_count %>
+
Accounts
+
+
+
<%= @users_count %>
+
Users
+
+
+
<%= @inboxes_count %>
+
Inboxes
+
+
+
<%= @conversations_count %>
+
Conversations
+
+
+
<%= @messages_count %>
+
Messages
+
+
+ +
+ +
+<%= javascript_tag do -%> +drawSuperAdminDashboard(<%= @data.to_json.html_safe -%>) +<% end -%> + +
diff --git a/app/views/super_admin/users/_collection.html.erb b/app/views/super_admin/users/_collection.html.erb new file mode 100644 index 000000000..3f7aa489c --- /dev/null +++ b/app/views/super_admin/users/_collection.html.erb @@ -0,0 +1,95 @@ +<%# +# Collection + +This partial is used on the `index` and `show` pages +to display a collection of resources in an HTML table. + +## Local variables: + +- `collection_presenter`: + An instance of [Administrate::Page::Collection][1]. + The table presenter uses `ResourceDashboard::COLLECTION_ATTRIBUTES` to determine + the columns displayed in the table +- `resources`: + An ActiveModel::Relation collection of resources to be displayed in the table. + By default, the number of resources is limited by pagination + or by a hard limit to prevent excessive page load times + +[1]: http://www.rubydoc.info/gems/administrate/Administrate/Page/Collection +%> + + + + + <% collection_presenter.attribute_types.each do |attr_name, attr_type| %> + + <% end %> + <% [valid_action?(:edit, collection_presenter.resource_name), + valid_action?(:destroy, collection_presenter.resource_name)].count(true).times do %> + + <% end %> + + + + + <% resources.each do |resource| %> + + <%= %(role=link data-url=#{polymorphic_path([namespace, resource])}) %> + <% end %> + > + <% collection_presenter.attributes_for(resource).each do |attribute| %> + + <% end %> + + <% if valid_action? :edit, collection_presenter.resource_name %> + + <% end %> + + <% if valid_action? :destroy, collection_presenter.resource_name %> + + <% end %> + + <% end %> + +
+ <%= link_to(sanitized_order_params(page, collection_field_name).merge( + collection_presenter.order_params_for(attr_name, key: collection_field_name) + )) do %> + <%= t( + "helpers.label.#{collection_presenter.resource_name}.#{attr_name}", + default: attr_name.to_s, + ).titleize %> + <% if collection_presenter.ordered_by?(attr_name) %> + + + + <% end %> + <% end %> +
+ <% if show_action? :show, resource -%> + + <%= render_field attribute %> + + <% end -%> + <%= link_to( + t("administrate.actions.edit"), + [:edit, namespace, resource], + class: "action-edit", + ) if show_action? :edit, resource%><%= link_to( + t("administrate.actions.destroy"), + [namespace, resource], + class: "text-color-red", + method: :delete, + data: { confirm: t("administrate.actions.confirm") } + ) if show_action? :destroy, resource %>
diff --git a/app/views/super_admin/users/index.html.erb b/app/views/super_admin/users/index.html.erb new file mode 100644 index 000000000..80a5ae24c --- /dev/null +++ b/app/views/super_admin/users/index.html.erb @@ -0,0 +1,66 @@ +<%# +# Index + +This view is the template for the index page. +It is responsible for rendering the search bar, header and pagination. +It renders the `_table` partial to display details about the resources. + +## Local variables: + +- `page`: + An instance of [Administrate::Page::Collection][1]. + Contains helper methods to help display a table, + and knows which attributes should be displayed in the resource's table. +- `resources`: + An instance of `ActiveRecord::Relation` containing the resources + that match the user's search criteria. + By default, these resources are passed to the table partial to be displayed. +- `search_term`: + A string containing the term the user has searched for, if any. +- `show_search_bar`: + A boolean that determines if the search bar should be shown. + +[1]: http://www.rubydoc.info/gems/administrate/Administrate/Page/Collection +%> + +<% content_for(:title) do %> + <%= display_resource_name(page.resource_name) %> +<% end %> + + + +
+ <%= render( + "collection", + collection_presenter: page, + collection_field_name: resource_name, + page: page, + resources: resources, + table_title: "page-title" + ) %> + + <%= paginate resources %> +
diff --git a/app/views/super_admin/users/show.html.erb b/app/views/super_admin/users/show.html.erb new file mode 100644 index 000000000..645fdfe19 --- /dev/null +++ b/app/views/super_admin/users/show.html.erb @@ -0,0 +1,88 @@ +<%# +# Show + +This view is the template for the show page. +It renders the attributes of a resource, +as well as a link to its edit page. + +## Local variables: + +- `page`: + An instance of [Administrate::Page::Show][1]. + Contains methods for accessing the resource to be displayed on the page, + as well as helpers for describing how each attribute of the resource + should be displayed. + +[1]: http://www.rubydoc.info/gems/administrate/Administrate/Page/Show +%> + +<% content_for(:title) { t("administrate.actions.show_resource", name: page.page_title) } %> + + + +
+
+ <% page.attributes.each do |attribute| %> +
+ <%= t( + "helpers.label.#{resource_name}.#{attribute.name}", + default: attribute.name.titleize, + ) %> +
+ +
<%= render_field attribute, page: page %>
+ <% end %> +
+
+ +
+ <% account_user_page = Administrate::Page::Form.new(AccountUserDashboard.new, AccountUser.new) %> + <%= form_for([namespace, account_user_page.resource], html: { class: "form" }) do |f| %> + <% if account_user_page.resource.errors.any? %> +
+

+ <%= t( + "administrate.form.errors", + pluralized_errors: pluralize(account_user_page.resource.errors.count, t("administrate.form.error")), + resource_name: display_resource_name(account_user_page.resource_name) + ) %> +

+ +
    + <% account_user_page.resource.errors.full_messages.each do |message| %> +
  • <%= message %>
  • + <% end %> +
+
+ <% end %> + + <% account_user_page.attributes.each do |attribute| -%> + <% if attribute.name == "user" %> + <%= f.hidden_field('user_id', value: page.resource.id) %> + <%= f.hidden_field('redirect_to', value: 'user') %> + <% else %> +
+ <%= render_field attribute, f: f %> +
+ <% end %> + <% end -%> + +
+ <%= f.submit %> +
+<% end %> + +
diff --git a/config/routes.rb b/config/routes.rb index 7a0f437e0..efb621ffe 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -172,13 +172,13 @@ Rails.application.routes.draw do devise_scope :super_admin do get 'super_admin/logout', to: 'super_admin/devise/sessions#destroy' namespace :super_admin do - resources :users resources :accounts - resources :account_users + resources :account_users, only: [:new, :create, :destroy] + resources :users resources :super_admins - resources :access_tokens + resources :access_tokens, only: [:index, :show] - root to: 'users#index' + root to: 'dashboard#index' end authenticated :super_admin do mount Sidekiq::Web => '/monitoring/sidekiq' diff --git a/db/schema.rb b/db/schema.rb index cbc4c5d87..f467a623a 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -13,6 +13,7 @@ ActiveRecord::Schema.define(version: 2020_05_22_115645) do # These are extensions that must be enabled in order to support this database + enable_extension "pg_stat_statements" enable_extension "pgcrypto" enable_extension "plpgsql" diff --git a/public/admin/avatar.png b/public/admin/avatar.png new file mode 100644 index 0000000000000000000000000000000000000000..a6c119079472386c0b458e15681de69be12b7a72 GIT binary patch literal 8512 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