diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index ba13bf1f29c6a..536a268e55dea 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1 +1 @@ - + diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8a94b40452e7b..910120219b8dd 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -26,10 +26,13 @@ jobs: fail-fast: false matrix: - build_type: [backend, frontend] + build_type: [backend, frontend, annotations] target: [core, plugins] postgres: ["13"] redis: ["6.x"] + exclude: + - build_type: annotations + target: plugins services: postgres: @@ -111,7 +114,7 @@ jobs: - name: Core RSpec if: matrix.build_type == 'backend' && matrix.target == 'core' - run: bin/turbo_rspec + run: bin/turbo_rspec --verbose - name: Plugin RSpec if: matrix.build_type == 'backend' && matrix.target == 'plugins' @@ -131,3 +134,20 @@ jobs: if: matrix.build_type == 'frontend' && matrix.target == 'plugins' run: bin/rake plugin:qunit['*','1200000'] timeout-minutes: 30 + + - name: Check Annotations + if: matrix.build_type == 'annotations' + run: | + bin/rake annotate:ensure_all_indexes + bin/annotate --models --model-dir app/models + + if [ ! -z "$(git status --porcelain app/models/)" ]; then + echo "Core annotations are not up to date. To resolve, run:" + echo " bin/rake annotate:clean" + echo + echo "Or manually apply the diff printed below:" + echo "---------------------------------------------" + git -c color.ui=always diff app/models/ + exit 1 + fi + timeout-minutes: 30 diff --git a/COPYRIGHT.md b/COPYRIGHT.md new file mode 100644 index 0000000000000..80c989e96b58b --- /dev/null +++ b/COPYRIGHT.md @@ -0,0 +1,59 @@ +# Legal notice + +This program is free software; you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation; either version 2 of the License, or (at +your option) any later version. + +This program is distributed in the hope that it will be useful, but +WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +for more details. + +You should have received a copy of the GNU General Public License +along with this program as the file LICENSE.txt; if not, please see +http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + +## Trademark + +Discourse is a registered trademark of Civilized Discourse Construction Kit. + +## Other copyright notices + +Discourse includes works under other copyright notices and distributed +according to the terms of the GNU General Public License or a compatible +license (where indicated), including: + +- Ember.js - Copyright (c) 2020 Yehuda Katz, Tom Dale and Ember.js contributors + MIT License + +- jQuery - Copyright OpenJS Foundation and other contributors, https://openjsf.org/ + MIT License + +- Rails - Copyright (c) 2005-2021 David Heinemeier Hansson + MIT License + +- Onebox - Copyright (c) 2013 jzeta + MIT License + +MIT License: + +``` +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` diff --git a/COPYRIGHT.txt b/COPYRIGHT.txt deleted file mode 100644 index cb867e5604bbd..0000000000000 --- a/COPYRIGHT.txt +++ /dev/null @@ -1,31 +0,0 @@ -All Discourse code is Copyright 2013 by Civilized Discourse Construction Kit, Inc. - -This program is free software; you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation; either version 2 of the License, or (at -your option) any later version. - -This program is distributed in the hope that it will be useful, but -WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY -or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License -for more details. - -You should have received a copy of the GNU General Public License -along with this program as the file LICENSE.txt; if not, please see -http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. - -Discourse is a registered trademark of Civilized Discourse Construction Kit. - -Discourse includes works under other copyright notices and distributed -according to the terms of the GNU General Public License or a compatible -license (where indicated), including: - -Javascript - - Ember.js - Copyright (c) 2012-2013 Yehuda Katz, Tom Dale, Charles Jolley and Ember.js contributors - - jQuery - Copyright (c) 2010-2013 John Resig - -Ruby - - Rails - Copyright (c) 2005-2013 David Heinemeier Hansson, Rails Core Team contributors (MIT) diff --git a/Gemfile b/Gemfile index 71ee3f910c0ea..80d264469e92d 100644 --- a/Gemfile +++ b/Gemfile @@ -60,8 +60,6 @@ gem 'redis-namespace' # better maintained living fork gem 'active_model_serializers', '~> 0.8.3' -gem 'onebox' - gem 'http_accept_language', require: false # Ember related gems need to be pinned cause they control client side @@ -90,9 +88,7 @@ gem 'unf', require: false gem 'email_reply_trimmer' -# Forked until https://github.com/toy/image_optim/pull/162 is merged -# https://github.com/discourse/image_optim -gem 'discourse_image_optim', require: 'image_optim' +gem 'image_optim' gem 'multi_json' gem 'mustache' gem 'nokogiri' @@ -169,6 +165,8 @@ group :test, :development do gem 'parallel_tests' gem 'rswag-specs' + + gem 'annotate' end group :development do @@ -177,8 +175,8 @@ group :development do gem 'better_errors', platform: :mri, require: !!ENV['BETTER_ERRORS'] gem 'binding_of_caller' gem 'yaml-lint' - gem 'annotate' - gem 'discourse_dev' + gem 'discourse_dev_assets' + gem 'faker', "~> 2.16" end # this is an optional gem, it provides a high performance replacement @@ -231,6 +229,8 @@ gem 'sshkey', require: false gem 'rchardet', require: false gem 'lz4-ruby', require: false, platform: :ruby +gem 'sanitize' + if ENV["IMPORT"] == "1" gem 'mysql2' gem 'redcarpet' diff --git a/Gemfile.lock b/Gemfile.lock index 2bbc42e7d2819..e1a9665ad8949 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -46,7 +46,7 @@ GEM minitest (>= 5.1) tzinfo (~> 2.0) zeitwerk (~> 2.3) - addressable (2.7.0) + addressable (2.8.0) public_suffix (>= 2.0.2, < 5.0) annotate (3.1.1) activerecord (>= 3.2, < 7.0) @@ -59,10 +59,10 @@ GEM aws-partitions (~> 1, >= 1.239.0) aws-sigv4 (~> 1.1) jmespath (~> 1.0) - aws-sdk-kms (1.42.0) + aws-sdk-kms (1.44.0) aws-sdk-core (~> 3, >= 3.112.0) aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.90.0) + aws-sdk-s3 (1.96.1) aws-sdk-core (~> 3, >= 3.112.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.1) @@ -92,7 +92,7 @@ GEM chunky_png (1.4.0) coderay (1.1.3) colored2 (3.1.2) - concurrent-ruby (1.1.8) + concurrent-ruby (1.1.9) connection_pool (2.2.5) cose (1.2.0) cbor (~> 0.5.9) @@ -115,14 +115,9 @@ GEM railties (>= 3.1) discourse-ember-source (3.12.2.3) discourse-fonts (0.0.8) - discourse_dev (0.2.1) + discourse_dev_assets (0.0.3) faker (~> 2.16) - discourse_image_optim (0.26.2) - exifr (~> 1.2, >= 1.2.2) - fspath (~> 3.0) - image_size (~> 1.5) - in_threads (~> 1.3) - progress (~> 3.0, >= 3.0.1) + literate_randomizer docile (1.4.0) ecma-re-validator (0.3.0) regexp_parser (~> 2.0) @@ -134,26 +129,34 @@ GEM sprockets (>= 3.3, < 4.1) ember-source (2.18.2) erubi (1.10.0) - excon (0.81.0) + excon (0.85.0) execjs (2.8.1) exifr (1.3.9) fabrication (2.22.0) - faker (2.17.0) + faker (2.18.0) i18n (>= 1.6, < 2) fakeweb (1.3.0) - faraday (1.4.1) + faraday (1.5.1) + faraday-em_http (~> 1.0) + faraday-em_synchrony (~> 1.0) faraday-excon (~> 1.1) + faraday-httpclient (~> 1.0.1) faraday-net_http (~> 1.0) faraday-net_http_persistent (~> 1.1) + faraday-patron (~> 1.0) multipart-post (>= 1.2, < 3) ruby2_keywords (>= 0.0.4) + faraday-em_http (1.0.0) + faraday-em_synchrony (1.0.0) faraday-excon (1.1.0) + faraday-httpclient (1.0.1) faraday-net_http (1.0.1) - faraday-net_http_persistent (1.1.0) + faraday-net_http_persistent (1.2.0) + faraday-patron (1.0.0) fast_blank (1.0.0) fast_xs (0.8.0) - fastimage (2.2.3) - ffi (1.15.0) + fastimage (2.2.4) + ffi (1.15.3) fspath (3.1.2) gc_tracer (1.5.1) globalid (0.4.2) @@ -168,7 +171,13 @@ GEM http_accept_language (2.1.1) i18n (1.8.10) concurrent-ruby (~> 1.0) - image_size (1.5.0) + image_optim (0.30.0) + exifr (~> 1.2, >= 1.2.2) + fspath (~> 3.0) + image_size (>= 1.5, < 3) + in_threads (~> 1.3) + progress (~> 3.0, >= 3.0.1) + image_size (2.1.1) in_threads (1.5.4) jmespath (1.4.0) jquery-rails (4.4.0) @@ -184,7 +193,7 @@ GEM regexp_parser (~> 2.0) uri_template (~> 0.7) jwt (2.2.3) - kgio (2.11.3) + kgio (2.11.4) libv8-node (15.14.0.1) libv8-node (15.14.0.1-arm64-darwin-20) libv8-node (15.14.0.1-x86_64-darwin-18) @@ -194,6 +203,7 @@ GEM listen (3.5.1) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) + literate_randomizer (0.4.0) lograge (0.11.2) actionpack (>= 4) activesupport (>= 4) @@ -202,19 +212,19 @@ GEM logstash-event (1.2.02) logstash-logger (0.26.1) logstash-event (~> 1.2) - logster (2.9.6) - loofah (2.9.1) + logster (2.9.7) + loofah (2.10.0) crass (~> 1.0.2) nokogiri (>= 1.5.9) lru_redux (1.1.0) lz4-ruby (0.3.3) maxminddb (0.1.22) memory_profiler (1.0.0) - message_bus (3.3.5) + message_bus (3.3.6) rack (>= 1.1.3) method_source (1.0.0) mini_mime (1.1.0) - mini_portile2 (2.5.1) + mini_portile2 (2.5.3) mini_racer (0.4.0) libv8-node (~> 15.14.0.0) mini_scheduler (0.13.0) @@ -223,7 +233,7 @@ GEM mini_suffix (0.3.2) ffi (~> 1.9) minitest (5.14.4) - mocha (1.12.0) + mocha (1.13.0) mock_redis (0.28.0) ruby2_keywords msgpack (1.4.2) @@ -232,14 +242,14 @@ GEM multipart-post (2.1.1) mustache (1.1.1) nio4r (2.5.7) - nokogiri (1.11.3) + nokogiri (1.11.7) mini_portile2 (~> 2.5.0) racc (~> 1.4) - nokogiri (1.11.3-arm64-darwin) + nokogiri (1.11.7-arm64-darwin) racc (~> 1.4) - nokogiri (1.11.3-x86_64-darwin) + nokogiri (1.11.7-x86_64-darwin) racc (~> 1.4) - nokogiri (1.11.3-x86_64-linux) + nokogiri (1.11.7-x86_64-linux) racc (~> 1.4) nokogumbo (2.0.5) nokogiri (~> 1.8, >= 1.8.4) @@ -250,7 +260,7 @@ GEM multi_json (~> 1.3) multi_xml (~> 0.5) rack (>= 1.2, < 3) - oj (3.11.5) + oj (3.12.1) omniauth (1.9.1) hashie (>= 3.4.6) rack (>= 1.6.2, < 3) @@ -273,13 +283,6 @@ GEM omniauth-twitter (1.4.0) omniauth-oauth (~> 1.1) rack - onebox (2.2.15) - addressable (~> 2.7.0) - htmlentities (~> 4.3) - multi_json (~> 1.11) - mustache - nokogiri (~> 1.7) - sanitize openssl (2.2.0) openssl-signature_algorithm (1.1.1) openssl (~> 2.0) @@ -287,7 +290,7 @@ GEM parallel (1.20.1) parallel_tests (3.7.0) parallel - parser (3.0.1.1) + parser (3.0.2.0) ast (~> 2.4.1) pg (1.2.3) progress (3.6.0) @@ -300,7 +303,7 @@ GEM pry-rails (0.3.9) pry (>= 0.10.4) public_suffix (4.0.6) - puma (5.3.1) + puma (5.3.2) nio4r (~> 2.0) r2 (0.2.7) racc (1.5.2) @@ -330,8 +333,8 @@ GEM rake (>= 0.8.7) thor (~> 1.0) rainbow (3.0.0) - raindrops (0.19.1) - rake (13.0.3) + raindrops (0.19.2) + rake (13.0.6) rb-fsevent (0.11.0) rb-inotify (0.10.1) ffi (~> 1.0) @@ -340,7 +343,7 @@ GEM msgpack (>= 0.4.3) optimist (>= 3.0.0) rchardet (1.8.0) - redis (4.2.5) + redis (4.3.1) redis-namespace (1.8.1) redis (>= 3.0.4) regexp_parser (2.1.1) @@ -352,7 +355,7 @@ GEM rqrcode (2.0.0) chunky_png (~> 1.0) rqrcode_core (~> 1.0) - rqrcode_core (1.0.0) + rqrcode_core (1.1.0) rspec (3.10.0) rspec-core (~> 3.10.0) rspec-expectations (~> 3.10.0) @@ -382,21 +385,21 @@ GEM json-schema (~> 2.2) railties (>= 3.1, < 7.0) rtlit (0.0.5) - rubocop (1.14.0) + rubocop (1.18.3) parallel (~> 1.10) parser (>= 3.0.0.0) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 1.8, < 3.0) rexml - rubocop-ast (>= 1.5.0, < 2.0) + rubocop-ast (>= 1.7.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 1.4.0, < 3.0) - rubocop-ast (1.5.0) + rubocop-ast (1.8.0) parser (>= 3.0.1.1) - rubocop-discourse (2.4.1) + rubocop-discourse (2.4.2) rubocop (>= 1.1.0) rubocop-rspec (>= 2.0.0) - rubocop-rspec (2.3.0) + rubocop-rspec (2.4.0) rubocop (~> 1.0) rubocop-ast (>= 1.1.0) ruby-prof (1.4.3) @@ -404,8 +407,8 @@ GEM ruby-readability (0.7.0) guess_html_encoding (>= 0.0.4) nokogiri (>= 1.6.0) - ruby2_keywords (0.0.4) - rubyzip (2.3.0) + ruby2_keywords (0.0.5) + rubyzip (2.3.2) sanitize (5.2.3) crass (~> 1.0.2) nokogiri (>= 1.8.0) @@ -422,8 +425,8 @@ GEM seed-fu (2.3.9) activerecord (>= 3.1) activesupport (>= 3.1) - shoulda-matchers (4.5.1) - activesupport (>= 4.2.0) + shoulda-matchers (5.0.0) + activesupport (>= 5.2.0) sidekiq (6.2.1) connection_pool (>= 2.2.2) rack (~> 2.0) @@ -443,7 +446,7 @@ GEM sprockets (>= 3.0.0) sshkey (2.0.0) stackprof (0.2.17) - test-prof (1.0.5) + test-prof (1.0.6) thor (1.1.0) tilt (2.0.10) tzinfo (2.0.4) @@ -459,7 +462,7 @@ GEM raindrops (~> 0.7) uniform_notifier (1.14.2) uri_template (0.7.0) - webmock (3.12.2) + webmock (3.13.0) addressable (>= 2.3.6) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) @@ -507,13 +510,13 @@ DEPENDENCIES discourse-ember-rails (= 0.18.6) discourse-ember-source (~> 3.12.2) discourse-fonts - discourse_dev - discourse_image_optim + discourse_dev_assets email_reply_trimmer ember-handlebars-template (= 0.8.0) excon execjs fabrication + faker (~> 2.16) fakeweb fast_blank fast_xs @@ -522,6 +525,7 @@ DEPENDENCIES highline htmlentities http_accept_language + image_optim json json_schemer listen @@ -554,7 +558,6 @@ DEPENDENCIES omniauth-google-oauth2 omniauth-oauth2 omniauth-twitter - onebox parallel_tests pg pry-byebug @@ -585,6 +588,7 @@ DEPENDENCIES ruby-prof ruby-readability rubyzip + sanitize sassc (= 2.0.1) sassc-rails seed-fu @@ -606,4 +610,4 @@ DEPENDENCIES yaml-lint BUNDLED WITH - 2.2.16 + 2.2.19 diff --git a/LICENSE.txt b/LICENSE.txt index 94fb84639c4b6..d159169d10508 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,12 +1,12 @@ - GNU GENERAL PUBLIC LICENSE - Version 2, June 1991 + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 Copyright (C) 1989, 1991 Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. - Preamble + Preamble The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public @@ -56,7 +56,7 @@ patent must be licensed for everyone's free use or not licensed at all. The precise terms and conditions for copying, distribution and modification follow. - GNU GENERAL PUBLIC LICENSE + GNU GENERAL PUBLIC LICENSE TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 0. This License applies to any program or other work which contains @@ -255,7 +255,7 @@ make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. - NO WARRANTY + NO WARRANTY 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN @@ -277,9 +277,9 @@ YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. - END OF TERMS AND CONDITIONS + END OF TERMS AND CONDITIONS - How to Apply These Terms to Your New Programs + How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it diff --git a/app/assets/javascripts/admin/addon/components/admin-penalty-history.js b/app/assets/javascripts/admin/addon/components/admin-penalty-history.js new file mode 100644 index 0000000000000..32c288b248ddf --- /dev/null +++ b/app/assets/javascripts/admin/addon/components/admin-penalty-history.js @@ -0,0 +1,22 @@ +import Component from "@ember/component"; +import discourseComputed from "discourse-common/utils/decorators"; + +export default Component.extend({ + classNames: ["penalty-history"], + + @discourseComputed("user.penalty_counts.suspended") + suspendedCountClass(count) { + if (count > 0) { + return "danger"; + } + return ""; + }, + + @discourseComputed("user.penalty_counts.silenced") + silencedCountClass(count) { + if (count > 0) { + return "danger"; + } + return ""; + }, +}); diff --git a/app/assets/javascripts/admin/addon/components/admin-report-chart.js b/app/assets/javascripts/admin/addon/components/admin-report-chart.js index 8a894b95629a8..9e2d382fc98f8 100644 --- a/app/assets/javascripts/admin/addon/components/admin-report-chart.js +++ b/app/assets/javascripts/admin/addon/components/admin-report-chart.js @@ -1,3 +1,4 @@ +import Report from "admin/models/report"; import Component from "@ember/component"; import discourseDebounce from "discourse-common/lib/debounce"; import loadScript from "discourse/lib/load-script"; @@ -157,7 +158,7 @@ export default Component.extend({ gridLines: { display: false }, type: "time", time: { - unit: this._unitForGrouping(options), + unit: Report.unitForGrouping(options.chartGrouping), }, ticks: { sampleSize: 5, @@ -179,62 +180,6 @@ export default Component.extend({ }, _applyChartGrouping(model, data, options) { - if (!options.chartGrouping || options.chartGrouping === "daily") { - return data; - } - - if ( - options.chartGrouping === "weekly" || - options.chartGrouping === "monthly" - ) { - const isoKind = options.chartGrouping === "weekly" ? "isoWeek" : "month"; - const kind = options.chartGrouping === "weekly" ? "week" : "month"; - const startMoment = moment(model.start_date, "YYYY-MM-DD"); - - let currentIndex = 0; - let currentStart = startMoment.clone().startOf(isoKind); - let currentEnd = startMoment.clone().endOf(isoKind); - const transformedData = [ - { - x: currentStart.format("YYYY-MM-DD"), - y: 0, - }, - ]; - - data.forEach((d) => { - let date = moment(d.x, "YYYY-MM-DD"); - - if (!date.isBetween(currentStart, currentEnd)) { - currentIndex += 1; - currentStart = currentStart.add(1, kind).startOf(isoKind); - currentEnd = currentEnd.add(1, kind).endOf(isoKind); - } - - if (transformedData[currentIndex]) { - transformedData[currentIndex].y += d.y; - } else { - transformedData[currentIndex] = { - x: d.x, - y: d.y, - }; - } - }); - - return transformedData; - } - - // ensure we return something if grouping is unknown - return data; - }, - - _unitForGrouping(options) { - switch (options.chartGrouping) { - case "monthly": - return "month"; - case "weekly": - return "week"; - default: - return "day"; - } + return Report.collapse(model, data, options.chartGrouping); }, }); diff --git a/app/assets/javascripts/admin/addon/components/admin-report-stacked-chart.js b/app/assets/javascripts/admin/addon/components/admin-report-stacked-chart.js index 4cac3b15dba40..4c3a9bb633036 100644 --- a/app/assets/javascripts/admin/addon/components/admin-report-stacked-chart.js +++ b/app/assets/javascripts/admin/addon/components/admin-report-stacked-chart.js @@ -1,3 +1,4 @@ +import Report from "admin/models/report"; import Component from "@ember/component"; import discourseDebounce from "discourse-common/lib/debounce"; import loadScript from "discourse/lib/load-script"; @@ -63,7 +64,7 @@ export default Component.extend({ return { label: cd.label, stack: "pageviews-stack", - data: cd.data.map((d) => Math.round(parseFloat(d.y))), + data: Report.collapse(model, cd.data), backgroundColor: cd.color, }; }), @@ -129,15 +130,14 @@ export default Component.extend({ }, }, ], + xAxes: [ { display: true, gridLines: { display: false }, type: "time", - offset: true, time: { - parser: "YYYY-MM-DD", - minUnit: "day", + unit: Report.unitForDatapoints(data.labels.length), }, ticks: { sampleSize: 5, diff --git a/app/assets/javascripts/admin/addon/components/admin-report.js b/app/assets/javascripts/admin/addon/components/admin-report.js index 3ecf00451df3f..9f60e09a257c5 100644 --- a/app/assets/javascripts/admin/addon/components/admin-report.js +++ b/app/assets/javascripts/admin/addon/components/admin-report.js @@ -1,5 +1,5 @@ import EmberObject, { action, computed } from "@ember/object"; -import Report, { SCHEMA_VERSION } from "admin/models/report"; +import Report, { DAILY_LIMIT_DAYS, SCHEMA_VERSION } from "admin/models/report"; import { alias, and, equal, notEmpty, or } from "@ember/object/computed"; import Component from "@ember/component"; import I18n from "I18n"; @@ -21,26 +21,6 @@ const TABLE_OPTIONS = { const CHART_OPTIONS = {}; -function collapseWeekly(data, average) { - let aggregate = []; - let bucket, i; - let offset = data.length % 7; - for (i = offset; i < data.length; i++) { - if (bucket && i % 7 === offset) { - if (average) { - bucket.y = parseFloat((bucket.y / 7.0).toFixed(2)); - } - aggregate.push(bucket); - bucket = null; - } - - bucket = bucket || { x: data[i].x, y: 0 }; - bucket.y += data[i].y; - } - - return aggregate; -} - export default Component.extend({ classNameBindings: [ "isHidden:hidden", @@ -99,6 +79,10 @@ export default Component.extend({ } this.set("endDate", endDate); + if (this.filters) { + this.set("currentMode", this.filters.mode); + } + if (this.report) { this._renderReport(this.report, this.forcedModes, this.currentMode); } else if (this.dataSourceName) { @@ -147,7 +131,7 @@ export default Component.extend({ return makeArray(modes).map((mode) => { const base = `btn-default mode-btn ${mode}`; - const cssClass = currentMode === mode ? `${base} is-current` : base; + const cssClass = currentMode === mode ? `${base} btn-primary` : base; return { mode, @@ -196,15 +180,16 @@ export default Component.extend({ return reportKey; }, - @discourseComputed("reportOptions.chartGrouping") - chartGroupings(chartGrouping) { - chartGrouping = chartGrouping || "daily"; + @discourseComputed("options.chartGrouping", "model.chartData.length") + chartGroupings(grouping, count) { + const options = ["daily", "weekly", "monthly"]; - return ["daily", "weekly", "monthly"].map((id) => { + return options.map((id) => { return { id, + disabled: id === "daily" && count >= DAILY_LIMIT_DAYS, label: `admin.dashboard.reports.${id}`, - class: `chart-grouping ${chartGrouping === id ? "active" : "inactive"}`, + class: `chart-grouping ${grouping === id ? "active" : "inactive"}`, }; }); }, @@ -240,6 +225,7 @@ export default Component.extend({ this.attrs.onRefresh({ type: this.get("model.type"), + mode: this.currentMode, chartGrouping: options.chartGrouping, startDate: typeof options.startDate === "undefined" @@ -271,7 +257,7 @@ export default Component.extend({ }, @action - changeMode(mode) { + onChangeMode(mode) { this.set("currentMode", mode); this.send("refreshReport", { @@ -329,7 +315,7 @@ export default Component.extend({ this.setProperties({ model: report, currentMode, - options: this._buildOptions(currentMode), + options: this._buildOptions(currentMode, report), }); }, @@ -366,7 +352,7 @@ export default Component.extend({ }, _buildPayload(facets) { - let payload = { data: { cache: true, facets } }; + let payload = { data: { facets } }; if (this.startDate) { payload.data.start_date = moment(this.startDate) @@ -391,17 +377,19 @@ export default Component.extend({ return payload; }, - _buildOptions(mode) { + _buildOptions(mode, report) { if (mode === "table") { const tableOptions = JSON.parse(JSON.stringify(TABLE_OPTIONS)); return EmberObject.create( Object.assign(tableOptions, this.get("reportOptions.table") || {}) ); - } else { + } else if (mode === "chart") { const chartOptions = JSON.parse(JSON.stringify(CHART_OPTIONS)); return EmberObject.create( Object.assign(chartOptions, this.get("reportOptions.chart") || {}, { - chartGrouping: this.get("reportOptions.chartGrouping"), + chartGrouping: + this.get("reportOptions.chartGrouping") || + Report.groupingForDatapoints(report.chartData.length), }) ); } @@ -414,7 +402,7 @@ export default Component.extend({ jsonReport.chartData = jsonReport.chartData.map((chartData) => { if (chartData.length > 40) { return { - data: collapseWeekly(chartData.data), + data: chartData.data, req: chartData.req, label: chartData.label, color: chartData.color, @@ -423,11 +411,6 @@ export default Component.extend({ return chartData; } }); - } else if (jsonReport.chartData && jsonReport.chartData.length > 40) { - jsonReport.chartData = collapseWeekly( - jsonReport.chartData, - jsonReport.average - ); } if (jsonReport.prev_data) { @@ -437,13 +420,6 @@ export default Component.extend({ starDate: jsonReport.prev_startDate, endDate: jsonReport.prev_endDate, }); - - if (jsonReport.prevChartData && jsonReport.prevChartData.length > 40) { - jsonReport.prevChartData = collapseWeekly( - jsonReport.prevChartData, - jsonReport.average - ); - } } return Report.create(jsonReport); diff --git a/app/assets/javascripts/admin/addon/components/admin-watched-word.js b/app/assets/javascripts/admin/addon/components/admin-watched-word.js index fb24a6f388fcb..b198ffcf2b6d6 100644 --- a/app/assets/javascripts/admin/addon/components/admin-watched-word.js +++ b/app/assets/javascripts/admin/addon/components/admin-watched-word.js @@ -1,11 +1,24 @@ import Component from "@ember/component"; -import I18n from "I18n"; +import { equal } from "@ember/object/computed"; import bootbox from "bootbox"; +import discourseComputed from "discourse-common/utils/decorators"; +import { action } from "@ember/object"; +import I18n from "I18n"; export default Component.extend({ classNames: ["watched-word"], - click() { + isReplace: equal("actionKey", "replace"), + isTag: equal("actionKey", "tag"), + isLink: equal("actionKey", "link"), + + @discourseComputed("word.replacement") + tags(replacement) { + return replacement.split(","); + }, + + @action + deleteWord() { this.word .destroy() .then(() => { diff --git a/app/assets/javascripts/admin/addon/components/suspension-details.js b/app/assets/javascripts/admin/addon/components/suspension-details.js index e99a7e1c5b8ad..fe931ae12e7c4 100644 --- a/app/assets/javascripts/admin/addon/components/suspension-details.js +++ b/app/assets/javascripts/admin/addon/components/suspension-details.js @@ -13,7 +13,7 @@ export default Component.extend({ reasonKeys: [ "not_listening_to_staff", "consuming_staff_time", - "combatative", + "combative", "in_wrong_place", "no_constructive_purpose", CUSTOM_REASON_KEY, diff --git a/app/assets/javascripts/admin/addon/components/themes-list.js b/app/assets/javascripts/admin/addon/components/themes-list.js index 715ca9cceef52..ca1d872f46b3d 100644 --- a/app/assets/javascripts/admin/addon/components/themes-list.js +++ b/app/assets/javascripts/admin/addon/components/themes-list.js @@ -1,8 +1,9 @@ import { COMPONENTS, THEMES } from "admin/models/theme"; -import { equal, gt } from "@ember/object/computed"; +import { equal, gt, gte } from "@ember/object/computed"; import Component from "@ember/component"; import discourseComputed from "discourse-common/utils/decorators"; import { inject as service } from "@ember/service"; +import { action } from "@ember/object"; export default Component.extend({ router: service(), @@ -10,10 +11,12 @@ export default Component.extend({ COMPONENTS, classNames: ["themes-list"], + filterTerm: null, hasThemes: gt("themesList.length", 0), hasActiveThemes: gt("activeThemes.length", 0), hasInactiveThemes: gt("inactiveThemes.length", 0), + showFilter: gte("themesList.length", 10), themesTabActive: equal("currentTab", THEMES), componentsTabActive: equal("currentTab", COMPONENTS), @@ -31,28 +34,36 @@ export default Component.extend({ "themesList", "currentTab", "themesList.@each.user_selectable", - "themesList.@each.default" + "themesList.@each.default", + "filterTerm" ) inactiveThemes(themes) { + let results; if (this.componentsTabActive) { - return themes.filter((theme) => theme.get("parent_themes.length") <= 0); + results = themes.filter( + (theme) => theme.get("parent_themes.length") <= 0 + ); + } else { + results = themes.filter( + (theme) => !theme.get("user_selectable") && !theme.get("default") + ); } - return themes.filter( - (theme) => !theme.get("user_selectable") && !theme.get("default") - ); + return this._filterThemes(results, this.filterTerm); }, @discourseComputed( "themesList", "currentTab", "themesList.@each.user_selectable", - "themesList.@each.default" + "themesList.@each.default", + "filterTerm" ) activeThemes(themes) { + let results; if (this.componentsTabActive) { - return themes.filter((theme) => theme.get("parent_themes.length") > 0); + results = themes.filter((theme) => theme.get("parent_themes.length") > 0); } else { - return themes + results = themes .filter((theme) => theme.get("user_selectable") || theme.get("default")) .sort((a, b) => { if (a.get("default") && !b.get("default")) { @@ -66,16 +77,29 @@ export default Component.extend({ .localeCompare(b.get("name").toLowerCase()); }); } + return this._filterThemes(results, this.filterTerm); + }, + + _filterThemes(themes, term) { + term = term?.trim()?.toLowerCase(); + if (!term) { + return themes; + } + return themes.filter(({ name }) => name.toLowerCase().includes(term)); }, - actions: { - changeView(newTab) { - if (newTab !== this.currentTab) { - this.set("currentTab", newTab); + @action + changeView(newTab) { + if (newTab !== this.currentTab) { + this.set("currentTab", newTab); + if (!this.showFilter) { + this.set("filterTerm", null); } - }, - navigateToTheme(theme) { - this.router.transitionTo("adminCustomizeThemes.show", theme); - }, + } + }, + + @action + navigateToTheme(theme) { + this.router.transitionTo("adminCustomizeThemes.show", theme); }, }); diff --git a/app/assets/javascripts/admin/addon/components/watched-word-form.js b/app/assets/javascripts/admin/addon/components/watched-word-form.js index cf957148e2bda..93dbdda751912 100644 --- a/app/assets/javascripts/admin/addon/components/watched-word-form.js +++ b/app/assets/javascripts/admin/addon/components/watched-word-form.js @@ -15,16 +15,24 @@ export default Component.extend({ formSubmitted: false, actionKey: null, showMessage: false, + selectedTags: null, canReplace: equal("actionKey", "replace"), canTag: equal("actionKey", "tag"), + canLink: equal("actionKey", "link"), - @discourseComputed("regularExpressions") - placeholderKey(regularExpressions) { - return ( - "admin.watched_words.form.placeholder" + - (regularExpressions ? "_regexp" : "") - ); + didInsertElement() { + this._super(...arguments); + this.set("selectedTags", []); + }, + + @discourseComputed("siteSettings.watched_words_regular_expressions") + placeholderKey(watchedWordsRegularExpressions) { + if (watchedWordsRegularExpressions) { + return "admin.watched_words.form.placeholder_regexp"; + } else { + return "admin.watched_words.form.placeholder"; + } }, @observes("word") @@ -46,6 +54,13 @@ export default Component.extend({ }, actions: { + changeSelectedTags(tags) { + this.setProperties({ + selectedTags: tags, + replacement: tags.join(","), + }); + }, + submit() { if (!this.isUniqueWord) { this.setProperties({ @@ -60,7 +75,10 @@ export default Component.extend({ const watchedWord = WatchedWord.create({ word: this.word, - replacement: this.canReplace || this.canTag ? this.replacement : null, + replacement: + this.canReplace || this.canTag || this.canLink + ? this.replacement + : null, action: this.actionKey, }); @@ -71,6 +89,7 @@ export default Component.extend({ word: "", replacement: "", formSubmitted: false, + selectedTags: [], showMessage: true, message: I18n.t("admin.watched_words.form.success"), }); diff --git a/app/assets/javascripts/admin/addon/controllers/admin-badges-award.js b/app/assets/javascripts/admin/addon/controllers/admin-badges-award.js index 218b3cb0e0a86..4318575a346fb 100644 --- a/app/assets/javascripts/admin/addon/controllers/admin-badges-award.js +++ b/app/assets/javascripts/admin/addon/controllers/admin-badges-award.js @@ -2,38 +2,98 @@ import Controller from "@ember/controller"; import I18n from "I18n"; import { ajax } from "discourse/lib/ajax"; import bootbox from "bootbox"; -import { popupAjaxError } from "discourse/lib/ajax-error"; +import { extractError } from "discourse/lib/ajax-error"; +import { action } from "@ember/object"; +import discourseComputed from "discourse-common/utils/decorators"; export default Controller.extend({ saving: false, replaceBadgeOwners: false, + grantExistingHolders: false, + fileSelected: false, + unmatchedEntries: null, + resultsMessage: null, + success: false, + unmatchedEntriesCount: 0, - actions: { - massAward() { - const file = document.querySelector("#massAwardCSVUpload").files[0]; - - if (this.model && file) { - const options = { - type: "POST", - processData: false, - contentType: false, - data: new FormData(), - }; - - options.data.append("file", file); - options.data.append("replace_badge_owners", this.replaceBadgeOwners); - - this.set("saving", true); - - ajax(`/admin/badges/award/${this.model.id}`, options) - .then(() => { - bootbox.alert(I18n.t("admin.badges.mass_award.success")); - }) - .catch(popupAjaxError) - .finally(() => this.set("saving", false)); - } else { - bootbox.alert(I18n.t("admin.badges.mass_award.aborted")); - } - }, + resetState() { + this.setProperties({ + saving: false, + unmatchedEntries: null, + resultsMessage: null, + success: false, + unmatchedEntriesCount: 0, + }); + this.send("updateFileSelected"); + }, + + @discourseComputed("fileSelected", "saving") + massAwardButtonDisabled(fileSelected, saving) { + return !fileSelected || saving; + }, + + @discourseComputed("unmatchedEntriesCount", "unmatchedEntries.length") + unmatchedEntriesTruncated(unmatchedEntriesCount, length) { + return unmatchedEntriesCount && length && unmatchedEntriesCount > length; + }, + + @action + updateFileSelected() { + this.set( + "fileSelected", + !!document.querySelector("#massAwardCSVUpload")?.files?.length + ); + }, + + @action + massAward() { + const file = document.querySelector("#massAwardCSVUpload").files[0]; + + if (this.model && file) { + const options = { + type: "POST", + processData: false, + contentType: false, + data: new FormData(), + }; + + options.data.append("file", file); + options.data.append("replace_badge_owners", this.replaceBadgeOwners); + options.data.append("grant_existing_holders", this.grantExistingHolders); + + this.resetState(); + this.set("saving", true); + + ajax(`/admin/badges/award/${this.model.id}`, options) + .then( + ({ + matched_users_count: matchedCount, + unmatched_entries: unmatchedEntries, + unmatched_entries_count: unmatchedEntriesCount, + }) => { + this.setProperties({ + resultsMessage: I18n.t("admin.badges.mass_award.success", { + count: matchedCount, + }), + success: true, + }); + if (unmatchedEntries.length) { + this.setProperties({ + unmatchedEntries, + unmatchedEntriesCount, + }); + } + } + ) + .catch((error) => { + this.setProperties({ + resultsMessage: extractError(error), + success: false, + }); + }) + .finally(() => this.set("saving", false)); + } else { + bootbox.alert(I18n.t("admin.badges.mass_award.aborted")); + } }, }); diff --git a/app/assets/javascripts/admin/addon/controllers/admin-dashboard-general.js b/app/assets/javascripts/admin/addon/controllers/admin-dashboard-general.js index a831b686bc2f0..bde2cf574b299 100644 --- a/app/assets/javascripts/admin/addon/controllers/admin-dashboard-general.js +++ b/app/assets/javascripts/admin/addon/controllers/admin-dashboard-general.js @@ -1,4 +1,4 @@ -import Controller, { inject } from "@ember/controller"; +import Controller, { inject as controller } from "@ember/controller"; import AdminDashboard from "admin/models/admin-dashboard"; import I18n from "I18n"; import PeriodComputationMixin from "admin/mixins/period-computation"; @@ -18,7 +18,7 @@ function staticReport(reportType) { export default Controller.extend(PeriodComputationMixin, { isLoading: false, dashboardFetchedAt: null, - exceptionController: inject("exception"), + exceptionController: controller("exception"), logSearchQueriesEnabled: setting("log_search_queries"), @discourseComputed("siteSettings.dashboard_general_tab_activity_metrics") diff --git a/app/assets/javascripts/admin/addon/controllers/admin-dashboard-reports.js b/app/assets/javascripts/admin/addon/controllers/admin-dashboard-reports.js index ab10591c8089b..51437f22f0450 100644 --- a/app/assets/javascripts/admin/addon/controllers/admin-dashboard-reports.js +++ b/app/assets/javascripts/admin/addon/controllers/admin-dashboard-reports.js @@ -2,8 +2,7 @@ import Controller from "@ember/controller"; import { INPUT_DELAY } from "discourse-common/config/environment"; import discourseComputed from "discourse-common/utils/decorators"; import discourseDebounce from "discourse-common/lib/debounce"; - -const { get } = Ember; +import { get } from "@ember/object"; export default Controller.extend({ filter: null, diff --git a/app/assets/javascripts/admin/addon/controllers/admin-dashboard.js b/app/assets/javascripts/admin/addon/controllers/admin-dashboard.js index 89955953fbd80..c7291f1a76394 100644 --- a/app/assets/javascripts/admin/addon/controllers/admin-dashboard.js +++ b/app/assets/javascripts/admin/addon/controllers/admin-dashboard.js @@ -1,4 +1,4 @@ -import Controller, { inject } from "@ember/controller"; +import Controller, { inject as controller } from "@ember/controller"; import AdminDashboard from "admin/models/admin-dashboard"; import VersionCheck from "admin/models/version-check"; import { computed } from "@ember/object"; @@ -10,7 +10,7 @@ const PROBLEMS_CHECK_MINUTES = 1; export default Controller.extend({ isLoading: false, dashboardFetchedAt: null, - exceptionController: inject("exception"), + exceptionController: controller("exception"), showVersionChecks: setting("version_checks"), @discourseComputed("problems.length") diff --git a/app/assets/javascripts/admin/addon/controllers/admin-reports-show.js b/app/assets/javascripts/admin/addon/controllers/admin-reports-show.js index 64a2c5de931eb..5f19399edf30b 100644 --- a/app/assets/javascripts/admin/addon/controllers/admin-reports-show.js +++ b/app/assets/javascripts/admin/addon/controllers/admin-reports-show.js @@ -2,7 +2,7 @@ import Controller from "@ember/controller"; import discourseComputed from "discourse-common/utils/decorators"; export default Controller.extend({ - queryParams: ["start_date", "end_date", "filters", "chart_grouping"], + queryParams: ["start_date", "end_date", "filters", "chart_grouping", "mode"], start_date: null, end_date: null, filters: null, diff --git a/app/assets/javascripts/admin/addon/controllers/admin-watched-words-action.js b/app/assets/javascripts/admin/addon/controllers/admin-watched-words-action.js index 1265e3d0288b1..d84d80615dc29 100644 --- a/app/assets/javascripts/admin/addon/controllers/admin-watched-words-action.js +++ b/app/assets/javascripts/admin/addon/controllers/admin-watched-words-action.js @@ -12,20 +12,14 @@ import showModal from "discourse/lib/show-modal"; export default Controller.extend({ adminWatchedWords: controller(), actionNameKey: null, - showWordsList: or( - "adminWatchedWords.filtered", - "adminWatchedWords.showWords" - ), downloadLink: fmt( "actionNameKey", "/admin/customize/watched_words/action/%@/download" ), + showWordsList: or("adminWatchedWords.showWords", "adminWatchedWords.filter"), findAction(actionName) { - return (this.get("adminWatchedWords.model") || []).findBy( - "nameKey", - actionName - ); + return (this.adminWatchedWords.model || []).findBy("nameKey", actionName); }, @discourseComputed("actionNameKey", "adminWatchedWords.model") @@ -33,9 +27,15 @@ export default Controller.extend({ return this.findAction(actionName); }, - @discourseComputed("currentAction.words.[]", "adminWatchedWords.model") - filteredContent(words) { - return words || []; + @discourseComputed("currentAction.words.[]") + regexpError(words) { + for (const { regexp, word } of words) { + try { + RegExp(regexp); + } catch { + return I18n.t("admin.watched_words.invalid_regex", { word }); + } + } }, @discourseComputed("actionNameKey") @@ -43,47 +43,51 @@ export default Controller.extend({ return I18n.t("admin.watched_words.action_descriptions." + actionNameKey); }, - @discourseComputed("currentAction.count") - wordCount(count) { - return count || 0; - }, - actions: { recordAdded(arg) { - const a = this.findAction(this.actionNameKey); - if (a) { - a.words.unshiftObject(arg); - a.incrementProperty("count"); - schedule("afterRender", () => { - // remove from other actions lists - let match = null; - this.get("adminWatchedWords.model").forEach((action) => { - if (match) { - return; - } + const action = this.findAction(this.actionNameKey); + if (!action) { + return; + } - if (action.nameKey !== this.actionNameKey) { - match = action.words.findBy("id", arg.id); - if (match) { - action.words.removeObject(match); - action.decrementProperty("count"); - } + action.words.unshiftObject(arg); + schedule("afterRender", () => { + // remove from other actions lists + let match = null; + this.adminWatchedWords.model.forEach((otherAction) => { + if (match) { + return; + } + + if (otherAction.nameKey !== this.actionNameKey) { + match = otherAction.words.findBy("id", arg.id); + if (match) { + otherAction.words.removeObject(match); } - }); + } }); - } + }); }, recordRemoved(arg) { if (this.currentAction) { this.currentAction.words.removeObject(arg); - this.currentAction.decrementProperty("count"); } }, uploadComplete() { WatchedWord.findAll().then((data) => { - this.set("adminWatchedWords.model", data); + this.adminWatchedWords.set("model", data); + }); + }, + + test() { + WatchedWord.findAll().then((data) => { + this.adminWatchedWords.set("model", data); + showModal("admin-watched-word-test", { + admin: true, + model: this.currentAction, + }); }); }, @@ -102,25 +106,12 @@ export default Controller.extend({ }).then(() => { const action = this.findAction(actionKey); if (action) { - action.setProperties({ - words: [], - count: 0, - }); + action.set("words", []); } }); } } ); }, - - test() { - WatchedWord.findAll().then((data) => { - this.set("adminWatchedWords.model", data); - showModal("admin-watched-word-test", { - admin: true, - model: this.currentAction, - }); - }); - }, }, }); diff --git a/app/assets/javascripts/admin/addon/controllers/admin-watched-words.js b/app/assets/javascripts/admin/addon/controllers/admin-watched-words.js index cfd86c6c22df3..1830e4c742dc0 100644 --- a/app/assets/javascripts/admin/addon/controllers/admin-watched-words.js +++ b/app/assets/javascripts/admin/addon/controllers/admin-watched-words.js @@ -1,71 +1,55 @@ import Controller from "@ember/controller"; -import EmberObject from "@ember/object"; +import EmberObject, { action } from "@ember/object"; import { INPUT_DELAY } from "discourse-common/config/environment"; -import { alias } from "@ember/object/computed"; import discourseDebounce from "discourse-common/lib/debounce"; import { isEmpty } from "@ember/utils"; import { observes } from "discourse-common/utils/decorators"; export default Controller.extend({ filter: null, - filtered: false, showWords: false, - disableShowWords: alias("filtered"), - regularExpressions: null, - filterContentNow() { - if (!!isEmpty(this.allWatchedWords)) { + _filterContent() { + if (isEmpty(this.allWatchedWords)) { return; } - let filter; - if (this.filter) { - filter = this.filter.toLowerCase(); - } - - if (filter === undefined || filter.length < 1) { + if (!this.filter) { this.set("model", this.allWatchedWords); return; } - const matchesByAction = []; + const filter = this.filter.toLowerCase(); + const model = []; this.allWatchedWords.forEach((wordsForAction) => { const wordRecords = wordsForAction.words.filter((wordRecord) => { return wordRecord.word.indexOf(filter) > -1; }); - matchesByAction.pushObject( + + model.pushObject( EmberObject.create({ nameKey: wordsForAction.nameKey, name: wordsForAction.name, words: wordRecords, - count: wordRecords.length, }) ); }); - - this.set("model", matchesByAction); + this.set("model", model); }, @observes("filter") filterContent() { - discourseDebounce( - this, - function () { - this.filterContentNow(); - this.set("filtered", !isEmpty(this.filter)); - }, - INPUT_DELAY - ); + discourseDebounce(this, this._filterContent, INPUT_DELAY); }, - actions: { - clearFilter() { - this.setProperties({ filter: "" }); - }, + @action + clearFilter() { + this.set("filter", ""); + }, - toggleMenu() { - $(".admin-detail").toggleClass("mobile-closed mobile-open"); - }, + @action + toggleMenu() { + $(".admin-detail").toggleClass("mobile-closed mobile-open"); }, }); diff --git a/app/assets/javascripts/admin/addon/controllers/modals/admin-silence-user.js b/app/assets/javascripts/admin/addon/controllers/modals/admin-silence-user.js index b76565f815783..506598625d4f7 100644 --- a/app/assets/javascripts/admin/addon/controllers/modals/admin-silence-user.js +++ b/app/assets/javascripts/admin/addon/controllers/modals/admin-silence-user.js @@ -12,6 +12,10 @@ export default Controller.extend(PenaltyController, { this.setProperties({ silenceUntil: null, silencing: false }); }, + finishedSetup() { + this.set("silenceUntil", this.user?.next_penalty); + }, + @discourseComputed("silenceUntil", "reason", "silencing") submitDisabled(silenceUntil, reason, silencing) { return silencing || isEmpty(silenceUntil) || !reason || reason.length < 1; diff --git a/app/assets/javascripts/admin/addon/controllers/modals/admin-suspend-user.js b/app/assets/javascripts/admin/addon/controllers/modals/admin-suspend-user.js index 2712b57cf234b..c82a562808429 100644 --- a/app/assets/javascripts/admin/addon/controllers/modals/admin-suspend-user.js +++ b/app/assets/javascripts/admin/addon/controllers/modals/admin-suspend-user.js @@ -12,6 +12,10 @@ export default Controller.extend(PenaltyController, { this.setProperties({ suspendUntil: null, suspending: false }); }, + finishedSetup() { + this.set("suspendUntil", this.user?.next_penalty); + }, + @discourseComputed("suspendUntil", "reason", "suspending") submitDisabled(suspendUntil, reason, suspending) { return suspending || isEmpty(suspendUntil) || !reason || reason.length < 1; diff --git a/app/assets/javascripts/admin/addon/controllers/modals/admin-watched-word-test.js b/app/assets/javascripts/admin/addon/controllers/modals/admin-watched-word-test.js index 215cc08a6d9d0..3ea2618acb2c6 100644 --- a/app/assets/javascripts/admin/addon/controllers/modals/admin-watched-word-test.js +++ b/app/assets/javascripts/admin/addon/controllers/modals/admin-watched-word-test.js @@ -1,14 +1,62 @@ import Controller from "@ember/controller"; import ModalFunctionality from "discourse/mixins/modal-functionality"; import discourseComputed from "discourse-common/utils/decorators"; +import { equal } from "@ember/object/computed"; export default Controller.extend(ModalFunctionality, { - @discourseComputed("value", "model.compiledRegularExpression") - matches(value, regexpString) { + isReplace: equal("model.nameKey", "replace"), + isTag: equal("model.nameKey", "tag"), + isLink: equal("model.nameKey", "link"), + + @discourseComputed( + "value", + "model.compiledRegularExpression", + "model.words", + "isReplace", + "isTag", + "isLink" + ) + matches(value, regexpString, words, isReplace, isTag, isLink) { if (!value || !regexpString) { - return; + return []; + } + + if (isReplace || isLink) { + const matches = []; + words.forEach((word) => { + const regexp = new RegExp(word.regexp, "gi"); + let match; + while ((match = regexp.exec(value)) !== null) { + matches.push({ + match: match[1], + replacement: word.replacement, + }); + } + }); + return matches; + } else if (isTag) { + const matches = {}; + words.forEach((word) => { + const regexp = new RegExp(word.regexp, "gi"); + let match; + while ((match = regexp.exec(value)) !== null) { + if (!matches[match[1]]) { + matches[match[1]] = new Set(); + } + + let tags = matches[match[1]]; + word.replacement.split(",").forEach((tag) => { + tags.add(tag); + }); + } + }); + + return Object.entries(matches).map((entry) => ({ + match: entry[0], + tags: Array.from(entry[1]), + })); + } else { + return value.match(new RegExp(regexpString, "ig")) || []; } - let censorRegexp = new RegExp(regexpString, "ig"); - return value.match(censorRegexp); }, }); diff --git a/app/assets/javascripts/admin/addon/controllers/modals/site-setting-default-categories.js b/app/assets/javascripts/admin/addon/controllers/modals/site-setting-default-categories.js index c46fe08b7ba70..8c3a264898979 100644 --- a/app/assets/javascripts/admin/addon/controllers/modals/site-setting-default-categories.js +++ b/app/assets/javascripts/admin/addon/controllers/modals/site-setting-default-categories.js @@ -1,20 +1,5 @@ import Controller from "@ember/controller"; import ModalFunctionality from "discourse/mixins/modal-functionality"; +import ModalUpdateExistingUsers from "discourse/mixins/modal-update-existing-users"; -export default Controller.extend(ModalFunctionality, { - onShow() { - this.set("updateExistingUsers", null); - }, - - actions: { - updateExistingUsers() { - this.set("updateExistingUsers", true); - this.send("closeModal"); - }, - - cancel() { - this.set("updateExistingUsers", false); - this.send("closeModal"); - }, - }, -}); +export default Controller.extend(ModalFunctionality, ModalUpdateExistingUsers); diff --git a/app/assets/javascripts/admin/addon/mixins/setting-object.js b/app/assets/javascripts/admin/addon/mixins/setting-object.js index 29dbb0d967902..2bf9e0c0820e3 100644 --- a/app/assets/javascripts/admin/addon/mixins/setting-object.js +++ b/app/assets/javascripts/admin/addon/mixins/setting-object.js @@ -50,7 +50,7 @@ export default Mixin.create({ const vals = [], translateNames = this.translate_names; - validValues.forEach((v) => { + (validValues || []).forEach((v) => { if (v.name && v.name.length > 0 && translateNames) { vals.addObject({ name: I18n.t(v.name), value: v.value }); } else { diff --git a/app/assets/javascripts/admin/addon/models/email-template.js b/app/assets/javascripts/admin/addon/models/email-template.js index 4be7d9fff93ee..19936a6a97693 100644 --- a/app/assets/javascripts/admin/addon/models/email-template.js +++ b/app/assets/javascripts/admin/addon/models/email-template.js @@ -1,6 +1,6 @@ import RestModel from "discourse/models/rest"; import { ajax } from "discourse/lib/ajax"; -const { getProperties } = Ember; +import { getProperties } from "@ember/object"; export default RestModel.extend({ revert() { diff --git a/app/assets/javascripts/admin/addon/models/report.js b/app/assets/javascripts/admin/addon/models/report.js index 84e6304c75f10..57d9bb69a5a05 100644 --- a/app/assets/javascripts/admin/addon/models/report.js +++ b/app/assets/javascripts/admin/addon/models/report.js @@ -503,7 +503,120 @@ const Report = EmberObject.extend({ }, }); +export const WEEKLY_LIMIT_DAYS = 365; +export const DAILY_LIMIT_DAYS = 34; + +function applyAverage(value, start, end) { + const count = end.diff(start, "day") + 1; // 1 to include start + return parseFloat((value / count).toFixed(2)); +} + Report.reopenClass({ + groupingForDatapoints(count) { + if (count < DAILY_LIMIT_DAYS) { + return "daily"; + } + + if (count >= DAILY_LIMIT_DAYS && count < WEEKLY_LIMIT_DAYS) { + return "weekly"; + } + + if (count >= WEEKLY_LIMIT_DAYS) { + return "monthly"; + } + }, + + unitForDatapoints(count) { + if (count >= DAILY_LIMIT_DAYS && count < WEEKLY_LIMIT_DAYS) { + return "week"; + } else if (count >= WEEKLY_LIMIT_DAYS) { + return "month"; + } else { + return "day"; + } + }, + + unitForGrouping(grouping) { + switch (grouping) { + case "monthly": + return "month"; + case "weekly": + return "week"; + default: + return "day"; + } + }, + + collapse(model, data, grouping) { + grouping = grouping || Report.groupingForDatapoints(data.length); + + if (grouping === "daily") { + return data; + } else if (grouping === "weekly" || grouping === "monthly") { + const isoKind = grouping === "weekly" ? "isoWeek" : "month"; + const kind = grouping === "weekly" ? "week" : "month"; + const startMoment = moment(model.start_date, "YYYY-MM-DD"); + + let currentIndex = 0; + let currentStart = startMoment.clone().startOf(isoKind); + let currentEnd = startMoment.clone().endOf(isoKind); + const transformedData = [ + { + x: currentStart.format("YYYY-MM-DD"), + y: 0, + }, + ]; + + let appliedAverage = false; + data.forEach((d) => { + const date = moment(d.x, "YYYY-MM-DD"); + + if ( + !date.isSame(currentStart) && + !date.isBetween(currentStart, currentEnd) + ) { + if (model.average) { + transformedData[currentIndex].y = applyAverage( + transformedData[currentIndex].y, + currentStart, + currentEnd + ); + + appliedAverage = true; + } + + currentIndex += 1; + currentStart = currentStart.add(1, kind).startOf(isoKind); + currentEnd = currentEnd.add(1, kind).endOf(isoKind); + } else { + appliedAverage = false; + } + + if (transformedData[currentIndex]) { + transformedData[currentIndex].y += d.y; + } else { + transformedData[currentIndex] = { + x: d.x, + y: d.y, + }; + } + }); + + if (model.average && !appliedAverage) { + transformedData[currentIndex].y = applyAverage( + transformedData[currentIndex].y, + currentStart, + moment(model.end_date).subtract(1, "day") // remove 1 day as model end date is at 00:00 of next day + ); + } + + return transformedData; + } + + // ensure we return something if grouping is unknown + return data; + }, + fillMissingDates(report, options = {}) { const dataField = options.dataField || "data"; const filledField = options.filledField || "data"; diff --git a/app/assets/javascripts/admin/addon/models/user-field.js b/app/assets/javascripts/admin/addon/models/user-field.js index 495be7a08d0d2..53b92a63fd95e 100644 --- a/app/assets/javascripts/admin/addon/models/user-field.js +++ b/app/assets/javascripts/admin/addon/models/user-field.js @@ -15,6 +15,7 @@ UserField.reopenClass({ UserFieldType.create({ id: "text" }), UserFieldType.create({ id: "confirm" }), UserFieldType.create({ id: "dropdown", hasOptions: true }), + UserFieldType.create({ id: "multiselect", hasOptions: true }), ]; } diff --git a/app/assets/javascripts/admin/addon/models/watched-word.js b/app/assets/javascripts/admin/addon/models/watched-word.js index 9aebef18eb0b0..54d20bffffaa5 100644 --- a/app/assets/javascripts/admin/addon/models/watched-word.js +++ b/app/assets/javascripts/admin/addon/models/watched-word.js @@ -31,27 +31,21 @@ WatchedWord.reopenClass({ findAll() { return ajax("/admin/customize/watched_words.json").then((list) => { const actions = {}; - list.words.forEach((s) => { - if (!actions[s.action]) { - actions[s.action] = []; - } - actions[s.action].pushObject(WatchedWord.create(s)); + + list.actions.forEach((action) => { + actions[action] = []; }); - list.actions.forEach((a) => { - if (!actions[a]) { - actions[a] = []; - } + list.words.forEach((watchedWord) => { + actions[watchedWord.action].pushObject(WatchedWord.create(watchedWord)); }); - return Object.keys(actions).map((n) => { + return Object.keys(actions).map((nameKey) => { return EmberObject.create({ - nameKey: n, - name: I18n.t("admin.watched_words.actions." + n), - words: actions[n], - count: actions[n].length, - regularExpressions: list.regular_expressions, - compiledRegularExpression: list.compiled_regular_expressions[n], + nameKey, + name: I18n.t("admin.watched_words.actions." + nameKey), + words: actions[nameKey], + compiledRegularExpression: list.compiled_regular_expressions[nameKey], }); }); }); diff --git a/app/assets/javascripts/admin/addon/routes/admin-badges-award.js b/app/assets/javascripts/admin/addon/routes/admin-badges-award.js index ee6cf4b82570e..6fe72a6bf1385 100644 --- a/app/assets/javascripts/admin/addon/routes/admin-badges-award.js +++ b/app/assets/javascripts/admin/addon/routes/admin-badges-award.js @@ -9,4 +9,9 @@ export default Route.extend({ ); } }, + + setupController(controller) { + this._super(...arguments); + controller.resetState(); + }, }); diff --git a/app/assets/javascripts/admin/addon/routes/admin-reports-show.js b/app/assets/javascripts/admin/addon/routes/admin-reports-show.js index 192158122e6f6..8aec4baf31c87 100644 --- a/app/assets/javascripts/admin/addon/routes/admin-reports-show.js +++ b/app/assets/javascripts/admin/addon/routes/admin-reports-show.js @@ -6,6 +6,7 @@ export default DiscourseRoute.extend({ end_date: { refreshModel: true }, filters: { refreshModel: true }, chart_grouping: { refreshModel: true }, + mode: { refreshModel: true }, }, model(params) { @@ -55,6 +56,7 @@ export default DiscourseRoute.extend({ onParamsChange(params) { const queryParams = { type: params.type, + mode: params.mode, start_date: params.startDate ? params.startDate.toISOString(true).split("T")[0] : null, diff --git a/app/assets/javascripts/admin/addon/routes/admin-search-logs-term.js b/app/assets/javascripts/admin/addon/routes/admin-search-logs-term.js index b9613fe04fa10..9847f44d19a1a 100644 --- a/app/assets/javascripts/admin/addon/routes/admin-search-logs-term.js +++ b/app/assets/javascripts/admin/addon/routes/admin-search-logs-term.js @@ -20,7 +20,7 @@ export default DiscourseRoute.extend({ search_type: params.searchType, term: params.term, }, - }).then((json) => { + }).then(async (json) => { // Add zero values for missing dates if (json.term.data.length > 0) { const startDate = @@ -31,7 +31,9 @@ export default DiscourseRoute.extend({ json.term.data = fillMissingDates(json.term.data, startDate, endDate); } if (json.term.search_result) { - json.term.search_result = translateResults(json.term.search_result); + json.term.search_result = await translateResults( + json.term.search_result + ); } const model = EmberObject.create({ type: "search_log_term" }); diff --git a/app/assets/javascripts/admin/addon/routes/admin-watched-words-action.js b/app/assets/javascripts/admin/addon/routes/admin-watched-words-action.js index fe1ce75ab707a..5ba55219c7b48 100644 --- a/app/assets/javascripts/admin/addon/routes/admin-watched-words-action.js +++ b/app/assets/javascripts/admin/addon/routes/admin-watched-words-action.js @@ -4,17 +4,12 @@ import I18n from "I18n"; export default DiscourseRoute.extend({ model(params) { - this.controllerFor("adminWatchedWordsAction").set( - "actionNameKey", - params.action_id - ); - let filteredContent = this.controllerFor("adminWatchedWordsAction").get( - "filteredContent" - ); + const controller = this.controllerFor("adminWatchedWordsAction"); + controller.set("actionNameKey", params.action_id); return EmberObject.create({ nameKey: params.action_id, name: I18n.t("admin.watched_words.actions." + params.action_id), - words: filteredContent, + words: controller.filteredContent, }); }, }); diff --git a/app/assets/javascripts/admin/addon/routes/admin-watched-words.js b/app/assets/javascripts/admin/addon/routes/admin-watched-words.js index d995d09d45403..aa793646cbd7f 100644 --- a/app/assets/javascripts/admin/addon/routes/admin-watched-words.js +++ b/app/assets/javascripts/admin/addon/routes/admin-watched-words.js @@ -10,17 +10,8 @@ export default DiscourseRoute.extend({ return WatchedWord.findAll(); }, - setupController(controller, model) { - controller.set("model", model); - if (model && model.length) { - controller.set("regularExpressions", model[0].get("regularExpressions")); - } - }, - - afterModel(watchedWordsList) { - this.controllerFor("adminWatchedWords").set( - "allWatchedWords", - watchedWordsList - ); + afterModel(model) { + const controller = this.controllerFor("adminWatchedWords"); + controller.set("allWatchedWords", model); }, }); diff --git a/app/assets/javascripts/admin/addon/services/admin-tools.js b/app/assets/javascripts/admin/addon/services/admin-tools.js index 8928af14e5a40..fe282fefd6b28 100644 --- a/app/assets/javascripts/admin/addon/services/admin-tools.js +++ b/app/assets/javascripts/admin/addon/services/admin-tools.js @@ -48,7 +48,6 @@ export default Service.extend({ _showControlModal(type, user, opts) { opts = opts || {}; - let controller = showModal(`admin-${type}-user`, { admin: true, modalClass: `${type}-user-modal`, @@ -65,6 +64,8 @@ export default Service.extend({ before: opts.before, successCallback: opts.successCallback, }); + + controller.finishedSetup(); }); }, diff --git a/app/assets/javascripts/admin/addon/templates/badges-award.hbs b/app/assets/javascripts/admin/addon/templates/badges-award.hbs index bb6c4bf22e9ad..52bed37623391 100644 --- a/app/assets/javascripts/admin/addon/templates/badges-award.hbs +++ b/app/assets/javascripts/admin/addon/templates/badges-award.hbs @@ -14,25 +14,62 @@
+ {{#if success}} + {{d-icon "check" class="bulk-award-status-icon success"}} + {{else}} + {{d-icon "times" class="bulk-award-status-icon failure"}} + {{/if}} + {{resultsMessage}} +
+ {{#if unmatchedEntries.length}} ++ {{d-icon "exclamation-triangle" class="bulk-award-status-icon failure"}} + + {{#if unmatchedEntriesTruncated}} + {{i18n "admin.badges.mass_award.csv_has_unmatched_users_truncated_list" count=unmatchedEntriesCount}} + {{else}} + {{i18n "admin.badges.mass_award.csv_has_unmatched_users"}} + {{/if}} + +
+