| @@ -0,0 +1,201 @@ | ||
| name: CI | ||
|
|
||
| on: | ||
| push: | ||
| branches: | ||
| - master | ||
| pull_request: | ||
| branches-ignore: | ||
| - "tests-passed" | ||
|
|
||
| jobs: | ||
| build: | ||
| name: "${{ matrix.target }}-${{ matrix.build_types }}" | ||
| runs-on: ${{ matrix.os }} | ||
| container: discourse/discourse_test:release | ||
| timeout-minutes: 60 | ||
|
|
||
| env: | ||
| DISCOURSE_HOSTNAME: www.example.com | ||
| RUBY_GLOBAL_METHOD_CACHE_SIZE: 131072 | ||
| BUILD_TYPE: ${{ matrix.build_types }} | ||
| TARGET: ${{ matrix.target }} | ||
| RAILS_ENV: test | ||
| PGHOST: postgres | ||
| PGUSER: discourse | ||
| PGPASSWORD: discourse | ||
|
|
||
| strategy: | ||
| fail-fast: false | ||
|
|
||
| matrix: | ||
| build_types: ["BACKEND", "FRONTEND", "LINT"] | ||
| target: ["PLUGINS", "CORE"] | ||
| os: [ubuntu-latest] | ||
| ruby: ["2.6"] | ||
| postgres: ["12"] | ||
| redis: ["4.x"] | ||
|
|
||
| services: | ||
| postgres: | ||
| image: postgres:${{ matrix.postgres }} | ||
| ports: | ||
| - 5432:5432 | ||
| env: | ||
| POSTGRES_USER: discourse | ||
| POSTGRES_PASSWORD: discourse | ||
| POSTGRES_DB: discourse_test | ||
| options: >- | ||
| --mount type=tmpfs,destination=/var/lib/postgresql/data | ||
| --health-cmd pg_isready | ||
| --health-interval 10s | ||
| --health-timeout 5s | ||
| --health-retries 5 | ||
| steps: | ||
| - uses: actions/checkout@master | ||
| with: | ||
| fetch-depth: 1 | ||
|
|
||
| - name: Setup Git | ||
| run: | | ||
| git config --global user.email "ci@ci.invalid" | ||
| git config --global user.name "Discourse CI" | ||
| - name: Setup redis | ||
| uses: shogo82148/actions-setup-redis@v1 | ||
| if: env.BUILD_TYPE != 'LINT' | ||
| with: | ||
| redis-version: ${{ matrix.redis }} | ||
|
|
||
| - name: Bundler cache | ||
| uses: actions/cache@v2 | ||
| with: | ||
| path: vendor/bundle | ||
| key: ${{ runner.os }}-${{ matrix.ruby }}-gem-${{ hashFiles('**/Gemfile.lock') }} | ||
| restore-keys: | | ||
| ${{ runner.os }}-${{ matrix.ruby }}-gem- | ||
| - name: Setup gems | ||
| run: | | ||
| bundle config --local path vendor/bundle | ||
| bundle config --local deployment true | ||
| bundle config --local without development | ||
| bundle install --jobs 4 | ||
| bundle clean | ||
| - name: Get yarn cache directory | ||
| id: yarn-cache-dir | ||
| run: echo "::set-output name=dir::$(yarn cache dir)" | ||
|
|
||
| - name: Yarn cache | ||
| uses: actions/cache@v2 | ||
| id: yarn-cache | ||
| with: | ||
| path: ${{ steps.yarn-cache-dir.outputs.dir }} | ||
| key: ${{ runner.os }}-${{ matrix.os }}-yarn-${{ hashFiles('**/yarn.lock') }} | ||
| restore-keys: | | ||
| ${{ runner.os }}-${{ matrix.os }}-yarn- | ||
| - name: Yarn install | ||
| run: yarn install | ||
|
|
||
| - name: "Checkout official plugins" | ||
| if: env.TARGET == 'PLUGINS' | ||
| run: bin/rake plugin:install_all_official | ||
|
|
||
| - name: Create database | ||
| if: env.BUILD_TYPE != 'LINT' | ||
| run: | | ||
| bin/rake db:create | ||
| bin/rake db:migrate | ||
| - name: Create parallel databases | ||
| if: env.BUILD_TYPE == 'BACKEND' && env.TARGET == 'CORE' | ||
| run: | | ||
| bin/rake parallel:create | ||
| bin/rake parallel:migrate | ||
| - name: Rubocop (core and core plugins) | ||
| if: env.BUILD_TYPE == 'LINT' && env.TARGET == 'CORE' | ||
| run: bundle exec rubocop . | ||
|
|
||
| - name: Rubocop (all plugins) | ||
| if: env.BUILD_TYPE == 'LINT' && env.TARGET == 'PLUGINS' | ||
| run: bundle exec rubocop plugins | ||
|
|
||
| - name: ESLint (core) | ||
| if: env.BUILD_TYPE == 'LINT' && env.TARGET == 'CORE' | ||
| run: yarn eslint --ext .js,.js.es6 --no-error-on-unmatched-pattern app/assets/javascripts | ||
|
|
||
| - name: ESLint (core plugins) | ||
| if: env.BUILD_TYPE == 'LINT' && env.TARGET == 'CORE' | ||
| run: yarn eslint --ext .js,.js.es6 --no-error-on-unmatched-pattern plugins/**/{test,assets}/javascripts | ||
|
|
||
| - name: ESLint (all plugins) | ||
| if: env.BUILD_TYPE == 'LINT' && env.TARGET == 'PLUGINS' | ||
| run: yarn eslint --ext .js,.js.es6 --no-error-on-unmatched-pattern plugins/**/{test,assets}/javascripts | ||
|
|
||
| - name: Prettier (core and core plugins) | ||
| if: env.BUILD_TYPE == 'LINT' && env.TARGET == 'CORE' | ||
| run: | | ||
| yarn prettier -v | ||
| yarn prettier --list-different \ | ||
| "app/assets/stylesheets/**/*.scss" \ | ||
| "app/assets/javascripts/**/*.{js,es6}" \ | ||
| "plugins/**/assets/stylesheets/**/*.scss" \ | ||
| "plugins/**/assets/javascripts/**/*.{js,es6}" | ||
| - name: Prettier (all plugins) | ||
| if: env.BUILD_TYPE == 'LINT' && env.TARGET == 'PLUGINS' | ||
| run: | | ||
| yarn prettier -v | ||
| yarn prettier --list-different \ | ||
| "plugins/**/assets/stylesheets/**/*.scss" \ | ||
| "plugins/**/assets/javascripts/**/*.{js,es6}" | ||
| - name: Ember template lint (core and core plugins) | ||
| if: env.BUILD_TYPE == 'LINT' && env.TARGET == 'CORE' | ||
| run: | | ||
| yarn ember-template-lint \ | ||
| app/assets/javascripts \ | ||
| plugins/**/assets/javascripts | ||
| - name: Ember template lint (all plugins) | ||
| if: env.BUILD_TYPE == 'LINT' && env.TARGET == 'PLUGINS' | ||
| run: | | ||
| yarn ember-template-lint \ | ||
| plugins/**/assets/javascripts | ||
| - name: Core English locale | ||
| if: env.BUILD_TYPE == 'LINT' && env.TARGET == 'CORE' | ||
| run: bundle exec ruby script/i18n_lint.rb "config/**/locales/{client,server}.en.yml" | ||
|
|
||
| - name: Plugin English locale | ||
| if: env.BUILD_TYPE == 'LINT' && env.TARGET == 'PLUGINS' | ||
| run: bundle exec ruby script/i18n_lint.rb "plugins/**/locales/{client,server}.en.yml" | ||
|
|
||
| - name: Core RSpec | ||
| if: env.BUILD_TYPE == 'BACKEND' && env.TARGET == 'CORE' | ||
| run: | | ||
| bin/turbo_rspec | ||
| bin/rake plugin:spec | ||
| - name: Plugin RSpec | ||
| if: env.BUILD_TYPE == 'BACKEND' && env.TARGET == 'PLUGINS' | ||
| run: bin/rake plugin:spec | ||
|
|
||
| - name: Core QUnit | ||
| if: env.BUILD_TYPE == 'FRONTEND' && env.TARGET == 'CORE' | ||
| run: bundle exec rake qunit:test['1200000'] | ||
| timeout-minutes: 30 | ||
|
|
||
| - name: Wizard QUnit | ||
| if: env.BUILD_TYPE == 'FRONTEND' && env.TARGET == 'CORE' | ||
| run: bundle exec rake qunit:test['1200000','/wizard/qunit'] | ||
| timeout-minutes: 30 | ||
|
|
||
| - name: Plugin QUnit # Tests core plugins in TARGET=CORE, and all plugins in TARGET=PLUGINS | ||
| if: env.BUILD_TYPE == 'FRONTEND' | ||
| run: bundle exec rake plugin:qunit['*','1200000'] | ||
| timeout-minutes: 30 |
| @@ -0,0 +1,12 @@ | ||
| sources: | ||
| yarn: true | ||
| bundler: true | ||
| allowed: | ||
| - mit | ||
| - apache-2.0 | ||
| - bsd-2-clause | ||
| - bsd-3-clause | ||
| - cc0-1.0 | ||
| - isc | ||
| - other | ||
| - none |
| @@ -1,3 +1,26 @@ | ||
| app/assets/stylesheets/vendor/ | ||
| plugins/**/assets/stylesheets/vendor/ | ||
| plugins/**/assets/javascripts/vendor/ | ||
| package.json | ||
| config/locales/**/*.yml | ||
| !config/locales/**/*.en*.yml | ||
| script/import_scripts/**/*.yml | ||
|
|
||
| app/assets/javascripts/discourse-loader.js | ||
| app/assets/javascripts/env.js | ||
| app/assets/javascripts/main_include_admin.js | ||
| app/assets/javascripts/vendor.js | ||
| app/assets/javascripts/locales/i18n.js | ||
| app/assets/javascripts/ember-addons/ | ||
| app/assets/javascripts/discourse/lib/autosize.js | ||
| lib/javascripts/locale/ | ||
| lib/javascripts/messageformat.js | ||
| lib/highlight_js/ | ||
| plugins/**/lib/javascripts/locale | ||
| public/ | ||
| vendor/ | ||
| app/assets/javascripts/discourse/tests/test_helper.js | ||
| app/assets/javascripts/discourse/tests/fixtures | ||
| node_modules/ | ||
| dist/ | ||
| **/*.rb |
| @@ -0,0 +1 @@ | ||
| {} |
| @@ -0,0 +1,4 @@ | ||
| --format progress | ||
| --format ParallelTests::RSpec::RuntimeLogger --out tmp/parallel_runtime_rspec.log | ||
| --format ParallelTests::RSpec::SummaryLogger --out tmp/spec_summary.log | ||
| --format ParallelTests::RSpec::FailuresLogger --out tmp/failing_specs.log |
| @@ -1,116 +1,9 @@ | ||
| AllCops: | ||
| TargetRubyVersion: 2.4 | ||
| DisabledByDefault: true | ||
| Exclude: | ||
| - 'db/schema.rb' | ||
| - 'bundle/**/*' | ||
| - 'vendor/**/*' | ||
| - 'node_modules/**/*' | ||
| - 'public/**/*' | ||
| inherit_gem: | ||
| rubocop-discourse: default.yml | ||
|
|
||
| # Prefer &&/|| over and/or. | ||
| Style/AndOr: | ||
| Enabled: true | ||
|
|
||
| # Do not use braces for hash literals when they are the last argument of a | ||
| # method call. | ||
| Style/BracesAroundHashParameters: | ||
| Enabled: true | ||
|
|
||
| # Align `when` with `case`. | ||
| Layout/CaseIndentation: | ||
| Enabled: true | ||
|
|
||
| # Align comments with method definitions. | ||
| Layout/CommentIndentation: | ||
| Enabled: true | ||
|
|
||
| # No extra empty lines. | ||
| Layout/EmptyLines: | ||
| Enabled: true | ||
|
|
||
| # Use Ruby >= 1.9 syntax for hashes. Prefer { a: :b } over { :a => :b }. | ||
| Style/HashSyntax: | ||
| Enabled: true | ||
|
|
||
| # Two spaces, no tabs (for indentation). | ||
| Layout/IndentationWidth: | ||
| Enabled: true | ||
|
|
||
| Layout/SpaceAfterColon: | ||
| Enabled: true | ||
|
|
||
| Layout/SpaceAfterComma: | ||
| Enabled: true | ||
|
|
||
| Layout/SpaceAroundEqualsInParameterDefault: | ||
| Enabled: true | ||
|
|
||
| Layout/SpaceAroundKeyword: | ||
| Enabled: true | ||
|
|
||
| Layout/SpaceAroundOperators: | ||
| Enabled: true | ||
|
|
||
| Layout/SpaceBeforeFirstArg: | ||
| Enabled: true | ||
|
|
||
| # Defining a method with parameters needs parentheses. | ||
| Style/MethodDefParentheses: | ||
| Enabled: true | ||
|
|
||
| # Use `foo {}` not `foo{}`. | ||
| Layout/SpaceBeforeBlockBraces: | ||
| Enabled: true | ||
|
|
||
| # Use `foo { bar }` not `foo {bar}`. | ||
| Layout/SpaceInsideBlockBraces: | ||
| Enabled: true | ||
|
|
||
| # Use `{ a: 1 }` not `{a:1}`. | ||
| Layout/SpaceInsideHashLiteralBraces: | ||
| Enabled: true | ||
|
|
||
| Layout/SpaceInsideParens: | ||
| Enabled: true | ||
|
|
||
| # Detect hard tabs, no hard tabs. | ||
| Layout/Tab: | ||
| Enabled: true | ||
|
|
||
| # Blank lines should not have any spaces. | ||
| Layout/TrailingBlankLines: | ||
| Enabled: true | ||
|
|
||
| # No trailing whitespace. | ||
| Layout/TrailingWhitespace: | ||
| Enabled: true | ||
|
|
||
| Lint/Debugger: | ||
| Enabled: true | ||
|
|
||
| Layout/BlockAlignment: | ||
| Enabled: true | ||
|
|
||
| # Align `end` with the matching keyword or starting expression except for | ||
| # assignments, where it should be aligned with the LHS. | ||
| Layout/EndAlignment: | ||
| Enabled: true | ||
| EnforcedStyleAlignWith: variable | ||
|
|
||
| # Use my_method(my_arg) not my_method( my_arg ) or my_method my_arg. | ||
| Lint/RequireParentheses: | ||
| Enabled: true | ||
|
|
||
| Lint/ShadowingOuterLocalVariable: | ||
| Enabled: true | ||
|
|
||
| Layout/MultilineMethodCallIndentation: | ||
| Enabled: true | ||
| EnforcedStyle: indented | ||
| # Still work to do in ensuring we don't link old files | ||
| Discourse/NoAddReferenceOrAliasesActiveRecordMigration: | ||
| Enabled: false | ||
|
|
||
| Layout/AlignHash: | ||
| Discourse/NoResetColumnInformationInMigrations: | ||
| Enabled: true | ||
|
|
||
| Bundler/OrderedGems: | ||
| Enabled: false |
| @@ -1 +1 @@ | ||
| 2.4.4 | ||
| 2.6.5 |
| @@ -0,0 +1,55 @@ | ||
| module.exports = { | ||
| extends: "recommended", | ||
| ignore: ["**/*.raw"], | ||
|
|
||
| // Pending: | ||
| // "eol-last": "always", | ||
|
|
||
| rules: { | ||
| "block-indentation": true, | ||
| "deprecated-render-helper": true, | ||
| "linebreak-style": true, | ||
| "link-rel-noopener": "strict", | ||
| "no-abstract-roles": true, | ||
| "no-args-paths": true, | ||
| "no-attrs-in-components": true, | ||
| "no-debugger": true, | ||
| "no-duplicate-attributes": true, | ||
| "no-extra-mut-helper-argument": true, | ||
| "no-html-comments": true, | ||
| "no-index-component-invocation": true, | ||
| "no-inline-styles": false, | ||
| "no-input-block": true, | ||
| "no-input-tagname": true, | ||
| "no-implicit-this": false, | ||
| "no-invalid-interactive": true, | ||
| "no-invalid-link-text": true, | ||
| "no-invalid-meta": true, | ||
| "no-invalid-role": true, | ||
| "no-log": true, | ||
| "no-negated-condition": true, | ||
| "no-nested-interactive": true, | ||
| "no-multiple-empty-lines": true, | ||
| "no-obsolete-elements": true, | ||
| "no-outlet-outside-routes": true, | ||
| "no-partial": true, | ||
| "no-positive-tabindex": false, | ||
| "no-quoteless-attributes": true, | ||
| "no-shadowed-elements": true, | ||
| "no-trailing-spaces": true, | ||
| "no-triple-curlies": true, | ||
| "no-unbound": true, | ||
| "no-unnecessary-concat": true, | ||
| "no-unnecessary-component-helper": true, | ||
| "no-unused-block-params": true, | ||
| quotes: "double", | ||
| "require-button-type": true, | ||
| "require-iframe-title": true, | ||
| "require-valid-alt-text": false, | ||
| "self-closing-void-elements": true, | ||
| "simple-unless": true, | ||
| "style-concatenation": true, | ||
| "table-groups": true, | ||
| "link-href-attributes": false, | ||
| }, | ||
| }; |
| @@ -0,0 +1,19 @@ | ||
| { | ||
| // Use IntelliSense to learn about possible attributes. | ||
| // Hover to view descriptions of existing attributes. | ||
| // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 | ||
| "version": "0.2.0", | ||
| "configurations": [ | ||
| { | ||
| "name": "Discourse", | ||
| "type": "Ruby", | ||
| "request": "launch", | ||
| "cwd": "/home/discourse/workspace/discourse", | ||
| // run bundle install before rails server | ||
| "preLaunchTask": "Prepare discourse", | ||
| "env": { "DISCOURSE_DEV_HOSTS": "${env:CLOUDENV_ENVIRONMENT_ID}-9292.apps.codespaces.githubusercontent.com", "UNICORN_BIND_ALL": "1", "UNICORN_WORKERS": "4", "DISCOURSE_DEV_ALLOW_ANON_TO_IMPERSONATE": "1" }, | ||
| "program": "bin/unicorn", | ||
| "args": ["-x"], | ||
| } | ||
| ] | ||
| } |
| @@ -0,0 +1,12 @@ | ||
| { | ||
| // See https://go.microsoft.com/fwlink/?LinkId=733558 | ||
| // for the documentation about the tasks.json format | ||
| "version": "2.0.0", | ||
| "tasks": [ | ||
| { | ||
| "label": "Prepare discourse", | ||
| "type": "shell", | ||
| "command": "cd /home/discourse/workspace/discourse && bundle install && yarn && bin/rake db:migrate" | ||
| }, | ||
| ], | ||
| } |
| @@ -0,0 +1,20 @@ | ||
| // discourse-skip-module | ||
| (function () { | ||
| setTimeout(function () { | ||
| const $activateButton = $("#activate-account-button"); | ||
| $activateButton.on("click", function () { | ||
| $activateButton.prop("disabled", true); | ||
| const hpPath = document.getElementById("data-activate-account").dataset | ||
| .path; | ||
| $.ajax(hpPath) | ||
| .then(function (hp) { | ||
| $("#password_confirmation").val(hp.value); | ||
| $("#challenge").val(hp.challenge.split("").reverse().join("")); | ||
| $("#activate-account-form").submit(); | ||
| }) | ||
| .fail(function () { | ||
| $activateButton.prop("disabled", false); | ||
| }); | ||
| }); | ||
| }, 50); | ||
| })(); |
| @@ -0,0 +1,13 @@ | ||
| import RESTAdapter from "discourse/adapters/rest"; | ||
|
|
||
| export default RESTAdapter.extend({ | ||
| jsonMode: true, | ||
|
|
||
| basePath() { | ||
| return "/admin/api/"; | ||
| }, | ||
|
|
||
| apiNameFor() { | ||
| return "key"; | ||
| }, | ||
| }); |
| @@ -0,0 +1,11 @@ | ||
| import RestAdapter from "discourse/adapters/rest"; | ||
|
|
||
| export default function buildPluginAdapter(pluginName) { | ||
| return RestAdapter.extend({ | ||
| pathFor(store, type, findArgs) { | ||
| return ( | ||
| "/admin/plugins/" + pluginName + this._super(store, type, findArgs) | ||
| ); | ||
| }, | ||
| }); | ||
| } |
| @@ -0,0 +1,7 @@ | ||
| import RestAdapter from "discourse/adapters/rest"; | ||
|
|
||
| export default RestAdapter.extend({ | ||
| basePath() { | ||
| return "/admin/customize/"; | ||
| }, | ||
| }); |
| @@ -0,0 +1,7 @@ | ||
| import RestAdapter from "discourse/adapters/rest"; | ||
|
|
||
| export default RestAdapter.extend({ | ||
| pathFor() { | ||
| return "/admin/customize/email_style"; | ||
| }, | ||
| }); |
| @@ -0,0 +1,7 @@ | ||
| import RestAdapter from "discourse/adapters/rest"; | ||
|
|
||
| export default RestAdapter.extend({ | ||
| pathFor() { | ||
| return "/admin/customize/embedding"; | ||
| }, | ||
| }); |
| @@ -0,0 +1,7 @@ | ||
| import RestAdapter from "discourse/adapters/rest"; | ||
|
|
||
| export default RestAdapter.extend({ | ||
| basePath() { | ||
| return "/admin/logs/"; | ||
| }, | ||
| }); |
| @@ -0,0 +1,5 @@ | ||
| import RestAdapter from "discourse/adapters/rest"; | ||
|
|
||
| export default RestAdapter.extend({ | ||
| jsonMode: true, | ||
| }); |
| @@ -0,0 +1,26 @@ | ||
| import RestAdapter from "discourse/adapters/rest"; | ||
|
|
||
| export default RestAdapter.extend({ | ||
| basePath() { | ||
| return "/admin/"; | ||
| }, | ||
|
|
||
| afterFindAll(results) { | ||
| let map = {}; | ||
| results.forEach((theme) => { | ||
| map[theme.id] = theme; | ||
| }); | ||
| results.forEach((theme) => { | ||
| let mapped = theme.get("child_themes") || []; | ||
| mapped = mapped.map((t) => map[t.id]); | ||
| theme.set("childThemes", mapped); | ||
|
|
||
| let mappedParents = theme.get("parent_themes") || []; | ||
| mappedParents = mappedParents.map((t) => map[t.id]); | ||
| theme.set("parentThemes", mappedParents); | ||
| }); | ||
| return results; | ||
| }, | ||
|
|
||
| jsonMode: true, | ||
| }); |
| @@ -0,0 +1,7 @@ | ||
| import RESTAdapter from "discourse/adapters/rest"; | ||
|
|
||
| export default RESTAdapter.extend({ | ||
| basePath() { | ||
| return "/admin/api/"; | ||
| }, | ||
| }); |
| @@ -0,0 +1,7 @@ | ||
| import RESTAdapter from "discourse/adapters/rest"; | ||
|
|
||
| export default RESTAdapter.extend({ | ||
| basePath() { | ||
| return "/admin/api/"; | ||
| }, | ||
| }); |
| @@ -0,0 +1,167 @@ | ||
| import Component from "@ember/component"; | ||
| import getURL from "discourse-common/lib/get-url"; | ||
| import loadScript from "discourse/lib/load-script"; | ||
| import { observes } from "discourse-common/utils/decorators"; | ||
| import { on } from "@ember/object/evented"; | ||
|
|
||
| export default Component.extend({ | ||
| mode: "css", | ||
| classNames: ["ace-wrapper"], | ||
| _editor: null, | ||
| _skipContentChangeEvent: null, | ||
| disabled: false, | ||
| htmlPlaceholder: false, | ||
|
|
||
| @observes("editorId") | ||
| editorIdChanged() { | ||
| if (this.autofocus) { | ||
| this.send("focus"); | ||
| } | ||
| }, | ||
|
|
||
| @observes("content") | ||
| contentChanged() { | ||
| const content = this.content || ""; | ||
| if (this._editor && !this._skipContentChangeEvent) { | ||
| this._editor.getSession().setValue(content); | ||
| } | ||
| }, | ||
|
|
||
| @observes("mode") | ||
| modeChanged() { | ||
| if (this._editor && !this._skipContentChangeEvent) { | ||
| this._editor.getSession().setMode("ace/mode/" + this.mode); | ||
| } | ||
| }, | ||
|
|
||
| @observes("placeholder") | ||
| placeholderChanged() { | ||
| if (this._editor) { | ||
| this._editor.setOptions({ | ||
| placeholder: this.placeholder, | ||
| }); | ||
| } | ||
| }, | ||
|
|
||
| @observes("disabled") | ||
| disabledStateChanged() { | ||
| this.changeDisabledState(); | ||
| }, | ||
|
|
||
| changeDisabledState() { | ||
| const editor = this._editor; | ||
| if (editor) { | ||
| const disabled = this.disabled; | ||
| editor.setOptions({ | ||
| readOnly: disabled, | ||
| highlightActiveLine: !disabled, | ||
| highlightGutterLine: !disabled, | ||
| }); | ||
| editor.container.parentNode.setAttribute("data-disabled", disabled); | ||
| } | ||
| }, | ||
|
|
||
| _destroyEditor: on("willDestroyElement", function () { | ||
| if (this._editor) { | ||
| this._editor.destroy(); | ||
| this._editor = null; | ||
| } | ||
| if (this.appEvents) { | ||
| // xxx: don't run during qunit tests | ||
| this.appEvents.off("ace:resize", this, "resize"); | ||
| } | ||
|
|
||
| $(window).off("ace:resize"); | ||
| }), | ||
|
|
||
| resize() { | ||
| if (this._editor) { | ||
| this._editor.resize(); | ||
| } | ||
| }, | ||
|
|
||
| didInsertElement() { | ||
| this._super(...arguments); | ||
| loadScript("/javascripts/ace/ace.js").then(() => { | ||
| window.ace.require(["ace/ace"], (loadedAce) => { | ||
| loadedAce.config.set("loadWorkerFromBlob", false); | ||
| loadedAce.config.set("workerPath", getURL("/javascripts/ace")); // Do not use CDN for workers | ||
|
|
||
| if (this.htmlPlaceholder) { | ||
| this._overridePlaceholder(loadedAce); | ||
| } | ||
|
|
||
| if (!this.element || this.isDestroying || this.isDestroyed) { | ||
| return; | ||
| } | ||
| const editor = loadedAce.edit(this.element.querySelector(".ace")); | ||
|
|
||
| editor.setTheme("ace/theme/chrome"); | ||
| editor.setShowPrintMargin(false); | ||
| editor.setOptions({ fontSize: "14px", placeholder: this.placeholder }); | ||
| editor.getSession().setMode("ace/mode/" + this.mode); | ||
| editor.on("change", () => { | ||
| this._skipContentChangeEvent = true; | ||
| this.set("content", editor.getSession().getValue()); | ||
| this._skipContentChangeEvent = false; | ||
| }); | ||
| editor.$blockScrolling = Infinity; | ||
| editor.renderer.setScrollMargin(10, 10); | ||
|
|
||
| this.element.setAttribute("data-editor", editor); | ||
| this._editor = editor; | ||
| this.changeDisabledState(); | ||
|
|
||
| $(window) | ||
| .off("ace:resize") | ||
| .on("ace:resize", () => this.appEvents.trigger("ace:resize")); | ||
|
|
||
| if (this.appEvents) { | ||
| // xxx: don't run during qunit tests | ||
| this.appEvents.on("ace:resize", this, "resize"); | ||
| } | ||
|
|
||
| if (this.autofocus) { | ||
| this.send("focus"); | ||
| } | ||
| }); | ||
| }); | ||
| }, | ||
|
|
||
| actions: { | ||
| focus() { | ||
| if (this._editor) { | ||
| this._editor.focus(); | ||
| this._editor.navigateFileEnd(); | ||
| } | ||
| }, | ||
| }, | ||
|
|
||
| _overridePlaceholder(loadedAce) { | ||
| const originalPlaceholderSetter = | ||
| loadedAce.config.$defaultOptions.editor.placeholder.set; | ||
|
|
||
| loadedAce.config.$defaultOptions.editor.placeholder.set = function () { | ||
| if (!this.$updatePlaceholder) { | ||
| const originalRendererOn = this.renderer.on; | ||
| this.renderer.on = function () {}; | ||
| originalPlaceholderSetter.call(this, ...arguments); | ||
| this.renderer.on = originalRendererOn; | ||
|
|
||
| const originalUpdatePlaceholder = this.$updatePlaceholder; | ||
|
|
||
| this.$updatePlaceholder = function () { | ||
| originalUpdatePlaceholder.call(this, ...arguments); | ||
|
|
||
| if (this.renderer.placeholderNode) { | ||
| this.renderer.placeholderNode.innerHTML = this.$placeholder || ""; | ||
| } | ||
| }.bind(this); | ||
|
|
||
| this.on("input", this.$updatePlaceholder); | ||
| } | ||
|
|
||
| this.$updatePlaceholder(); | ||
| }; | ||
| }, | ||
| }); |
| @@ -0,0 +1,80 @@ | ||
| import { observes, on } from "discourse-common/utils/decorators"; | ||
| import Component from "@ember/component"; | ||
| import I18n from "I18n"; | ||
| import discourseDebounce from "discourse-common/lib/debounce"; | ||
| import { scheduleOnce } from "@ember/runloop"; | ||
|
|
||
| export default Component.extend({ | ||
| classNames: ["admin-backups-logs"], | ||
| showLoadingSpinner: false, | ||
| hasFormattedLogs: false, | ||
| noLogsMessage: I18n.t("admin.backups.logs.none"), | ||
|
|
||
| init() { | ||
| this._super(...arguments); | ||
| this._reset(); | ||
| }, | ||
|
|
||
| _reset() { | ||
| this.setProperties({ formattedLogs: "", index: 0 }); | ||
| }, | ||
|
|
||
| _scrollDown() { | ||
| const div = this.element; | ||
| div.scrollTop = div.scrollHeight; | ||
| }, | ||
|
|
||
| @on("init") | ||
| @observes("logs.[]") | ||
| _resetFormattedLogs() { | ||
| if (this.logs.length === 0) { | ||
| this._reset(); // reset the cached logs whenever the model is reset | ||
| this.renderLogs(); | ||
| } | ||
| }, | ||
|
|
||
| _updateFormattedLogsFunc: function () { | ||
| const logs = this.logs; | ||
| if (logs.length === 0) { | ||
| return; | ||
| } | ||
|
|
||
| // do the log formatting only once for HELLish performance | ||
| let formattedLogs = this.formattedLogs; | ||
| for (let i = this.index, length = logs.length; i < length; i++) { | ||
| const date = logs[i].get("timestamp"), | ||
| message = logs[i].get("message"); | ||
| formattedLogs += "[" + date + "] " + message + "\n"; | ||
| } | ||
| // update the formatted logs & cache index | ||
| this.setProperties({ | ||
| formattedLogs: formattedLogs, | ||
| index: logs.length, | ||
| }); | ||
| // force rerender | ||
| this.renderLogs(); | ||
|
|
||
| scheduleOnce("afterRender", this, this._scrollDown); | ||
| }, | ||
|
|
||
| @on("init") | ||
| @observes("logs.[]") | ||
| _updateFormattedLogs() { | ||
| discourseDebounce(this, this._updateFormattedLogsFunc, 150); | ||
| }, | ||
|
|
||
| renderLogs() { | ||
| const formattedLogs = this.formattedLogs; | ||
| if (formattedLogs && formattedLogs.length > 0) { | ||
| this.set("hasFormattedLogs", true); | ||
| } else { | ||
| this.set("hasFormattedLogs", false); | ||
| } | ||
| // add a loading indicator | ||
| if (this.get("status.isOperationRunning")) { | ||
| this.set("showLoadingSpinner", true); | ||
| } else { | ||
| this.set("showLoadingSpinner", false); | ||
| } | ||
| }, | ||
| }); |
| @@ -0,0 +1,24 @@ | ||
| import Component from "@ember/component"; | ||
| export default Component.extend({ | ||
| tagName: "", | ||
|
|
||
| buffer: "", | ||
| editing: false, | ||
|
|
||
| init() { | ||
| this._super(...arguments); | ||
| this.set("editing", false); | ||
| }, | ||
|
|
||
| actions: { | ||
| edit() { | ||
| this.set("buffer", this.value); | ||
| this.toggleProperty("editing"); | ||
| }, | ||
|
|
||
| save() { | ||
| // Action has to toggle 'editing' property. | ||
| this.action(this.buffer); | ||
| }, | ||
| }, | ||
| }); |
| @@ -0,0 +1,4 @@ | ||
| import Component from "@ember/component"; | ||
| export default Component.extend({ | ||
| classNames: ["row"], | ||
| }); |
| @@ -0,0 +1,57 @@ | ||
| import Component from "@ember/component"; | ||
| import loadScript from "discourse/lib/load-script"; | ||
|
|
||
| export default Component.extend({ | ||
| tagName: "canvas", | ||
| type: "line", | ||
|
|
||
| refreshChart() { | ||
| const ctx = this.element.getContext("2d"); | ||
| const model = this.model; | ||
| const rawData = this.get("model.data"); | ||
|
|
||
| let data = { | ||
| labels: rawData.map((r) => r.x), | ||
| datasets: [ | ||
| { | ||
| data: rawData.map((r) => r.y), | ||
| label: model.get("title"), | ||
| backgroundColor: `rgba(200,220,240,${this.type === "bar" ? 1 : 0.3})`, | ||
| borderColor: "#08C", | ||
| }, | ||
| ], | ||
| }; | ||
|
|
||
| const config = { | ||
| type: this.type, | ||
| data: data, | ||
| options: { | ||
| responsive: true, | ||
| tooltips: { | ||
| callbacks: { | ||
| title: (context) => | ||
| moment(context[0].xLabel, "YYYY-MM-DD").format("LL"), | ||
| }, | ||
| }, | ||
| scales: { | ||
| yAxes: [ | ||
| { | ||
| display: true, | ||
| ticks: { | ||
| stepSize: 1, | ||
| }, | ||
| }, | ||
| ], | ||
| }, | ||
| }, | ||
| }; | ||
|
|
||
| this._chart = new window.Chart(ctx, config); | ||
| }, | ||
|
|
||
| didInsertElement() { | ||
| loadScript("/javascripts/Chart.min.js").then(() => | ||
| this.refreshChart.apply(this) | ||
| ); | ||
| }, | ||
| }); |
| @@ -0,0 +1,4 @@ | ||
| import Component from "@ember/component"; | ||
| export default Component.extend({ | ||
| tagName: "", | ||
| }); |
| @@ -0,0 +1,240 @@ | ||
| import Component from "@ember/component"; | ||
| import discourseDebounce from "discourse-common/lib/debounce"; | ||
| import loadScript from "discourse/lib/load-script"; | ||
| import { makeArray } from "discourse-common/lib/helpers"; | ||
| import { number } from "discourse/lib/formatter"; | ||
| import { schedule } from "@ember/runloop"; | ||
|
|
||
| export default Component.extend({ | ||
| classNames: ["admin-report-chart"], | ||
| limit: 8, | ||
| total: 0, | ||
| options: null, | ||
|
|
||
| init() { | ||
| this._super(...arguments); | ||
|
|
||
| this.resizeHandler = () => | ||
| discourseDebounce(this, this._scheduleChartRendering, 500); | ||
| }, | ||
|
|
||
| didInsertElement() { | ||
| this._super(...arguments); | ||
|
|
||
| $(window).on("resize.chart", this.resizeHandler); | ||
| }, | ||
|
|
||
| willDestroyElement() { | ||
| this._super(...arguments); | ||
|
|
||
| $(window).off("resize.chart", this.resizeHandler); | ||
|
|
||
| this._resetChart(); | ||
| }, | ||
|
|
||
| didReceiveAttrs() { | ||
| this._super(...arguments); | ||
|
|
||
| discourseDebounce(this, this._scheduleChartRendering, 100); | ||
| }, | ||
|
|
||
| _scheduleChartRendering() { | ||
| schedule("afterRender", () => { | ||
| this._renderChart( | ||
| this.model, | ||
| this.element && this.element.querySelector(".chart-canvas") | ||
| ); | ||
| }); | ||
| }, | ||
|
|
||
| _renderChart(model, chartCanvas) { | ||
| if (!chartCanvas) { | ||
| return; | ||
| } | ||
|
|
||
| const context = chartCanvas.getContext("2d"); | ||
| const chartData = this._applyChartGrouping( | ||
| model, | ||
| makeArray(model.get("chartData") || model.get("data"), "weekly"), | ||
| this.options | ||
| ); | ||
| const prevChartData = makeArray( | ||
| model.get("prevChartData") || model.get("prev_data") | ||
| ); | ||
|
|
||
| const labels = chartData.map((d) => d.x); | ||
|
|
||
| const data = { | ||
| labels, | ||
| datasets: [ | ||
| { | ||
| data: chartData.map((d) => Math.round(parseFloat(d.y))), | ||
| backgroundColor: prevChartData.length | ||
| ? "transparent" | ||
| : model.secondary_color, | ||
| borderColor: model.primary_color, | ||
| pointRadius: 3, | ||
| borderWidth: 1, | ||
| pointBackgroundColor: model.primary_color, | ||
| pointBorderColor: model.primary_color, | ||
| }, | ||
| ], | ||
| }; | ||
|
|
||
| if (prevChartData.length) { | ||
| data.datasets.push({ | ||
| data: prevChartData.map((d) => Math.round(parseFloat(d.y))), | ||
| borderColor: model.primary_color, | ||
| borderDash: [5, 5], | ||
| backgroundColor: "transparent", | ||
| borderWidth: 1, | ||
| pointRadius: 0, | ||
| }); | ||
| } | ||
|
|
||
| loadScript("/javascripts/Chart.min.js").then(() => { | ||
| this._resetChart(); | ||
|
|
||
| if (!this.element) { | ||
| return; | ||
| } | ||
|
|
||
| this._chart = new window.Chart( | ||
| context, | ||
| this._buildChartConfig(data, this.options) | ||
| ); | ||
| }); | ||
| }, | ||
|
|
||
| _buildChartConfig(data, options) { | ||
| return { | ||
| type: "line", | ||
| data, | ||
| options: { | ||
| tooltips: { | ||
| callbacks: { | ||
| title: (tooltipItem) => | ||
| moment(tooltipItem[0].xLabel, "YYYY-MM-DD").format("LL"), | ||
| }, | ||
| }, | ||
| legend: { | ||
| display: false, | ||
| }, | ||
| responsive: true, | ||
| maintainAspectRatio: false, | ||
| responsiveAnimationDuration: 0, | ||
| animation: { | ||
| duration: 0, | ||
| }, | ||
| layout: { | ||
| padding: { | ||
| left: 0, | ||
| top: 0, | ||
| right: 0, | ||
| bottom: 0, | ||
| }, | ||
| }, | ||
| scales: { | ||
| yAxes: [ | ||
| { | ||
| display: true, | ||
| ticks: { | ||
| userCallback: (label) => { | ||
| if (Math.floor(label) === label) { | ||
| return label; | ||
| } | ||
| }, | ||
| callback: (label) => number(label), | ||
| sampleSize: 5, | ||
| maxRotation: 25, | ||
| minRotation: 25, | ||
| }, | ||
| }, | ||
| ], | ||
| xAxes: [ | ||
| { | ||
| display: true, | ||
| gridLines: { display: false }, | ||
| type: "time", | ||
| time: { | ||
| unit: this._unitForGrouping(options), | ||
| }, | ||
| ticks: { | ||
| sampleSize: 5, | ||
| maxRotation: 50, | ||
| minRotation: 50, | ||
| }, | ||
| }, | ||
| ], | ||
| }, | ||
| }, | ||
| }; | ||
| }, | ||
|
|
||
| _resetChart() { | ||
| if (this._chart) { | ||
| this._chart.destroy(); | ||
| this._chart = null; | ||
| } | ||
| }, | ||
|
|
||
| _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"; | ||
| } | ||
| }, | ||
| }); |
| @@ -0,0 +1,6 @@ | ||
| import Component from "@ember/component"; | ||
| export default Component.extend({ | ||
| classNames: ["admin-report-counters"], | ||
|
|
||
| attributeBindings: ["model.description:title"], | ||
| }); |
| @@ -0,0 +1,11 @@ | ||
| import Component from "@ember/component"; | ||
| import { match } from "@ember/object/computed"; | ||
| export default Component.extend({ | ||
| allTime: true, | ||
| tagName: "tr", | ||
| reverseColors: match( | ||
| "report.type", | ||
| /^(time_to_first_response|topics_with_no_response)$/ | ||
| ), | ||
| classNameBindings: ["reverseColors"], | ||
| }); |
| @@ -0,0 +1,4 @@ | ||
| import Component from "@ember/component"; | ||
| export default Component.extend({ | ||
| classNames: ["admin-report-inline-table"], | ||
| }); |
| @@ -0,0 +1,4 @@ | ||
| import Component from "@ember/component"; | ||
| export default Component.extend({ | ||
| tagName: "tr", | ||
| }); |
| @@ -0,0 +1,160 @@ | ||
| import Component from "@ember/component"; | ||
| import discourseDebounce from "discourse-common/lib/debounce"; | ||
| import loadScript from "discourse/lib/load-script"; | ||
| import { makeArray } from "discourse-common/lib/helpers"; | ||
| import { number } from "discourse/lib/formatter"; | ||
| import { schedule } from "@ember/runloop"; | ||
|
|
||
| export default Component.extend({ | ||
| classNames: ["admin-report-chart", "admin-report-stacked-chart"], | ||
|
|
||
| init() { | ||
| this._super(...arguments); | ||
|
|
||
| this.resizeHandler = () => | ||
| discourseDebounce(this, this._scheduleChartRendering, 500); | ||
| }, | ||
|
|
||
| didInsertElement() { | ||
| this._super(...arguments); | ||
|
|
||
| $(window).on("resize.chart", this.resizeHandler); | ||
| }, | ||
|
|
||
| willDestroyElement() { | ||
| this._super(...arguments); | ||
|
|
||
| $(window).off("resize.chart", this.resizeHandler); | ||
|
|
||
| this._resetChart(); | ||
| }, | ||
|
|
||
| didReceiveAttrs() { | ||
| this._super(...arguments); | ||
|
|
||
| discourseDebounce(this, this._scheduleChartRendering, 100); | ||
| }, | ||
|
|
||
| _scheduleChartRendering() { | ||
| schedule("afterRender", () => { | ||
| if (!this.element) { | ||
| return; | ||
| } | ||
|
|
||
| this._renderChart( | ||
| this.model, | ||
| this.element.querySelector(".chart-canvas") | ||
| ); | ||
| }); | ||
| }, | ||
|
|
||
| _renderChart(model, chartCanvas) { | ||
| if (!chartCanvas) { | ||
| return; | ||
| } | ||
|
|
||
| const context = chartCanvas.getContext("2d"); | ||
|
|
||
| const chartData = makeArray(model.get("chartData") || model.get("data")); | ||
|
|
||
| const data = { | ||
| labels: chartData[0].data.mapBy("x"), | ||
| datasets: chartData.map((cd) => { | ||
| return { | ||
| label: cd.label, | ||
| stack: "pageviews-stack", | ||
| data: cd.data.map((d) => Math.round(parseFloat(d.y))), | ||
| backgroundColor: cd.color, | ||
| }; | ||
| }), | ||
| }; | ||
|
|
||
| loadScript("/javascripts/Chart.min.js").then(() => { | ||
| this._resetChart(); | ||
|
|
||
| this._chart = new window.Chart(context, this._buildChartConfig(data)); | ||
| }); | ||
| }, | ||
|
|
||
| _buildChartConfig(data) { | ||
| return { | ||
| type: "bar", | ||
| data, | ||
| options: { | ||
| responsive: true, | ||
| maintainAspectRatio: false, | ||
| responsiveAnimationDuration: 0, | ||
| hover: { mode: "index" }, | ||
| animation: { | ||
| duration: 0, | ||
| }, | ||
| tooltips: { | ||
| mode: "index", | ||
| intersect: false, | ||
| callbacks: { | ||
| beforeFooter: (tooltipItem) => { | ||
| let total = 0; | ||
| tooltipItem.forEach( | ||
| (item) => (total += parseInt(item.yLabel || 0, 10)) | ||
| ); | ||
| return `= ${total}`; | ||
| }, | ||
| title: (tooltipItem) => | ||
| moment(tooltipItem[0].xLabel, "YYYY-MM-DD").format("LL"), | ||
| }, | ||
| }, | ||
| layout: { | ||
| padding: { | ||
| left: 0, | ||
| top: 0, | ||
| right: 0, | ||
| bottom: 0, | ||
| }, | ||
| }, | ||
| scales: { | ||
| yAxes: [ | ||
| { | ||
| stacked: true, | ||
| display: true, | ||
| ticks: { | ||
| userCallback: (label) => { | ||
| if (Math.floor(label) === label) { | ||
| return label; | ||
| } | ||
| }, | ||
| callback: (label) => number(label), | ||
| sampleSize: 5, | ||
| maxRotation: 25, | ||
| minRotation: 25, | ||
| }, | ||
| }, | ||
| ], | ||
| xAxes: [ | ||
| { | ||
| display: true, | ||
| gridLines: { display: false }, | ||
| type: "time", | ||
| offset: true, | ||
| time: { | ||
| parser: "YYYY-MM-DD", | ||
| minUnit: "day", | ||
| }, | ||
| ticks: { | ||
| sampleSize: 5, | ||
| maxRotation: 50, | ||
| minRotation: 50, | ||
| }, | ||
| }, | ||
| ], | ||
| }, | ||
| }, | ||
| }; | ||
| }, | ||
|
|
||
| _resetChart() { | ||
| if (this._chart) { | ||
| this._chart.destroy(); | ||
| this._chart = null; | ||
| } | ||
| }, | ||
| }); |
| @@ -0,0 +1,43 @@ | ||
| import Component from "@ember/component"; | ||
| import I18n from "I18n"; | ||
| import { alias } from "@ember/object/computed"; | ||
| import discourseComputed from "discourse-common/utils/decorators"; | ||
| import { setting } from "discourse/lib/computed"; | ||
|
|
||
| export default Component.extend({ | ||
| classNames: ["admin-report-storage-stats"], | ||
|
|
||
| backupLocation: setting("backup_location"), | ||
| backupStats: alias("model.data.backups"), | ||
| uploadStats: alias("model.data.uploads"), | ||
|
|
||
| @discourseComputed("backupStats") | ||
| showBackupStats(stats) { | ||
| return stats && this.currentUser.admin; | ||
| }, | ||
|
|
||
| @discourseComputed("backupLocation") | ||
| backupLocationName(backupLocation) { | ||
| return I18n.t(`admin.backups.location.${backupLocation}`); | ||
| }, | ||
|
|
||
| @discourseComputed("backupStats.used_bytes") | ||
| usedBackupSpace(bytes) { | ||
| return I18n.toHumanSize(bytes); | ||
| }, | ||
|
|
||
| @discourseComputed("backupStats.free_bytes") | ||
| freeBackupSpace(bytes) { | ||
| return I18n.toHumanSize(bytes); | ||
| }, | ||
|
|
||
| @discourseComputed("uploadStats.used_bytes") | ||
| usedUploadSpace(bytes) { | ||
| return I18n.toHumanSize(bytes); | ||
| }, | ||
|
|
||
| @discourseComputed("uploadStats.free_bytes") | ||
| freeUploadSpace(bytes) { | ||
| return I18n.toHumanSize(bytes); | ||
| }, | ||
| }); |
| @@ -0,0 +1,20 @@ | ||
| import Component from "@ember/component"; | ||
| import { alias } from "@ember/object/computed"; | ||
| import discourseComputed from "discourse-common/utils/decorators"; | ||
|
|
||
| export default Component.extend({ | ||
| tagName: "td", | ||
| classNames: ["admin-report-table-cell"], | ||
| classNameBindings: ["type", "property"], | ||
| options: null, | ||
|
|
||
| @discourseComputed("label", "data", "options") | ||
| computedLabel(label, data, options) { | ||
| return label.compute(data, options || {}); | ||
| }, | ||
|
|
||
| type: alias("label.type"), | ||
| property: alias("label.mainProperty"), | ||
| formatedValue: alias("computedLabel.formatedValue"), | ||
| value: alias("computedLabel.value"), | ||
| }); |
| @@ -0,0 +1,19 @@ | ||
| import Component from "@ember/component"; | ||
| import discourseComputed from "discourse-common/utils/decorators"; | ||
|
|
||
| export default Component.extend({ | ||
| tagName: "th", | ||
| classNames: ["admin-report-table-header"], | ||
| classNameBindings: ["label.mainProperty", "label.type", "isCurrentSort"], | ||
| attributeBindings: ["label.title:title"], | ||
|
|
||
| @discourseComputed("currentSortLabel.sortProperty", "label.sortProperty") | ||
| isCurrentSort(currentSortField, labelSortField) { | ||
| return currentSortField === labelSortField; | ||
| }, | ||
|
|
||
| @discourseComputed("currentSortDirection") | ||
| sortIcon(currentSortDirection) { | ||
| return currentSortDirection === 1 ? "caret-up" : "caret-down"; | ||
| }, | ||
| }); |
| @@ -0,0 +1,6 @@ | ||
| import Component from "@ember/component"; | ||
| export default Component.extend({ | ||
| tagName: "tr", | ||
| classNames: ["admin-report-table-row"], | ||
| options: null, | ||
| }); |
| @@ -0,0 +1,174 @@ | ||
| import Component from "@ember/component"; | ||
| import { alias } from "@ember/object/computed"; | ||
| import discourseComputed from "discourse-common/utils/decorators"; | ||
| import { makeArray } from "discourse-common/lib/helpers"; | ||
|
|
||
| const PAGES_LIMIT = 8; | ||
|
|
||
| export default Component.extend({ | ||
| classNameBindings: ["sortable", "twoColumns"], | ||
| classNames: ["admin-report-table"], | ||
| sortable: false, | ||
| sortDirection: 1, | ||
| perPage: alias("options.perPage"), | ||
| page: 0, | ||
|
|
||
| @discourseComputed("model.computedLabels.length") | ||
| twoColumns(labelsLength) { | ||
| return labelsLength === 2; | ||
| }, | ||
|
|
||
| @discourseComputed( | ||
| "totalsForSample", | ||
| "options.total", | ||
| "model.dates_filtering" | ||
| ) | ||
| showTotalForSample(totalsForSample, total, datesFiltering) { | ||
| // check if we have at least one cell which contains a value | ||
| const sum = totalsForSample | ||
| .map((t) => t.value) | ||
| .compact() | ||
| .reduce((s, v) => s + v, 0); | ||
|
|
||
| return sum >= 1 && total && datesFiltering; | ||
| }, | ||
|
|
||
| @discourseComputed("model.total", "options.total", "twoColumns") | ||
| showTotal(reportTotal, total, twoColumns) { | ||
| return reportTotal && total && twoColumns; | ||
| }, | ||
|
|
||
| @discourseComputed( | ||
| "model.{average,data}", | ||
| "totalsForSample.1.value", | ||
| "twoColumns" | ||
| ) | ||
| showAverage(model, sampleTotalValue, hasTwoColumns) { | ||
| return ( | ||
| model.average && | ||
| model.data.length > 0 && | ||
| sampleTotalValue && | ||
| hasTwoColumns | ||
| ); | ||
| }, | ||
|
|
||
| @discourseComputed("totalsForSample.1.value", "model.data.length") | ||
| averageForSample(totals, count) { | ||
| return (totals / count).toFixed(0); | ||
| }, | ||
|
|
||
| @discourseComputed("model.data.length") | ||
| showSortingUI(dataLength) { | ||
| return dataLength >= 5; | ||
| }, | ||
|
|
||
| @discourseComputed("totalsForSampleRow", "model.computedLabels") | ||
| totalsForSample(row, labels) { | ||
| return labels.map((label) => { | ||
| const computedLabel = label.compute(row); | ||
| computedLabel.type = label.type; | ||
| computedLabel.property = label.mainProperty; | ||
| return computedLabel; | ||
| }); | ||
| }, | ||
|
|
||
| @discourseComputed("model.data", "model.computedLabels") | ||
| totalsForSampleRow(rows, labels) { | ||
| if (!rows || !rows.length) { | ||
| return {}; | ||
| } | ||
|
|
||
| let totalsRow = {}; | ||
|
|
||
| labels.forEach((label) => { | ||
| const reducer = (sum, row) => { | ||
| const computedLabel = label.compute(row); | ||
| const value = computedLabel.value; | ||
|
|
||
| if (!["seconds", "number", "percent"].includes(label.type)) { | ||
| return; | ||
| } else { | ||
| return sum + Math.round(value || 0); | ||
| } | ||
| }; | ||
|
|
||
| const total = rows.reduce(reducer, 0); | ||
| totalsRow[label.mainProperty] = | ||
| label.type === "percent" ? Math.round(total / rows.length) : total; | ||
| }); | ||
|
|
||
| return totalsRow; | ||
| }, | ||
|
|
||
| @discourseComputed("sortLabel", "sortDirection", "model.data.[]") | ||
| sortedData(sortLabel, sortDirection, data) { | ||
| data = makeArray(data); | ||
|
|
||
| if (sortLabel) { | ||
| const compare = (label, direction) => { | ||
| return (a, b) => { | ||
| const aValue = label.compute(a, { useSortProperty: true }).value; | ||
| const bValue = label.compute(b, { useSortProperty: true }).value; | ||
| const result = aValue < bValue ? -1 : aValue > bValue ? 1 : 0; | ||
| return result * direction; | ||
| }; | ||
| }; | ||
|
|
||
| return data.sort(compare(sortLabel, sortDirection)); | ||
| } | ||
|
|
||
| return data; | ||
| }, | ||
|
|
||
| @discourseComputed("sortedData.[]", "perPage", "page") | ||
| paginatedData(data, perPage, page) { | ||
| if (perPage < data.length) { | ||
| const start = perPage * page; | ||
| return data.slice(start, start + perPage); | ||
| } | ||
|
|
||
| return data; | ||
| }, | ||
|
|
||
| @discourseComputed("model.data", "perPage", "page") | ||
| pages(data, perPage, page) { | ||
| if (!data || data.length <= perPage) { | ||
| return []; | ||
| } | ||
|
|
||
| const pagesIndexes = []; | ||
| for (let i = 0; i < Math.ceil(data.length / perPage); i++) { | ||
| pagesIndexes.push(i); | ||
| } | ||
|
|
||
| let pages = pagesIndexes.map((v) => { | ||
| return { | ||
| page: v + 1, | ||
| index: v, | ||
| class: v === page ? "is-current" : null, | ||
| }; | ||
| }); | ||
|
|
||
| if (pages.length > PAGES_LIMIT) { | ||
| const before = Math.max(0, page - PAGES_LIMIT / 2); | ||
| const after = Math.max(PAGES_LIMIT, page + PAGES_LIMIT / 2); | ||
| pages = pages.slice(before, after); | ||
| } | ||
|
|
||
| return pages; | ||
| }, | ||
|
|
||
| actions: { | ||
| changePage(page) { | ||
| this.set("page", page); | ||
| }, | ||
|
|
||
| sortByLabel(label) { | ||
| if (this.sortLabel === label) { | ||
| this.set("sortDirection", this.sortDirection === 1 ? -1 : 1); | ||
| } else { | ||
| this.set("sortLabel", label); | ||
| } | ||
| }, | ||
| }, | ||
| }); |
| @@ -0,0 +1,4 @@ | ||
| import Component from "@ember/component"; | ||
| export default Component.extend({ | ||
| tagName: "tr", | ||
| }); |
| @@ -0,0 +1,124 @@ | ||
| import Component from "@ember/component"; | ||
| import I18n from "I18n"; | ||
| import discourseComputed from "discourse-common/utils/decorators"; | ||
| import { fmt } from "discourse/lib/computed"; | ||
| import { isDocumentRTL } from "discourse/lib/text-direction"; | ||
| import { next } from "@ember/runloop"; | ||
|
|
||
| export default Component.extend({ | ||
| @discourseComputed("theme.targets", "onlyOverridden", "showAdvanced") | ||
| visibleTargets(targets, onlyOverridden, showAdvanced) { | ||
| return targets.filter((target) => { | ||
| if (target.advanced && !showAdvanced) { | ||
| return false; | ||
| } | ||
| if (!onlyOverridden) { | ||
| return true; | ||
| } | ||
| return target.edited; | ||
| }); | ||
| }, | ||
|
|
||
| @discourseComputed("currentTargetName", "onlyOverridden", "theme.fields") | ||
| visibleFields(targetName, onlyOverridden, fields) { | ||
| fields = fields[targetName]; | ||
| if (onlyOverridden) { | ||
| fields = fields.filter((field) => field.edited); | ||
| } | ||
| return fields; | ||
| }, | ||
|
|
||
| @discourseComputed("currentTargetName", "fieldName") | ||
| activeSectionMode(targetName, fieldName) { | ||
| if (["settings", "translations"].includes(targetName)) { | ||
| return "yaml"; | ||
| } | ||
| if (["extra_scss"].includes(targetName)) { | ||
| return "scss"; | ||
| } | ||
| if (["color_definitions"].includes(fieldName)) { | ||
| return "scss"; | ||
| } | ||
| return fieldName && fieldName.indexOf("scss") > -1 ? "scss" : "html"; | ||
| }, | ||
|
|
||
| @discourseComputed("currentTargetName", "fieldName") | ||
| placeholder(targetName, fieldName) { | ||
| if (fieldName && fieldName === "color_definitions") { | ||
| const example = | ||
| ":root {\n" + | ||
| " --mytheme-tertiary-or-quaternary: #{dark-light-choose($tertiary, $quaternary)};\n" + | ||
| "}"; | ||
|
|
||
| return I18n.t("admin.customize.theme.color_definitions.placeholder", { | ||
| example: isDocumentRTL() ? `<div dir="ltr">${example}</div>` : example, | ||
| }); | ||
| } | ||
| return ""; | ||
| }, | ||
|
|
||
| @discourseComputed("fieldName", "currentTargetName", "theme") | ||
| activeSection: { | ||
| get(fieldName, target, model) { | ||
| return model.getField(target, fieldName); | ||
| }, | ||
| set(value, fieldName, target, model) { | ||
| model.setField(target, fieldName, value); | ||
| return value; | ||
| }, | ||
| }, | ||
|
|
||
| editorId: fmt("fieldName", "currentTargetName", "%@|%@"), | ||
|
|
||
| @discourseComputed("maximized") | ||
| maximizeIcon(maximized) { | ||
| return maximized ? "discourse-compress" : "discourse-expand"; | ||
| }, | ||
|
|
||
| @discourseComputed("currentTargetName", "theme.targets") | ||
| showAddField(currentTargetName, targets) { | ||
| return targets.find((t) => t.name === currentTargetName).customNames; | ||
| }, | ||
|
|
||
| @discourseComputed( | ||
| "currentTargetName", | ||
| "fieldName", | ||
| "theme.theme_fields.@each.error" | ||
| ) | ||
| error(target, fieldName) { | ||
| return this.theme.getError(target, fieldName); | ||
| }, | ||
|
|
||
| actions: { | ||
| toggleShowAdvanced() { | ||
| this.toggleProperty("showAdvanced"); | ||
| }, | ||
|
|
||
| toggleAddField() { | ||
| this.toggleProperty("addingField"); | ||
| }, | ||
|
|
||
| cancelAddField() { | ||
| this.set("addingField", false); | ||
| }, | ||
|
|
||
| addField(name) { | ||
| if (!name) { | ||
| return; | ||
| } | ||
| name = name.replace(/[^a-zA-Z0-9-_/]/g, ""); | ||
| this.theme.setField(this.currentTargetName, name, ""); | ||
| this.setProperties({ newFieldName: "", addingField: false }); | ||
| this.fieldAdded(this.currentTargetName, name); | ||
| }, | ||
|
|
||
| toggleMaximize: function () { | ||
| this.toggleProperty("maximized"); | ||
| next(() => this.appEvents.trigger("ace:resize")); | ||
| }, | ||
|
|
||
| onlyOverriddenChanged(value) { | ||
| this.onlyOverriddenChanged(value); | ||
| }, | ||
| }, | ||
| }); |
| @@ -0,0 +1,107 @@ | ||
| import discourseComputed, { | ||
| observes, | ||
| on, | ||
| } from "discourse-common/utils/decorators"; | ||
| import { i18n, propertyEqual } from "discourse/lib/computed"; | ||
| import Component from "@ember/component"; | ||
| import I18n from "I18n"; | ||
| import UserField from "admin/models/user-field"; | ||
| import { bufferedProperty } from "discourse/mixins/buffered-content"; | ||
| import { empty } from "@ember/object/computed"; | ||
| import { isEmpty } from "@ember/utils"; | ||
| import { popupAjaxError } from "discourse/lib/ajax-error"; | ||
| import { scheduleOnce } from "@ember/runloop"; | ||
|
|
||
| export default Component.extend(bufferedProperty("userField"), { | ||
| editing: empty("userField.id"), | ||
| classNameBindings: [":user-field"], | ||
|
|
||
| cantMoveUp: propertyEqual("userField", "firstField"), | ||
| cantMoveDown: propertyEqual("userField", "lastField"), | ||
|
|
||
| userFieldsDescription: i18n("admin.user_fields.description"), | ||
|
|
||
| @discourseComputed("buffered.field_type") | ||
| bufferedFieldType(fieldType) { | ||
| return UserField.fieldTypeById(fieldType); | ||
| }, | ||
|
|
||
| @on("didInsertElement") | ||
| @observes("editing") | ||
| _focusOnEdit() { | ||
| if (this.editing) { | ||
| scheduleOnce("afterRender", this, "_focusName"); | ||
| } | ||
| }, | ||
|
|
||
| _focusName() { | ||
| $(".user-field-name").select(); | ||
| }, | ||
|
|
||
| @discourseComputed("userField.field_type") | ||
| fieldName(fieldType) { | ||
| return UserField.fieldTypeById(fieldType).get("name"); | ||
| }, | ||
|
|
||
| @discourseComputed( | ||
| "userField.editable", | ||
| "userField.required", | ||
| "userField.show_on_profile", | ||
| "userField.show_on_user_card" | ||
| ) | ||
| flags(editable, required, showOnProfile, showOnUserCard) { | ||
| const ret = []; | ||
| if (editable) { | ||
| ret.push(I18n.t("admin.user_fields.editable.enabled")); | ||
| } | ||
| if (required) { | ||
| ret.push(I18n.t("admin.user_fields.required.enabled")); | ||
| } | ||
| if (showOnProfile) { | ||
| ret.push(I18n.t("admin.user_fields.show_on_profile.enabled")); | ||
| } | ||
| if (showOnUserCard) { | ||
| ret.push(I18n.t("admin.user_fields.show_on_user_card.enabled")); | ||
| } | ||
|
|
||
| return ret.join(", "); | ||
| }, | ||
|
|
||
| actions: { | ||
| save() { | ||
| const buffered = this.buffered; | ||
| const attrs = buffered.getProperties( | ||
| "name", | ||
| "description", | ||
| "field_type", | ||
| "editable", | ||
| "required", | ||
| "show_on_profile", | ||
| "show_on_user_card", | ||
| "options" | ||
| ); | ||
|
|
||
| this.userField | ||
| .save(attrs) | ||
| .then(() => { | ||
| this.set("editing", false); | ||
| this.commitBuffer(); | ||
| }) | ||
| .catch(popupAjaxError); | ||
| }, | ||
|
|
||
| edit() { | ||
| this.set("editing", true); | ||
| }, | ||
|
|
||
| cancel() { | ||
| const id = this.get("userField.id"); | ||
| if (isEmpty(id)) { | ||
| this.destroyAction(this.userField); | ||
| } else { | ||
| this.rollbackBuffer(); | ||
| this.set("editing", false); | ||
| } | ||
| }, | ||
| }, | ||
| }); |
| @@ -0,0 +1,30 @@ | ||
| import Component from "@ember/component"; | ||
| import I18n from "I18n"; | ||
| import bootbox from "bootbox"; | ||
| import { iconHTML } from "discourse-common/lib/icon-library"; | ||
|
|
||
| export default Component.extend({ | ||
| classNames: ["watched-word"], | ||
| watchedWord: null, | ||
| xIcon: iconHTML("times").htmlSafe(), | ||
|
|
||
| init() { | ||
| this._super(...arguments); | ||
| this.set("watchedWord", this.get("word.word")); | ||
| }, | ||
|
|
||
| click() { | ||
| this.word | ||
| .destroy() | ||
| .then(() => { | ||
| this.action(this.word); | ||
| }) | ||
| .catch((e) => { | ||
| bootbox.alert( | ||
| I18n.t("generic_error_with_reason", { | ||
| error: `http: ${e.status} - ${e.body}`, | ||
| }) | ||
| ); | ||
| }); | ||
| }, | ||
| }); |
| @@ -0,0 +1,47 @@ | ||
| import Component from "@ember/component"; | ||
| import I18n from "I18n"; | ||
| import { alias } from "@ember/object/computed"; | ||
| import discourseComputed from "discourse-common/utils/decorators"; | ||
|
|
||
| export default Component.extend({ | ||
| classNames: ["hook-event"], | ||
| typeName: alias("type.name"), | ||
|
|
||
| @discourseComputed("typeName") | ||
| name(typeName) { | ||
| return I18n.t(`admin.web_hooks.${typeName}_event.name`); | ||
| }, | ||
|
|
||
| @discourseComputed("typeName") | ||
| details(typeName) { | ||
| return I18n.t(`admin.web_hooks.${typeName}_event.details`); | ||
| }, | ||
|
|
||
| @discourseComputed("model.[]", "typeName") | ||
| eventTypeExists(eventTypes, typeName) { | ||
| return eventTypes.any((event) => event.name === typeName); | ||
| }, | ||
|
|
||
| @discourseComputed("eventTypeExists") | ||
| enabled: { | ||
| get(eventTypeExists) { | ||
| return eventTypeExists; | ||
| }, | ||
| set(value, eventTypeExists) { | ||
| const type = this.type; | ||
| const model = this.model; | ||
| // add an association when not exists | ||
| if (value !== eventTypeExists) { | ||
| if (value) { | ||
| model.addObject(type); | ||
| } else { | ||
| model.removeObjects( | ||
| model.filter((eventType) => eventType.name === type.name) | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| return value; | ||
| }, | ||
| }, | ||
| }); |
| @@ -0,0 +1,113 @@ | ||
| import { ensureJSON, plainJSON, prettyJSON } from "discourse/lib/formatter"; | ||
| import Component from "@ember/component"; | ||
| import I18n from "I18n"; | ||
| import { ajax } from "discourse/lib/ajax"; | ||
| import bootbox from "bootbox"; | ||
| import discourseComputed from "discourse-common/utils/decorators"; | ||
| import { popupAjaxError } from "discourse/lib/ajax-error"; | ||
|
|
||
| export default Component.extend({ | ||
| tagName: "li", | ||
| expandDetails: null, | ||
| expandDetailsRequestKey: "request", | ||
| expandDetailsResponseKey: "response", | ||
|
|
||
| @discourseComputed("model.status") | ||
| statusColorClasses(status) { | ||
| if (!status) { | ||
| return ""; | ||
| } | ||
|
|
||
| if (status >= 200 && status <= 299) { | ||
| return "text-successful"; | ||
| } else { | ||
| return "text-danger"; | ||
| } | ||
| }, | ||
|
|
||
| @discourseComputed("model.created_at") | ||
| createdAt(createdAt) { | ||
| return moment(createdAt).format("YYYY-MM-DD HH:mm:ss"); | ||
| }, | ||
|
|
||
| @discourseComputed("model.duration") | ||
| completion(duration) { | ||
| const seconds = Math.floor(duration / 10.0) / 100.0; | ||
| return I18n.t("admin.web_hooks.events.completed_in", { count: seconds }); | ||
| }, | ||
|
|
||
| @discourseComputed("expandDetails") | ||
| expandRequestIcon(expandDetails) { | ||
| return expandDetails === this.expandDetailsRequestKey | ||
| ? "ellipsis-h" | ||
| : "ellipsis-v"; | ||
| }, | ||
|
|
||
| @discourseComputed("expandDetails") | ||
| expandResponseIcon(expandDetails) { | ||
| return expandDetails === this.expandDetailsResponseKey | ||
| ? "ellipsis-h" | ||
| : "ellipsis-v"; | ||
| }, | ||
|
|
||
| actions: { | ||
| redeliver() { | ||
| return bootbox.confirm( | ||
| I18n.t("admin.web_hooks.events.redeliver_confirm"), | ||
| I18n.t("no_value"), | ||
| I18n.t("yes_value"), | ||
| (result) => { | ||
| if (result) { | ||
| ajax( | ||
| `/admin/api/web_hooks/${this.get( | ||
| "model.web_hook_id" | ||
| )}/events/${this.get("model.id")}/redeliver`, | ||
| { type: "POST" } | ||
| ) | ||
| .then((json) => { | ||
| this.set("model", json.web_hook_event); | ||
| }) | ||
| .catch(popupAjaxError); | ||
| } | ||
| } | ||
| ); | ||
| }, | ||
|
|
||
| toggleRequest() { | ||
| const expandDetailsKey = this.expandDetailsRequestKey; | ||
|
|
||
| if (this.expandDetails !== expandDetailsKey) { | ||
| let headers = Object.assign( | ||
| { | ||
| "Request URL": this.get("model.request_url"), | ||
| "Request method": "POST", | ||
| }, | ||
| ensureJSON(this.get("model.headers")) | ||
| ); | ||
| this.setProperties({ | ||
| headers: plainJSON(headers), | ||
| body: prettyJSON(this.get("model.payload")), | ||
| expandDetails: expandDetailsKey, | ||
| bodyLabel: I18n.t("admin.web_hooks.events.payload"), | ||
| }); | ||
| } else { | ||
| this.set("expandDetails", null); | ||
| } | ||
| }, | ||
|
|
||
| toggleResponse() { | ||
| const expandDetailsKey = this.expandDetailsResponseKey; | ||
|
|
||
| if (this.expandDetails !== expandDetailsKey) { | ||
| this.setProperties({ | ||
| headers: plainJSON(this.get("model.response_headers")), | ||
| body: this.get("model.response_body"), | ||
| expandDetails: expandDetailsKey, | ||
| bodyLabel: I18n.t("admin.web_hooks.events.body"), | ||
| }); | ||
| } else { | ||
| this.set("expandDetails", null); | ||
| } | ||
| }, | ||
| }, | ||
| }); |
| @@ -0,0 +1,38 @@ | ||
| import Component from "@ember/component"; | ||
| import I18n from "I18n"; | ||
| import discourseComputed from "discourse-common/utils/decorators"; | ||
| import { iconHTML } from "discourse-common/lib/icon-library"; | ||
|
|
||
| export default Component.extend({ | ||
| classes: ["text-muted", "text-danger", "text-successful", "text-muted"], | ||
| icons: ["far-circle", "times-circle", "circle", "circle"], | ||
| circleIcon: null, | ||
| deliveryStatus: null, | ||
|
|
||
| @discourseComputed("deliveryStatuses", "model.last_delivery_status") | ||
| status(deliveryStatuses, lastDeliveryStatus) { | ||
| return deliveryStatuses.find((s) => s.id === lastDeliveryStatus); | ||
| }, | ||
|
|
||
| @discourseComputed("status.id", "icons") | ||
| icon(statusId, icons) { | ||
| return icons[statusId - 1]; | ||
| }, | ||
|
|
||
| @discourseComputed("status.id", "classes") | ||
| class(statusId, classes) { | ||
| return classes[statusId - 1]; | ||
| }, | ||
|
|
||
| didReceiveAttrs() { | ||
| this._super(...arguments); | ||
| this.set( | ||
| "circleIcon", | ||
| iconHTML(this.icon, { class: this.class }).htmlSafe() | ||
| ); | ||
| this.set( | ||
| "deliveryStatus", | ||
| I18n.t(`admin.web_hooks.delivery_status.${this.get("status.name")}`) | ||
| ); | ||
| }, | ||
| }); |
| @@ -0,0 +1,12 @@ | ||
| import Component from "@ember/component"; | ||
| export default Component.extend({ | ||
| didInsertElement() { | ||
| this._super(...arguments); | ||
| $("body").addClass("admin-interface"); | ||
| }, | ||
|
|
||
| willDestroyElement() { | ||
| this._super(...arguments); | ||
| $("body").removeClass("admin-interface"); | ||
| }, | ||
| }); |
| @@ -0,0 +1,4 @@ | ||
| import Component from "@ember/component"; | ||
| export default Component.extend({ | ||
| tagName: "", | ||
| }); |