Merge v3.3.0.beta5 into stable

This commit is contained in:
Nat 2024-07-30 15:35:40 +08:00
commit 4922ad795d
No known key found for this signature in database
GPG Key ID: 4938B35D927EC773
5071 changed files with 188330 additions and 99586 deletions

View File

@ -52,13 +52,22 @@ updates:
- "railties"
- "sprockets-rails"
- package-ecosystem: "npm"
directory: "/app/assets/javascripts/"
directory: "/"
schedule:
interval: daily
time: "08:00"
timezone: Australia/Sydney
open-pull-requests-limit: 20
versioning-strategy: increase
ignore: # These are all vendored so need to be updated manually. See lib/tasks/javascript.rake
- dependency-name: "chart.js"
- dependency-name: "chartjs-plugin-datalabels"
- dependency-name: "magnific-popup"
- dependency-name: "pikaday"
- dependency-name: "moment"
- dependency-name: "moment-timezone"
- dependency-name: "@discourse/moment-timezon-names-translations"
- dependency-name: "squoosh"
groups:
babel:
patterns:
@ -69,3 +78,11 @@ updates:
types:
patterns:
- "@types/*"
# - package-ecosystem: "bundler"
# directory: "migrations/config/gemfiles/convert"
# schedule:
# interval: "weekly"
# day: "wednesday"
# time: "10:00"
# timezone: "Europe/Vienna"
# versioning-strategy: "increase"

View File

@ -1,69 +0,0 @@
# This workflow is designed to stop us accidently committing the lockfile/packagejson symlink changes
# which would cause the default Ember version to change.
name: Ember Version Enforcement
on:
pull_request:
push:
branches:
- main
permissions:
contents: write
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: yarn
- name: Yarn install
run: |
cd app/assets/javascripts/discourse
yarn install
- name: "Check default ember version"
working-directory: app/assets/javascripts/discourse
run: |
node -e '
const version = require("ember-source/package.json").version;
console.log("Ember version is", version);
if(version.split(".")[0] !== "5"){
console.log(`Ember has unexpectedly been downgraded to version ${version}. If this is intentional, remove this github workflow.`);
process.exit(1);
}
'
- name: Downgrade ember
run: |
script/switch_ember_version 3
cd app/assets/javascripts/discourse
yarn install
- name: "Check upgraded version"
working-directory: app/assets/javascripts/discourse
run: |
node -e '
const version = require("ember-source/package.json").version;
console.log("Ember version is", version);
if(version.split(".")[0] !== "3"){
console.log(`Expected Ember 3, but found ${version}`);
process.exit(1);
}
'
- name: "Revert ember downgrade"
run: |
script/switch_ember_version 5
- name: "Ensure no diff"
run: |
if [ ! -z "$(git status --porcelain)" ]; then
echo "Working directory was not clean after upgrading/downgrading ember. Perhaps a lockfile is out-of-date. Run this command to re-sync:"
echo " script/regen_ember_3_lockfile"
echo
echo "Current diff:"
echo "---------------------------------------------"
git -c color.ui=always diff
exit 1
fi

View File

@ -1,38 +0,0 @@
# This workflow will run on dependabot pull requests to ensure that they update both the ember3 and ember5 lockfiles
name: Ember Version Lockfiles
on:
- pull_request
permissions:
contents: write
jobs:
help_dependabot:
runs-on: ubuntu-latest
if: ${{ github.actor == 'dependabot[bot]' }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.head_ref }}
- name: Move lockfile to correct location
run: |
# Dependabot gets confused by the symlinks and dumps the updated lockfile in the root of the repo.
# Let's move it back to the correct location.
if [[ -f yarn-ember5.lock ]]; then
mv yarn-ember5.lock app/assets/javascripts/yarn-ember5.lock
fi
- name: Update ember3 lockfile
run: script/regen_ember_3_lockfile
- name: Push changes
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if [ ! -z "$(git status --porcelain)" ]; then
git config --global user.email "build@discourse.org"
git config --global user.name "discoursebuild"
git add .
git commit -m "Update lockfiles for ember version flag"
git push
fi

View File

@ -77,9 +77,3 @@ jobs:
yarn global add licensee
yarn global upgrade licensee
licensee --errors-only
- name: Check Ember CLI Workspace Licenses
if: ${{ !cancelled() }}
working-directory: ./app/assets/javascripts
run: |
licensee --errors-only

View File

@ -3,12 +3,14 @@ name: Migration Tests
on:
pull_request:
paths:
- ".github/workflows/migration-tests.yml"
- "migrations/**"
push:
branches:
- main
- stable
paths:
- ".github/workflows/migration-tests.yml"
- "migrations/**"
concurrency:
@ -20,7 +22,8 @@ permissions:
jobs:
tests:
name: Ruby ${{ matrix.ruby }}
if: github.event_name == 'pull_request' || github.repository != 'discourse/discourse-private-mirror'
name: Tests with Ruby ${{ matrix.ruby }}
runs-on: 'ubuntu-latest'
container: discourse/discourse_test:slim
timeout-minutes: 20
@ -59,12 +62,23 @@ jobs:
sudo -E -u postgres script/start_test_db.rb
sudo -u postgres psql -c "CREATE ROLE $PGUSER LOGIN SUPERUSER PASSWORD '$PGPASSWORD';"
- name: Container envs
id: container-envs
run: |
echo "ruby_version=$RUBY_VERSION" >> $GITHUB_OUTPUT
echo "debian_release=$DEBIAN_RELEASE" >> $GITHUB_OUTPUT
shell: bash
- name: Bundler cache
uses: actions/cache@v4
with:
path: vendor/bundle
key: ${{ runner.os }}-${{ matrix.ruby }}-gem-${{ hashFiles('**/Gemfile.lock') }}
restore-keys: ${{ runner.os }}-${{ matrix.ruby }}-gem-
key: >-
${{ runner.os }}-
${{ steps.container-envs.outputs.ruby_version }}-
${{ steps.container-envs.outputs.debian_release }}-
${{ hashFiles('**/Gemfile.lock') }}-
${{ hashFiles('migrations/config/gemfiles/**/Gemfile') }}
- name: Setup gems
run: |
@ -72,8 +86,8 @@ jobs:
bundle config --local path vendor/bundle
bundle config --local deployment true
bundle config --local without development
bundle install --jobs 4
bundle clean
bundle install --jobs $(($(nproc) - 1))
# don't call `bundle clean` clean, we need the gems for the migrations
- name: Get yarn cache directory
id: yarn-cache-dir
@ -85,7 +99,6 @@ jobs:
with:
path: ${{ steps.yarn-cache-dir.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: ${{ runner.os }}-yarn-
- name: Yarn install
run: yarn install --frozen-lockfile
@ -99,10 +112,11 @@ jobs:
${{ runner.os }}-
${{ hashFiles('.github/workflows/tests.yml') }}-
${{ hashFiles('db/**/*', 'plugins/**/db/**/*') }}-
${{ hashFiles('config/environments/test.rb') }}
- name: Restore database from cache
if: steps.app-cache.outputs.cache-hit == 'true'
run: psql --quiet -o /dev/null -f tmp/app-cache/cache.sql postgres
run: script/silence_successful_output psql --quiet -o /dev/null -f tmp/app-cache/cache.sql postgres
- name: Restore uploads from cache
if: steps.app-cache.outputs.cache-hit == 'true'
@ -112,7 +126,7 @@ jobs:
if: steps.app-cache.outputs.cache-hit != 'true'
run: |
bin/rake db:create
bin/rake db:migrate
script/silence_successful_output bin/rake db:migrate
- name: Dump database for cache
if: steps.app-cache.outputs.cache-hit != 'true'
@ -122,11 +136,44 @@ jobs:
if: steps.app-cache.outputs.cache-hit != 'true'
run: rm -rf tmp/app-cache/uploads && cp -r public/uploads tmp/app-cache/uploads
- name: Check core database drift
run: |
mkdir /tmp/intermediate_db
./migrations/scripts/schema_generator /tmp/intermediate_db/base_migration.sql
diff -u migrations/common/intermediate_db_schema/000_base_schema.sql /tmp/intermediate_db/base_migration.sql
# - name: Check core database drift
# run: |
# mkdir /tmp/intermediate_db
# ./migrations/scripts/schema_generator /tmp/intermediate_db/base_migration.sql
# diff -u migrations/common/intermediate_db_schema/000_base_schema.sql /tmp/intermediate_db/base_migration.sql
- name: RSpec
run: bin/rspec migrations/spec/
run: bin/rspec --default-path migrations/spec
runtime:
if: github.event_name == 'pull_request' || github.repository != 'discourse/discourse-private-mirror'
name: Runs on ${{ matrix.os }}, Ruby ${{ matrix.ruby }}
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
os: ["ubuntu-latest", "macos-latest"]
ruby: ["3.2", "3.3"]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Modify path for libpq
if: matrix.os == 'macos-latest'
run: echo "/opt/homebrew/opt/libpq/bin" >> $GITHUB_PATH
- name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: ${{ matrix.ruby }}
bundler-cache: true
- name: Run converter
working-directory: migrations
run: bin/convert version

103
.github/workflows/release-notes.yml vendored Normal file
View File

@ -0,0 +1,103 @@
name: Release Notes
on:
workflow_dispatch:
inputs:
from:
description: 'Starting ref (exclusive). Can be a tag, branch or commit ref. `latest-release` refers to the last beta version bump.'
required: true
default: 'latest-release'
type: string
to:
description: 'Ending ref (inclusive). Can be a tag, branch or commit ref. `HEAD` refers to the most recent commit.'
required: true
default: 'HEAD'
type: string
permissions:
contents: read
jobs:
build:
name: run
runs-on: ubuntu-latest
container: discourse/discourse_test:slim
timeout-minutes: 10
env:
from_ref: ${{ inputs.from || 'latest-release' }}
to_ref: ${{ inputs.to || 'HEAD' }}
steps:
- name: Set working directory owner
run: chown root:root .
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Bundler cache
uses: actions/cache@v4
with:
path: vendor/bundle
key: ${{ runner.os }}-gem-${{ hashFiles('**/Gemfile.lock') }}
restore-keys: ${{ runner.os }}-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: Create output dir
run: mkdir -p tmp/notes
- name: Core release notes
run: bin/rake "release_note:generate[ $from_ref , $to_ref ]" | tee tmp/notes/core.txt
- name: Calculate from/to dates from refs
id: dates
run: |
from=$(git show -s --date=format:'%Y-%m-%d' --format=%cd "${from_ref}^{commit}")
echo "from=$from" >> $GITHUB_OUTPUT
to=$(git show -s --date=format:'%Y-%m-%d' --format=%cd "${to_ref}^{commit}")
echo "to=$to" >> $GITHUB_OUTPUT
- name: Setup all-the-plugins
run: |
git clone https://github.com/discourse/all-the-plugins tmp/all-the-plugins
cd tmp/all-the-plugins
./reset-all-repos
- name: Plugin release notes
run: |
bin/rake "release_note:plugins:generate[ ${{ steps.dates.outputs.from }} , ${{ steps.dates.outputs.to }} , ./tmp/all-the-plugins/official/* , discourse ]" | tee tmp/notes/plugins.txt
- name: Export files
uses: actions/upload-artifact@v4
with:
name: release-notes
path: ./tmp/notes/*.txt
- name: Write summary
run: |
echo "### Release notes" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "From: $from_ref - $(git rev-parse --short $from_ref) - ${{ steps.dates.outputs.from }}" >> $GITHUB_STEP_SUMMARY
echo "To: $to_ref - $(git rev-parse --short $to_ref) - ${{ steps.dates.outputs.to }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "---" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Core:" >> $GITHUB_STEP_SUMMARY
echo '~~~' >> $GITHUB_STEP_SUMMARY
cat tmp/notes/core.txt >> $GITHUB_STEP_SUMMARY
echo '~~~' >> $GITHUB_STEP_SUMMARY
echo ""
echo "Plugins:" >> $GITHUB_STEP_SUMMARY
echo '~~~' >> $GITHUB_STEP_SUMMARY
cat tmp/notes/plugins.txt >> $GITHUB_STEP_SUMMARY
echo '~~~' >> $GITHUB_STEP_SUMMARY

View File

@ -3,6 +3,9 @@ name: Tests
on:
pull_request:
paths-ignore:
- ".github/workflows/migration-tests.yml"
- ".github/dependabot.yml"
- ".github/labeler.yml"
- "migrations/**"
push:
branches:
@ -10,6 +13,9 @@ on:
- beta
- stable
paths-ignore:
- ".github/workflows/migration-tests.yml"
- ".github/dependabot.yml"
- ".github/labeler.yml"
- "migrations/**"
concurrency:
@ -22,9 +28,9 @@ permissions:
jobs:
build:
if: github.event_name == 'pull_request' || github.repository != 'discourse/discourse-private-mirror'
name: ${{ matrix.target }} ${{ matrix.build_type }}${{ !matrix.updated_ember && ' (Ember 3)' || '' }} # Update fetch-job-id step if changing this
name: ${{ matrix.target }} ${{ matrix.build_type }} # Update fetch-job-id step if changing this
runs-on: ${{ (matrix.build_type == 'annotations') && 'ubuntu-latest' || 'ubuntu-20.04-8core' }}
container: discourse/discourse_test:slim${{ (matrix.build_type == 'frontend' || matrix.build_type == 'system') && '-browsers' || '' }}${{ (matrix.ruby == '3.1') && '-ruby-3.1.0' || '' }}
container: discourse/discourse_test:slim${{ (matrix.build_type == 'frontend' || matrix.build_type == 'system') && '-browsers' || '' }}
timeout-minutes: 20
env:
@ -35,8 +41,8 @@ jobs:
USES_PARALLEL_DATABASES: ${{ matrix.build_type == 'backend' || matrix.build_type == 'system' }}
CAPYBARA_DEFAULT_MAX_WAIT_TIME: 10
MINIO_RUNNER_LOG_LEVEL: DEBUG
EMBER_VERSION: ${{ (matrix.updated_ember && '5') || '3' }}
DISCOURSE_TURBO_RSPEC_RETRY_AND_LOG_FLAKY_TESTS: ${{ (matrix.build_type == 'system' || matrix.build_type == 'backend') && github.ref_name == 'main' }}
DISCOURSE_TURBO_RSPEC_RETRY_AND_LOG_FLAKY_TESTS: ${{ (matrix.build_type == 'system' || matrix.build_type == 'backend') && github.ref == 'refs/main/head' }}
CHEAP_SOURCE_MAPS: "1"
strategy:
fail-fast: false
@ -44,8 +50,6 @@ jobs:
matrix:
build_type: [backend, frontend, system, annotations]
target: [core, plugins, themes]
ruby: ["3.2"]
updated_ember: [true]
exclude:
- build_type: annotations
target: plugins
@ -58,15 +62,15 @@ jobs:
include:
- build_type: system
target: chat
updated_ember: true
steps:
- name: Set working directory owner
run: chown root:root .
- name: Remove Chrome
- name: Remove Chromium
continue-on-error: true
run: apt remove -y google-chrome-stable
if: matrix.build_type == 'system'
run: apt remove -y chromium-driver chromium
- uses: actions/checkout@v4
with:
@ -87,11 +91,18 @@ jobs:
sudo -E -u postgres script/start_test_db.rb
sudo -u postgres psql -c "CREATE ROLE $PGUSER LOGIN SUPERUSER PASSWORD '$PGPASSWORD';"
- name: Container envs
id: container-envs
run: |
echo "ruby_version=$RUBY_VERSION" >> $GITHUB_OUTPUT
echo "debian_release=$DEBIAN_RELEASE" >> $GITHUB_OUTPUT
shell: bash
- name: Bundler cache
uses: actions/cache@v4
with:
path: vendor/bundle
key: ${{ runner.os }}-${{ matrix.ruby }}-gem-${{ hashFiles('**/Gemfile.lock') }}-cachev2
key: ${{ runner.os }}-${{ steps.container-envs.outputs.ruby_version }}-${{ steps.container-envs.outputs.debian_release }}-gem-${{ hashFiles('**/Gemfile.lock') }}-cachev2
- name: Setup gems
run: |
@ -99,7 +110,7 @@ jobs:
bundle config --local path vendor/bundle
bundle config --local deployment true
bundle config --local without development
bundle install --jobs 4
bundle install --jobs $(($(nproc) - 1))
bundle clean
- name: Get yarn cache directory
@ -133,7 +144,7 @@ jobs:
uses: actions/cache@v4
with:
path: plugins/*/gems
key: ${{ runner.os }}-plugin-gems-${{matrix.ruby}}-${{ hashFiles('plugins/*/plugin.rb') }}
key: ${{ runner.os }}-plugin-gems-${{ steps.container-envs.outputs.ruby_version }}-${{ steps.container-envs.outputs.debian_release }}-${{ hashFiles('plugins/*/plugin.rb') }}
- name: Checkout official themes
if: matrix.target == 'themes'
@ -226,8 +237,8 @@ jobs:
- name: Theme QUnit
if: matrix.build_type == 'frontend' && matrix.target == 'themes'
run: DISCOURSE_DEV_DB=discourse_test QUNIT_PARALLEL=3 bin/rake themes:qunit_all_official
timeout-minutes: 15
run: DISCOURSE_DEV_DB=discourse_test bin/rake themes:qunit_all_official
timeout-minutes: 10
- uses: actions/upload-artifact@v4
if: always() && matrix.build_type == 'frontend' && matrix.target == 'plugins'
@ -245,23 +256,31 @@ jobs:
- name: Core System Tests
if: matrix.build_type == 'system' && matrix.target == 'core'
env:
CHECKOUT_TIMEOUT: 10
run: RAILS_ENABLE_TEST_LOG=1 RAILS_TEST_LOG_LEVEL=error PARALLEL_TEST_PROCESSORS=4 bin/turbo_rspec --use-runtime-info --profile=50 --verbose --format documentation spec/system
- name: Plugin System Tests
if: matrix.build_type == 'system' && matrix.target == 'plugins'
env:
CHECKOUT_TIMEOUT: 10
run: |
GLOBIGNORE="plugins/chat/*";
PREFABRICATION=0 LOAD_PLUGINS=1 RAILS_ENABLE_TEST_LOG=1 RAILS_TEST_LOG_LEVEL=error PARALLEL_TEST_PROCESSORS=4 bin/turbo_rspec --use-runtime-info --profile=50 --verbose --format documentation plugins/*/spec/system
LOAD_PLUGINS=1 RAILS_ENABLE_TEST_LOG=1 RAILS_TEST_LOG_LEVEL=error PARALLEL_TEST_PROCESSORS=4 bin/turbo_rspec --use-runtime-info --profile=50 --verbose --format documentation plugins/*/spec/system
shell: bash
timeout-minutes: 30
- name: Chat System Tests
if: matrix.build_type == 'system' && matrix.target == 'chat'
env:
CHECKOUT_TIMEOUT: 10
run: LOAD_PLUGINS=1 RAILS_ENABLE_TEST_LOG=1 RAILS_TEST_LOG_LEVEL=error PARALLEL_TEST_PROCESSORS=4 bin/turbo_rspec --use-runtime-info --profile=50 --verbose --format documentation plugins/chat/spec/system
timeout-minutes: 30
- name: Theme System Tests
if: matrix.build_type == 'system' && matrix.target == 'themes'
env:
CHECKOUT_TIMEOUT: 10
run: |
RAILS_ENABLE_TEST_LOG=1 RAILS_TEST_LOG_LEVEL=error PARALLEL_TEST_PROCESSORS=4 bin/turbo_rspec --profile=50 --verbose --format documentation tmp/themes/*/spec/system
shell: bash
@ -300,7 +319,7 @@ jobs:
id: fetch-job-id
if: always() && steps.check-flaky-spec-report.outputs.exists == 'true'
run: |
job_id=$(ruby script/get_github_workflow_run_job_id.rb ${{ github.run_id }} ${{ github.run_attempt }} '${{ matrix.target }} ${{ matrix.build_type }}${{ !matrix.updated_ember && ' (Ember 3)' || '' }}')
job_id=$(ruby script/get_github_workflow_run_job_id.rb ${{ github.run_id }} ${{ github.run_attempt }} '${{ matrix.target }} ${{ matrix.build_type }}')
echo "job_id=$job_id" >> $GITHUB_OUTPUT
- name: Create flaky tests report artifact
@ -333,7 +352,7 @@ jobs:
core_frontend_tests:
if: github.event_name == 'pull_request' || github.repository != 'discourse/discourse-private-mirror'
name: core frontend (${{ matrix.browser }})${{ !matrix.updated_ember && ' (Ember 3)' || '' }}
name: core frontend (${{ matrix.browser }})
runs-on: ubuntu-20.04-8core
container:
image: discourse/discourse_test:slim-browsers
@ -345,14 +364,11 @@ jobs:
fail-fast: false
matrix:
browser: ["Chromium", "Firefox ESR", "Firefox Evergreen"]
updated_ember: [true]
include:
- browser: Chromium
updated_ember: false
env:
TESTEM_BROWSER: ${{ (startsWith(matrix.browser, 'Firefox') && 'Firefox') || matrix.browser }}
TESTEM_FIREFOX_PATH: ${{ (matrix.browser == 'Firefox Evergreen') && '/opt/firefox-evergreen/firefox' }}
CHEAP_SOURCE_MAPS: "1"
steps:
- uses: actions/checkout@v4
@ -375,12 +391,7 @@ jobs:
path: ${{ steps.yarn-cache-dir.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}-cachev2
- name: Downgrade Ember
if: matrix.updated_ember == false
run: script/switch_ember_version 3
- name: Yarn install
working-directory: ./app/assets/javascripts/discourse
run: yarn install --frozen-lockfile
- name: Ember Build

20
.gitignore vendored
View File

@ -35,18 +35,20 @@
# Plugins except for the bundled ones
/plugins/*
!/plugins/discourse-details/
!/plugins/discourse-details
!/plugins/discourse-local-dates
!/plugins/discourse-narrative-bot
!/plugins/discourse-presence
!/plugins/discourse-lazy-videos/
!/plugins/chat/
!/plugins/poll/
!/plugins/discourse-lazy-videos
!/plugins/automation
/plugins/automation/gems
!/plugins/chat
!/plugins/poll
!/plugins/styleguide
!/plugins/spoiler-alert/
!/plugins/checklist/
!/plugins/footnote/
/plugins/*/auto_generated/
!/plugins/spoiler-alert
!/plugins/checklist
!/plugins/footnote
/plugins/*/auto_generated
/spec/fixtures/plugins/my_plugin/auto_generated
@ -69,7 +71,7 @@ yarn-error.log
# Linting artifacts
.eslintcache
/lint-progress/
/lint-progress
# Auto-generated plugin JS assets
/app/assets/javascripts/plugins/*

View File

@ -20,17 +20,22 @@ ignored:
- date # Ruby (default gem)
- digest # Ruby (default gem)
- io-wait # Ruby (default gem)
- irb # Ruby (default gem)
- json # Ruby (default gem)
- logger # Ruby (default gem)
- mutex_m # BSD-2-Clause
- net-http # Ruby (default gem)
- net-protocol # Ruby (default gem)
- openssl # Ruby (default gem)
- racc # Ruby (default gem)
- rchardet # LGPL
- rdoc # Ruby (default gem)
- reline # Ruby (default gem)
- ruby2_keywords # Ruby (default gem)
- stringio # Ruby (default gem)
- strscan # Ruby (default gem)
- timeout # Ruby (default gem)
- uri # Ruby (default gem)
- mutex_m # BSD-2-Clause
reviewed:
bundler:
@ -38,6 +43,7 @@ reviewed:
- coderay # MIT
- concurrent-ruby # MIT
- css_parser # MIT
- drb # BSD-2-Clause
- excon # MIT
- faraday-em_http # MIT
- faraday-em_synchrony # MIT
@ -65,4 +71,3 @@ reviewed:
- tilt # MIT
- unf # BSD-2-Clause
- unicorn # Ruby or GPLv2/GPLv3
- drb # BSD-2-Clause

View File

@ -5,17 +5,47 @@
"CC0-1.0",
"CC-BY-3.0",
"CC-BY-4.0",
"Apache-2.0 WITH LLVM-exception"
"Apache-2.0 WITH LLVM-exception",
"ISC"
]
},
"packages": {
"@fortawesome/fontawesome-free": "*",
"@glimmer/compiler": "*",
"@glimmer/debug": "*",
"@glimmer/encoder": "*",
"@glimmer/global-context": "*",
"@glimmer/interfaces": "*",
"@glimmer/manager": "*",
"@glimmer/node": "*",
"@glimmer/opcode-compiler": "*",
"@glimmer/program": "*",
"@glimmer/syntax": "*",
"@glimmer/vm": "*",
"@jspreadsheet/formula": "2.0.2",
"cli-table": "0.3.11",
"component-bind": "1.0.0",
"component-inherit": "0.0.3",
"duplex": "1.0.0",
"ember-template-lint-plugin-discourse": "*",
"glob": "3.1.21",
"indexof": "0.0.1",
"inherits": "1.0.2",
"jsonify": "0.0.1",
"jspreadsheet-ce": "4.13.4",
"line-stream": "0.0.0",
"messageformat": "0.1.5",
"regenerator-transform": "0.10.1",
"source-map": "0.1.43",
"sourcemap-validator": "1.1.1",
"spawn-command": "0.0.2",
"squoosh": "2.0.0",
"taffydb": "2.6.2"
},
"corrections": true
"corrections": true,
"ignore": [
{
"author": "Discourse"
}
]
}

View File

@ -8,6 +8,30 @@ Discourse/NoAddReferenceOrAliasesActiveRecordMigration:
Discourse/NoResetColumnInformationInMigrations:
Enabled: true
Discourse/Plugins/CallRequiresPlugin:
Include:
- "plugins/*/app/controllers/**/*"
Discourse/Plugins/UsePluginInstanceOn:
Include:
- "plugins/**/*"
Discourse/Plugins/NamespaceMethods:
Include:
- "plugins/**/*"
Discourse/Plugins/NamespaceConstants:
Include:
- "plugins/**/*"
Discourse/Plugins/UseRequireRelative:
Include:
- "plugins/**/*"
Discourse/Plugins/NoMonkeyPatching:
Include:
- "plugins/**/*"
Lint/Debugger:
Exclude:
- script/**/*

28
Gemfile
View File

@ -6,20 +6,20 @@ source "https://rubygems.org"
gem "bootsnap", require: false, platform: :mri
gem "actionmailer", "< 7.1"
gem "actionpack", "< 7.1"
gem "actionview", "< 7.1"
gem "activemodel", "< 7.1"
gem "activerecord", "< 7.1"
gem "activesupport", "< 7.1"
gem "railties", "< 7.1"
gem "actionmailer", "~> 7.1.0"
gem "actionpack", "~> 7.1.0"
gem "actionview", "~> 7.1.0"
gem "activemodel", "~> 7.1.0"
gem "activerecord", "~> 7.1.0"
gem "activesupport", "~> 7.1.0"
gem "railties", "~> 7.1.0"
gem "sprockets-rails"
gem "json"
# TODO: At the moment Discourse does not work with Sprockets 4, we would need to correct internals
# We intend to drop sprockets rather than upgrade to 4.x
gem "sprockets", git: "https://github.com/rails/sprockets", branch: "3.x"
gem "sprockets", "~> 3.7.3"
# this will eventually be added to rails,
# allows us to precompile all our templates in the unicorn master
@ -31,7 +31,9 @@ gem "mail"
gem "mini_mime"
gem "mini_suffix"
gem "redis"
# config/initializers/006-mini_profiler.rb depends upon the RedisClient#call.
# Rework this when upgrading to redis client 5.0 and above.
gem "redis", "< 5.0"
# This is explicitly used by Sidekiq and is an optional dependency.
# We tell Sidekiq to use the namespace "sidekiq" which triggers this
@ -87,6 +89,7 @@ gem "mini_sql"
gem "pry-rails", require: false
gem "pry-byebug", require: false
gem "rtlcss", require: false
gem "messageformat-wrapper", require: false
gem "rake"
gem "thor", require: false
@ -123,7 +126,6 @@ group :test do
gem "capybara", require: false
gem "webmock", require: false
gem "fakeweb", require: false
gem "minitest", require: false
gem "simplecov", require: false
gem "selenium-webdriver", "~> 4.14", require: false
gem "selenium-devtools", require: false
@ -145,6 +147,7 @@ group :test, :development do
gem "shoulda-matchers", require: false
gem "rspec-html-matchers"
gem "pry-stack_explorer", require: false
gem "byebug", require: ENV["RM_INFO"].nil?, platform: :mri
gem "rubocop-discourse", require: false
gem "parallel_tests"
@ -210,7 +213,6 @@ gem "cppjieba_rb", require: false
gem "lograge", require: false
gem "logstash-event", require: false
gem "logstash-logger", require: false
gem "logster"
# A fork of sassc with dart-sass support
@ -270,3 +272,7 @@ gem "csv", require: false
# TODO: Can be removed once we upgrade to Rails 7.1
gem "mutex_m"
gem "drb"
# dependencies for the automation plugin
gem "iso8601"
gem "rrule"

View File

@ -1,92 +1,92 @@
GIT
remote: https://github.com/rails/sprockets
revision: f4d3dae71ef29c44b75a49cfbf8032cce07b423a
branch: 3.x
specs:
sprockets (3.7.2)
concurrent-ruby (~> 1.0)
rack (> 1, < 3)
GEM
remote: https://rubygems.org/
specs:
actionmailer (7.0.8)
actionpack (= 7.0.8)
actionview (= 7.0.8)
activejob (= 7.0.8)
activesupport (= 7.0.8)
actionmailer (7.1.3.4)
actionpack (= 7.1.3.4)
actionview (= 7.1.3.4)
activejob (= 7.1.3.4)
activesupport (= 7.1.3.4)
mail (~> 2.5, >= 2.5.4)
net-imap
net-pop
net-smtp
rails-dom-testing (~> 2.0)
actionpack (7.0.8)
actionview (= 7.0.8)
activesupport (= 7.0.8)
rack (~> 2.0, >= 2.2.4)
rails-dom-testing (~> 2.2)
actionpack (7.1.3.4)
actionview (= 7.1.3.4)
activesupport (= 7.1.3.4)
nokogiri (>= 1.8.5)
racc
rack (>= 2.2.4)
rack-session (>= 1.0.1)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.2.0)
actionview (7.0.8)
activesupport (= 7.0.8)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
actionview (7.1.3.4)
activesupport (= 7.1.3.4)
builder (~> 3.1)
erubi (~> 1.4)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.1, >= 1.2.0)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
actionview_precompiler (0.4.0)
actionview (>= 6.0.a)
active_model_serializers (0.8.4)
activemodel (>= 3.0)
activejob (7.0.8)
activesupport (= 7.0.8)
activejob (7.1.3.4)
activesupport (= 7.1.3.4)
globalid (>= 0.3.6)
activemodel (7.0.8)
activesupport (= 7.0.8)
activerecord (7.0.8)
activemodel (= 7.0.8)
activesupport (= 7.0.8)
activesupport (7.0.8)
activemodel (7.1.3.4)
activesupport (= 7.1.3.4)
activerecord (7.1.3.4)
activemodel (= 7.1.3.4)
activesupport (= 7.1.3.4)
timeout (>= 0.4.0)
activesupport (7.1.3.4)
base64
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
connection_pool (>= 2.2.5)
drb
i18n (>= 1.6, < 2)
minitest (>= 5.1)
mutex_m
tzinfo (~> 2.0)
addressable (2.8.6)
public_suffix (>= 2.0.2, < 6.0)
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
annotate (3.2.0)
activerecord (>= 3.2, < 8.0)
rake (>= 10.4, < 14.0)
ast (2.4.2)
aws-eventstream (1.3.0)
aws-partitions (1.583.0)
aws-sdk-core (3.130.2)
aws-eventstream (~> 1, >= 1.0.2)
aws-partitions (~> 1, >= 1.525.0)
aws-partitions (1.894.0)
aws-sdk-core (3.191.3)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.651.0)
aws-sigv4 (~> 1.8)
jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.77.0)
aws-sdk-core (~> 3, >= 3.191.0)
aws-sigv4 (~> 1.1)
jmespath (~> 1.0)
aws-sdk-kms (1.56.0)
aws-sdk-core (~> 3, >= 3.127.0)
aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.114.0)
aws-sdk-core (~> 3, >= 3.127.0)
aws-sdk-s3 (1.143.0)
aws-sdk-core (~> 3, >= 3.191.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.4)
aws-sdk-sns (1.53.0)
aws-sdk-core (~> 3, >= 3.127.0)
aws-sigv4 (~> 1.8)
aws-sdk-sns (1.72.0)
aws-sdk-core (~> 3, >= 3.191.0)
aws-sigv4 (~> 1.1)
aws-sigv4 (1.5.0)
aws-sigv4 (1.8.0)
aws-eventstream (~> 1, >= 1.0.2)
base64 (0.2.0)
better_errors (2.10.1)
erubi (>= 1.0.0)
rack (>= 0.9.0)
rouge (>= 1.0.0)
bigdecimal (3.1.6)
binding_of_caller (1.0.0)
debug_inspector (>= 0.0.1)
bootsnap (1.17.1)
bigdecimal (3.1.8)
binding_of_caller (1.0.1)
debug_inspector (>= 1.2.0)
bootsnap (1.18.3)
msgpack (~> 1.2)
builder (3.2.4)
bullet (7.1.6)
builder (3.3.0)
bullet (7.2.0)
activesupport (>= 3.0.0)
uniform_notifier (~> 1.11)
byebug (11.1.3)
@ -105,22 +105,22 @@ GEM
chunky_png (1.4.0)
coderay (1.1.3)
colored2 (4.0.0)
concurrent-ruby (1.2.3)
concurrent-ruby (1.3.3)
connection_pool (2.4.1)
cose (1.3.0)
cbor (~> 0.5.9)
openssl-signature_algorithm (~> 1.0)
cppjieba_rb (0.4.2)
crack (0.4.6)
crack (1.0.0)
bigdecimal
rexml
crass (1.0.6)
css_parser (1.16.0)
css_parser (1.17.1)
addressable
csv (3.3.0)
date (3.3.4)
debug_inspector (1.2.0)
diff-lcs (1.5.0)
diff-lcs (1.5.1)
diffy (3.4.2)
digest (3.1.1)
discourse-fonts (0.0.9)
@ -130,41 +130,54 @@ GEM
discourse_dev_assets (0.0.4)
faker (~> 2.16)
literate_randomizer
docile (1.4.0)
docile (1.4.1)
drb (2.2.1)
email_reply_trimmer (0.1.13)
erubi (1.12.0)
excon (0.109.0)
erubi (1.13.0)
excon (0.111.0)
execjs (2.9.1)
exifr (1.4.0)
fabrication (2.31.0)
faker (2.23.0)
i18n (>= 1.8.11, < 2)
fakeweb (1.3.0)
faraday (2.9.0)
faraday (2.10.0)
faraday-net_http (>= 2.0, < 3.2)
faraday-net_http (3.1.0)
logger
faraday-net_http (3.1.1)
net-http
faraday-retry (2.2.0)
faraday-retry (2.2.1)
faraday (~> 2.0)
fast_blank (1.0.1)
fastimage (2.3.0)
ffi (1.16.3)
fastimage (2.3.1)
ffi (1.17.0-aarch64-linux-gnu)
ffi (1.17.0-arm64-darwin)
ffi (1.17.0-x86_64-darwin)
ffi (1.17.0-x86_64-linux-gnu)
fspath (3.1.2)
globalid (1.2.1)
activesupport (>= 6.1)
google-protobuf (3.25.3-aarch64-linux)
google-protobuf (3.25.3-arm64-darwin)
google-protobuf (3.25.3-x86_64-darwin)
google-protobuf (3.25.3-x86_64-linux)
google-protobuf (4.27.2-aarch64-linux)
bigdecimal
rake (>= 13)
google-protobuf (4.27.2-arm64-darwin)
bigdecimal
rake (>= 13)
google-protobuf (4.27.2-x86_64-darwin)
bigdecimal
rake (>= 13)
google-protobuf (4.27.2-x86_64-linux)
bigdecimal
rake (>= 13)
guess_html_encoding (0.0.11)
hana (1.3.7)
hashdiff (1.1.0)
hashie (5.0.0)
highline (3.0.1)
highline (3.1.0)
reline
htmlentities (4.3.4)
http_accept_language (2.1.1)
i18n (1.14.1)
i18n (1.14.5)
concurrent-ruby (~> 1.0)
image_optim (0.31.3)
exifr (~> 1.2, >= 1.2.2)
@ -174,34 +187,40 @@ GEM
progress (~> 3.0, >= 3.0.1)
image_size (3.4.0)
in_threads (1.6.0)
io-console (0.7.2)
irb (1.14.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
iso8601 (0.13.0)
jmespath (1.6.2)
json (2.7.1)
json-schema (4.1.1)
json (2.7.2)
json-schema (4.3.1)
addressable (>= 2.8)
json_schemer (2.1.1)
json_schemer (2.3.0)
bigdecimal
hana (~> 1.3)
regexp_parser (~> 2.0)
simpleidn (~> 0.2)
jwt (2.7.1)
jwt (2.8.2)
base64
kgio (2.11.4)
language_server-protocol (3.17.0.3)
libv8-node (18.16.0.0-aarch64-linux)
libv8-node (18.16.0.0-arm64-darwin)
libv8-node (18.16.0.0-x86_64-darwin)
libv8-node (18.16.0.0-x86_64-linux)
listen (3.8.0)
libv8-node (18.19.0.0-aarch64-linux)
libv8-node (18.19.0.0-arm64-darwin)
libv8-node (18.19.0.0-x86_64-darwin)
libv8-node (18.19.0.0-x86_64-linux)
listen (3.9.0)
rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10)
literate_randomizer (0.4.0)
logger (1.6.0)
lograge (0.14.0)
actionpack (>= 4)
activesupport (>= 4)
railties (>= 4)
request_store (~> 1.0)
logstash-event (1.2.02)
logstash-logger (0.26.1)
logstash-event (~> 1.2)
logster (2.16.0)
logster (2.20.0)
loofah (2.22.0)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
@ -214,46 +233,49 @@ GEM
net-smtp
matrix (0.4.2)
maxminddb (0.1.22)
memory_profiler (1.0.1)
memory_profiler (1.0.2)
message_bus (4.3.8)
rack (>= 1.1.3)
method_source (1.0.0)
messageformat-wrapper (1.1.0)
mini_racer (>= 0.6.3)
method_source (1.1.0)
mini_mime (1.1.5)
mini_racer (0.8.0)
libv8-node (~> 18.16.0.0)
mini_racer (0.9.0)
libv8-node (~> 18.19.0.0)
mini_scheduler (0.16.0)
sidekiq (>= 4.2.3, < 7.0)
mini_sql (1.5.0)
mini_suffix (0.3.3)
ffi (~> 1.9)
minio_runner (0.1.2)
minitest (5.21.2)
mocha (2.1.0)
minitest (5.24.1)
mocha (2.4.5)
ruby2_keywords (>= 0.0.5)
msgpack (1.7.2)
multi_json (1.15.0)
multi_xml (0.6.0)
multi_xml (0.7.1)
bigdecimal (~> 3.1)
mustache (1.1.1)
mutex_m (0.2.0)
net-http (0.4.1)
uri
net-imap (0.4.9.1)
net-imap (0.4.14)
date
net-protocol
net-pop (0.1.2)
net-protocol
net-protocol (0.2.2)
timeout
net-smtp (0.4.0.1)
net-smtp (0.5.0)
net-protocol
nio4r (2.7.0)
nokogiri (1.16.0-aarch64-linux)
nio4r (2.7.3)
nokogiri (1.16.7-aarch64-linux)
racc (~> 1.4)
nokogiri (1.16.0-arm64-darwin)
nokogiri (1.16.7-arm64-darwin)
racc (~> 1.4)
nokogiri (1.16.0-x86_64-darwin)
nokogiri (1.16.7-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.16.0-x86_64-linux)
nokogiri (1.16.7-x86_64-linux)
racc (~> 1.4)
oauth (1.1.0)
oauth-tty (~> 1.0, >= 1.0.1)
@ -267,7 +289,7 @@ GEM
multi_json (~> 1.3)
multi_xml (~> 0.5)
rack (>= 1.2, < 4)
oj (3.16.3)
oj (3.16.4)
bigdecimal (>= 3.0)
omniauth (1.9.2)
hashie (>= 3.4.6)
@ -295,10 +317,10 @@ GEM
openssl-signature_algorithm (1.3.0)
openssl (> 2.0)
optimist (3.1.0)
parallel (1.24.0)
parallel_tests (4.4.0)
parallel (1.25.1)
parallel_tests (4.7.1)
parallel
parser (3.3.0.5)
parser (3.3.4.0)
ast (~> 2.4.1)
racc
pg (1.5.4)
@ -310,20 +332,30 @@ GEM
pry-byebug (3.10.1)
byebug (~> 11.0)
pry (>= 0.13, < 0.15)
pry-rails (0.3.9)
pry (>= 0.10.4)
public_suffix (5.0.4)
pry-rails (0.3.11)
pry (>= 0.13.0)
pry-stack_explorer (0.6.1)
binding_of_caller (~> 1.0)
pry (~> 0.13)
psych (5.1.2)
stringio
public_suffix (6.0.1)
puma (6.4.2)
nio4r (~> 2.0)
racc (1.7.3)
rack (2.2.8)
rack-mini-profiler (3.3.0)
racc (1.8.0)
rack (2.2.9)
rack-mini-profiler (3.3.1)
rack (>= 1.2.0)
rack-protection (3.2.0)
base64 (>= 0.1.0)
rack (~> 2.2, >= 2.2.4)
rack-session (1.0.2)
rack (< 3)
rack-test (2.1.0)
rack (>= 1.3)
rackup (1.0.0)
rack (< 3)
webrick
rails-dom-testing (2.2.0)
activesupport (>= 5.0.0)
minitest
@ -331,70 +363,78 @@ GEM
rails-html-sanitizer (1.6.0)
loofah (~> 2.21)
nokogiri (~> 1.14)
rails_failover (2.0.1)
activerecord (>= 6.1, <= 7.1)
rails_failover (2.1.1)
activerecord (>= 6.1, < 8.0)
concurrent-ruby
railties (>= 6.1, <= 7.1)
rails_multisite (5.0.0)
railties (>= 6.1, < 8.0)
rails_multisite (6.0.0)
activerecord (>= 6.0)
railties (>= 6.0)
railties (7.0.8)
actionpack (= 7.0.8)
activesupport (= 7.0.8)
method_source
railties (7.1.3.4)
actionpack (= 7.1.3.4)
activesupport (= 7.1.3.4)
irb
rackup (>= 1.0.0)
rake (>= 12.2)
thor (~> 1.0)
zeitwerk (~> 2.5)
thor (~> 1.0, >= 1.2.2)
zeitwerk (~> 2.6)
rainbow (3.1.1)
raindrops (0.20.1)
rake (13.1.0)
rake (13.2.1)
rb-fsevent (0.11.2)
rb-inotify (0.10.1)
rb-inotify (0.11.1)
ffi (~> 1.0)
rbtrace (0.5.1)
ffi (>= 1.0.6)
msgpack (>= 0.4.3)
optimist (>= 3.0.0)
rchardet (1.8.0)
rdoc (6.7.0)
psych (>= 4.0.0)
redcarpet (3.6.0)
redis (4.8.1)
redis-namespace (1.11.0)
redis (>= 4)
regexp_parser (2.9.0)
request_store (1.5.1)
regexp_parser (2.9.2)
reline (0.5.9)
io-console (~> 0.5)
request_store (1.7.0)
rack (>= 1.4)
rexml (3.2.6)
rexml (3.3.2)
strscan
rinku (2.0.6)
rotp (6.3.0)
rouge (4.2.0)
rouge (4.3.0)
rqrcode (2.2.0)
chunky_png (~> 1.0)
rqrcode_core (~> 1.0)
rqrcode_core (1.2.0)
rspec (3.12.0)
rspec-core (~> 3.12.0)
rspec-expectations (~> 3.12.0)
rspec-mocks (~> 3.12.0)
rspec-core (3.12.2)
rspec-support (~> 3.12.0)
rspec-expectations (3.12.3)
rrule (0.6.0)
activesupport (>= 2.3)
rspec (3.13.0)
rspec-core (~> 3.13.0)
rspec-expectations (~> 3.13.0)
rspec-mocks (~> 3.13.0)
rspec-core (3.13.0)
rspec-support (~> 3.13.0)
rspec-expectations (3.13.1)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.12.0)
rspec-support (~> 3.13.0)
rspec-html-matchers (0.10.0)
nokogiri (~> 1)
rspec (>= 3.0.0.a)
rspec-mocks (3.12.6)
rspec-mocks (3.13.1)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.12.0)
rspec-rails (6.1.1)
rspec-support (~> 3.13.0)
rspec-rails (6.1.3)
actionpack (>= 6.1)
activesupport (>= 6.1)
railties (>= 6.1)
rspec-core (~> 3.12)
rspec-expectations (~> 3.12)
rspec-mocks (~> 3.12)
rspec-support (~> 3.12)
rspec-support (3.12.1)
rspec-core (~> 3.13)
rspec-expectations (~> 3.13)
rspec-mocks (~> 3.13)
rspec-support (~> 3.13)
rspec-support (3.13.1)
rss (0.3.0)
rexml
rswag-specs (2.13.0)
@ -404,58 +444,70 @@ GEM
rspec-core (>= 2.14)
rtlcss (0.2.1)
mini_racer (>= 0.6.3)
rubocop (1.60.2)
rubocop (1.65.0)
json (~> 2.3)
language_server-protocol (>= 3.17.0)
parallel (~> 1.10)
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0)
regexp_parser (>= 2.4, < 3.0)
rexml (>= 3.2.5, < 4.0)
rubocop-ast (>= 1.30.0, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 3.0)
rubocop-ast (1.30.0)
parser (>= 3.2.1.0)
rubocop-capybara (2.20.0)
rubocop-ast (1.31.3)
parser (>= 3.3.1.0)
rubocop-capybara (2.21.0)
rubocop (~> 1.41)
rubocop-discourse (3.6.0)
rubocop-discourse (3.8.1)
activesupport (>= 6.1)
rubocop (>= 1.59.0)
rubocop-rspec (>= 2.25.0)
rubocop-factory_bot (2.25.1)
rubocop (~> 1.41)
rubocop-rspec (2.26.1)
rubocop (~> 1.40)
rubocop-capybara (~> 2.17)
rubocop-factory_bot (~> 2.22)
rubocop-capybara (>= 2.0.0)
rubocop-factory_bot (>= 2.0.0)
rubocop-rails (>= 2.25.0)
rubocop-rspec (>= 3.0.1)
rubocop-rspec_rails (>= 2.30.0)
rubocop-factory_bot (2.26.1)
rubocop (~> 1.61)
rubocop-rails (2.25.1)
activesupport (>= 4.2.0)
rack (>= 1.1)
rubocop (>= 1.33.0, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0)
rubocop-rspec (3.0.3)
rubocop (~> 1.61)
rubocop-rspec_rails (2.30.0)
rubocop (~> 1.61)
rubocop-rspec (~> 3, >= 3.0.1)
ruby-prof (1.7.0)
ruby-progressbar (1.13.0)
ruby-readability (0.7.0)
ruby-readability (0.7.1)
guess_html_encoding (>= 0.0.4)
nokogiri (>= 1.6.0)
ruby2_keywords (0.0.5)
rubyzip (2.3.2)
sanitize (6.1.0)
sanitize (6.1.2)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
sass-embedded (1.70.0-aarch64-linux-gnu)
google-protobuf (~> 3.25)
sass-embedded (1.70.0-arm64-darwin)
google-protobuf (~> 3.25)
sass-embedded (1.70.0-x86_64-darwin)
google-protobuf (~> 3.25)
sass-embedded (1.70.0-x86_64-linux-gnu)
google-protobuf (~> 3.25)
sassc-embedded (1.70.0)
sass-embedded (~> 1.70)
selenium-devtools (0.121.0)
sass-embedded (1.77.5-aarch64-linux-gnu)
google-protobuf (>= 3.25, < 5.0)
sass-embedded (1.77.5-arm64-darwin)
google-protobuf (>= 3.25, < 5.0)
sass-embedded (1.77.5-x86_64-darwin)
google-protobuf (>= 3.25, < 5.0)
sass-embedded (1.77.5-x86_64-linux-gnu)
google-protobuf (>= 3.25, < 5.0)
sassc-embedded (1.77.7)
sass-embedded (~> 1.77)
selenium-devtools (0.126.0)
selenium-webdriver (~> 4.2)
selenium-webdriver (4.17.0)
selenium-webdriver (4.23.0)
base64 (~> 0.2)
logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 3.0)
websocket (~> 1.0)
shoulda-matchers (6.1.0)
shoulda-matchers (6.2.0)
activesupport (>= 5.2.0)
sidekiq (6.5.12)
connection_pool (>= 2.2.5, < 3)
@ -467,30 +519,35 @@ GEM
simplecov_json_formatter (~> 0.1)
simplecov-html (0.12.3)
simplecov_json_formatter (0.1.4)
simpleidn (0.2.1)
unf (~> 0.1.4)
simpleidn (0.2.3)
snaky_hash (2.0.1)
hashie
version_gem (~> 1.1, >= 1.1.1)
sprockets-rails (3.4.2)
actionpack (>= 5.2)
activesupport (>= 5.2)
sprockets (3.7.3)
base64
concurrent-ruby (~> 1.0)
rack (> 1, < 3)
sprockets-rails (3.5.1)
actionpack (>= 6.1)
activesupport (>= 6.1)
sprockets (>= 3.0.0)
sqlite3 (1.7.1-aarch64-linux)
sqlite3 (1.7.1-arm64-darwin)
sqlite3 (1.7.1-x86_64-darwin)
sqlite3 (1.7.1-x86_64-linux)
sqlite3 (2.0.2-aarch64-linux-gnu)
sqlite3 (2.0.2-arm64-darwin)
sqlite3 (2.0.2-x86_64-darwin)
sqlite3 (2.0.2-x86_64-linux-gnu)
sshkey (3.0.0)
stackprof (0.2.26)
stringio (3.1.1)
strscan (3.1.0)
syntax_tree (6.2.0)
prettier_print (>= 1.2.0)
syntax_tree-disable_ternary (1.0.0)
test-prof (1.3.1)
thor (1.3.0)
test-prof (1.3.3.1)
thor (1.3.1)
timeout (0.4.1)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
tzinfo-data (1.2023.4)
tzinfo-data (1.2024.1)
tzinfo (>= 1.0.0)
uglifier (4.2.0)
execjs (>= 0.3.0, < 3)
@ -503,20 +560,21 @@ GEM
raindrops (~> 0.7)
uniform_notifier (1.16.0)
uri (0.13.0)
version_gem (1.1.3)
version_gem (1.1.4)
web-push (3.0.1)
jwt (~> 2.0)
openssl (~> 3.0)
webmock (3.19.1)
webmock (3.23.1)
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
websocket (1.2.10)
webrick (1.8.1)
websocket (1.2.11)
xpath (3.2.0)
nokogiri (~> 1.8)
yaml-lint (0.1.2)
yard (0.9.34)
zeitwerk (2.6.12)
yard (0.9.36)
zeitwerk (2.6.16)
PLATFORMS
aarch64-linux
@ -524,6 +582,7 @@ PLATFORMS
arm64-darwin-21
arm64-darwin-22
arm64-darwin-23
arm64-darwin-24
x86_64-darwin-18
x86_64-darwin-19
x86_64-darwin-20
@ -531,14 +590,14 @@ PLATFORMS
x86_64-linux
DEPENDENCIES
actionmailer (< 7.1)
actionpack (< 7.1)
actionview (< 7.1)
actionmailer (~> 7.1.0)
actionpack (~> 7.1.0)
actionview (~> 7.1.0)
actionview_precompiler
active_model_serializers (~> 0.8.3)
activemodel (< 7.1)
activerecord (< 7.1)
activesupport (< 7.1)
activemodel (~> 7.1.0)
activerecord (~> 7.1.0)
activesupport (~> 7.1.0)
addressable
annotate
aws-sdk-s3
@ -577,12 +636,12 @@ DEPENDENCIES
htmlentities
http_accept_language
image_optim
iso8601
json
json_schemer
listen
lograge
logstash-event
logstash-logger
logster
loofah
lru_redux
@ -591,13 +650,13 @@ DEPENDENCIES
maxminddb
memory_profiler
message_bus
messageformat-wrapper
mini_mime
mini_racer
mini_scheduler
mini_sql
mini_suffix
minio_runner
minitest
mocha
multi_json
mustache
@ -618,6 +677,7 @@ DEPENDENCIES
pg
pry-byebug
pry-rails
pry-stack_explorer
puma
rack
rack-mini-profiler
@ -625,17 +685,18 @@ DEPENDENCIES
rails-dom-testing
rails_failover
rails_multisite
railties (< 7.1)
railties (~> 7.1.0)
rake
rb-fsevent
rbtrace
rchardet
redcarpet
redis
redis (< 5.0)
redis-namespace
rinku
rotp
rqrcode
rrule
rspec
rspec-html-matchers
rspec-rails
@ -653,7 +714,7 @@ DEPENDENCIES
shoulda-matchers
sidekiq
simplecov
sprockets!
sprockets (~> 3.7.3)
sprockets-rails
sqlite3
sshkey
@ -672,4 +733,4 @@ DEPENDENCIES
yard
BUNDLED WITH
2.5.3
2.5.9

View File

@ -14,7 +14,7 @@ To learn more, visit [**discourse.org**](https://www.discourse.org) and join our
<a href="https://bbs.boingboing.net"><img alt="Boing Boing" src="https://github-production-user-asset-6210df.s3.amazonaws.com/5862206/261580781-1413ac96-5d08-40b2-bc8e-27c3f2d3bfe6.png" width="720px"></a>
<a href="https://twittercommunity.com/"><img src="https://user-images.githubusercontent.com/1681963/52239250-04ad8280-289c-11e9-9e42-574f6eaab9d7.png" width="720px"></a>
<a href="https://twittercommunity.com/"><img alt="X Community" src="https://github.com/discourse/discourse/assets/2790986/ebb63eee-1927-4060-ada1-cf1bc774084c.png" width="720px"></a>
<img src="https://user-images.githubusercontent.com/1681963/52239118-b304f800-289b-11e9-9904-16450680d9ec.jpg" alt="Mobile" width="414">

View File

@ -1,35 +0,0 @@
{
"licenses": {
"blueOak": "bronze",
"spdx": [
"CC0-1.0",
"CC-BY-3.0",
"CC-BY-4.0",
"Apache-2.0 WITH LLVM-exception",
"ISC"
]
},
"packages": {
"cli-table": "0.3.11",
"component-bind": "1.0.0",
"component-inherit": "0.0.3",
"duplex": "1.0.0",
"glob": "3.1.21",
"indexof": "0.0.1",
"inherits": "1.0.2",
"jsonify": "0.0.1",
"line-stream": "0.0.0",
"messageformat": "0.1.5",
"regenerator-transform": "0.10.1",
"source-map": "0.1.43",
"sourcemap-validator": "1.1.1",
"jspreadsheet-ce": "4.13.4",
"@jspreadsheet/formula": "2.0.2"
},
"corrections": true,
"ignore": [
{
"author": "Discourse"
}
]
}

View File

@ -2,15 +2,18 @@ import RestAdapter from "discourse/adapters/rest";
export default class Theme extends RestAdapter {
jsonMode = true;
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]);
@ -20,6 +23,7 @@ export default class Theme extends RestAdapter {
mappedParents = mappedParents.map((t) => map[t.id]);
theme.set("parentThemes", mappedParents);
});
return results;
}
}

View File

@ -4,4 +4,22 @@ export default class WebHookEvent extends RestAdapter {
basePath() {
return "/admin/api/";
}
appendQueryParams(path, findArgs, extension) {
const urlSearchParams = new URLSearchParams();
for (const [key, value] of Object.entries(findArgs)) {
if (value && key !== "webhookId") {
urlSearchParams.set(key, value);
}
}
const queryString = urlSearchParams.toString();
let url = `${path}/${findArgs.webhookId}${extension || ""}`;
if (queryString) {
url = `${url}?${queryString}`;
}
return url;
}
}

View File

@ -1 +0,0 @@
<div class="ace">{{this.content}}</div>

View File

@ -0,0 +1,23 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import I18n from "discourse-i18n";
export default class AdminConfigAreaCard extends Component {
@tracked collapsed = false;
get computedHeading() {
if (this.args.heading) {
return I18n.t(this.args.heading);
}
return this.args.translatedHeading;
}
<template>
<section class="admin-config-area-card" ...attributes>
<h3 class="admin-config-area-card__title">{{this.computedHeading}}</h3>
<div class="admin-config-area-card__content">
{{yield}}
</div>
</section>
</template>
}

View File

@ -0,0 +1,167 @@
import Component from "@glimmer/component";
import { cached, tracked } from "@glimmer/tracking";
import { hash } from "@ember/helper";
import { action } from "@ember/object";
import { service } from "@ember/service";
import Form from "discourse/components/form";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import i18n from "discourse-common/helpers/i18n";
import I18n from "discourse-i18n";
import GroupChooser from "select-kit/components/group-chooser";
import UserChooser from "select-kit/components/user-chooser";
export default class AdminConfigAreasAboutContactInformation extends Component {
@service site;
@service toasts;
@tracked
contactGroupId = this.site.groups.find(
(group) => group.name === this.data.contactGroupName
)?.id;
@cached
get data() {
return {
communityOwner: this.args.contactInformation.communityOwner.value,
contactEmail: this.args.contactInformation.contactEmail.value,
contactURL: this.args.contactInformation.contactURL.value,
contactGroupName: this.args.contactInformation.contactGroupName.value,
contactUsername:
this.args.contactInformation.contactUsername.value || null,
};
}
@action
setContactUsername(usernames, { set }) {
set("contactUsername", usernames[0] || null);
}
@action
setContactGroup(groupIds, { set }) {
this.contactGroupId = groupIds[0];
set(
"contactGroupName",
this.site.groups.find((group) => group.id === groupIds[0])?.name
);
}
@action
async save(data) {
try {
this.args.setGlobalSavingStatus(true);
await ajax("/admin/config/about.json", {
type: "PUT",
data: {
contact_information: {
community_owner: data.communityOwner,
contact_email: data.contactEmail,
contact_url: data.contactURL,
contact_username: data.contactUsername,
contact_group_name: data.contactGroupName,
},
},
});
this.toasts.success({
duration: 3000,
data: {
message: I18n.t(
"admin.config_areas.about.toasts.contact_information_saved"
),
},
});
} catch (err) {
popupAjaxError(err);
} finally {
this.args.setGlobalSavingStatus(false);
}
}
<template>
<Form @data={{this.data}} @onSubmit={{this.save}} as |form|>
<form.Field
@name="communityOwner"
@title={{i18n "admin.config_areas.about.community_owner"}}
@subtitle={{i18n "admin.config_areas.about.community_owner_help"}}
@format="large"
as |field|
>
<field.Input
placeholder={{i18n
"admin.config_areas.about.community_owner_placeholder"
}}
/>
</form.Field>
<form.Field
@name="contactEmail"
@title={{i18n "admin.config_areas.about.contact_email"}}
@subtitle={{i18n "admin.config_areas.about.contact_email_help"}}
@type="email"
@format="large"
as |field|
>
<field.Input
placeholder={{i18n
"admin.config_areas.about.contact_email_placeholder"
}}
/>
</form.Field>
<form.Field
@name="contactURL"
@title={{i18n "admin.config_areas.about.contact_url"}}
@subtitle={{i18n "admin.config_areas.about.contact_url_help"}}
@type="url"
@format="large"
as |field|
>
<field.Input
placeholder={{i18n
"admin.config_areas.about.contact_url_placeholder"
}}
/>
</form.Field>
<form.Field
@name="contactUsername"
@title={{i18n "admin.config_areas.about.site_contact_name"}}
@subtitle={{i18n "admin.config_areas.about.site_contact_group_help"}}
@onSet={{this.setContactUsername}}
@format="large"
as |field|
>
<field.Custom>
<UserChooser
@value={{field.value}}
@options={{hash maximum=1}}
@onChange={{field.set}}
/>
</field.Custom>
</form.Field>
<form.Field
@name="contactGroupName"
@title={{i18n "admin.config_areas.about.site_contact_group"}}
@subtitle={{i18n "admin.config_areas.about.site_contact_group_help"}}
@onSet={{this.setContactGroup}}
@format="large"
as |field|
>
<field.Custom>
<GroupChooser
@content={{this.site.groups}}
@value={{this.contactGroupId}}
@options={{hash maximum=1}}
@onChange={{field.set}}
/>
</field.Custom>
</form.Field>
<form.Submit
@label="admin.config_areas.about.update"
@disabled={{@globalSavingStatus}}
/>
</Form>
</template>
}

View File

@ -0,0 +1,114 @@
import Component from "@glimmer/component";
import { cached } from "@glimmer/tracking";
import { action } from "@ember/object";
import { service } from "@ember/service";
import Form from "discourse/components/form";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import i18n from "discourse-common/helpers/i18n";
import I18n from "discourse-i18n";
export default class AdminConfigAreasAboutGeneralSettings extends Component {
@service toasts;
name = this.args.generalSettings.title.value;
summary = this.args.generalSettings.siteDescription.value;
extendedDescription = this.args.generalSettings.extendedSiteDescription.value;
aboutBannerImage = this.args.generalSettings.aboutBannerImage.value;
@cached
get data() {
return {
name: this.args.generalSettings.title.value,
summary: this.args.generalSettings.siteDescription.value,
extendedDescription:
this.args.generalSettings.extendedSiteDescription.value,
aboutBannerImage: this.args.generalSettings.aboutBannerImage.value,
};
}
@action
async save(data) {
try {
this.args.setGlobalSavingStatus(true);
await ajax("/admin/config/about.json", {
type: "PUT",
data: {
general_settings: {
name: data.name,
summary: data.summary,
extended_description: data.extendedDescription,
about_banner_image: data.aboutBannerImage,
},
},
});
this.toasts.success({
duration: 3000,
data: {
message: I18n.t(
"admin.config_areas.about.toasts.general_settings_saved"
),
},
});
} catch (err) {
popupAjaxError(err);
} finally {
this.args.setGlobalSavingStatus(false);
}
}
@action
setImage(upload, { set }) {
set("aboutBannerImage", upload.url);
}
<template>
<Form @data={{this.data}} @onSubmit={{this.save}} as |form|>
<form.Field
@name="name"
@title={{i18n "admin.config_areas.about.community_name"}}
@validation="required"
@format="large"
as |field|
>
<field.Input
placeholder={{i18n
"admin.config_areas.about.community_name_placeholder"
}}
/>
</form.Field>
<form.Field
@name="summary"
@title={{i18n "admin.config_areas.about.community_summary"}}
@format="large"
as |field|
>
<field.Input />
</form.Field>
<form.Field
@name="extendedDescription"
@title={{i18n "admin.config_areas.about.community_description"}}
as |field|
>
<field.Composer />
</form.Field>
<form.Field
@name="aboutBannerImage"
@title={{i18n "admin.config_areas.about.banner_image"}}
@subtitle={{i18n "admin.config_areas.about.banner_image_help"}}
@onSet={{this.setImage}}
as |field|
>
<field.Image />
</form.Field>
<form.Submit
@label="admin.config_areas.about.update"
@disabled={{@globalSavingStatus}}
/>
</Form>
</template>
}

View File

@ -0,0 +1,109 @@
import Component from "@glimmer/component";
import { cached } from "@glimmer/tracking";
import { action } from "@ember/object";
import { service } from "@ember/service";
import Form from "discourse/components/form";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import i18n from "discourse-common/helpers/i18n";
import I18n from "discourse-i18n";
export default class AdminConfigAreasAboutYourOrganization extends Component {
@service toasts;
companyName = this.args.yourOrganization.companyName.value;
governingLaw = this.args.yourOrganization.governingLaw.value;
cityForDisputes = this.args.yourOrganization.cityForDisputes.value;
@cached
get data() {
return {
companyName: this.args.yourOrganization.companyName.value,
governingLaw: this.args.yourOrganization.governingLaw.value,
cityForDisputes: this.args.yourOrganization.cityForDisputes.value,
};
}
@action
async save(data) {
this.args.setGlobalSavingStatus(true);
try {
await ajax("/admin/config/about.json", {
type: "PUT",
data: {
your_organization: {
company_name: data.companyName,
governing_law: data.governingLaw,
city_for_disputes: data.cityForDisputes,
},
},
});
this.toasts.success({
duration: 30000,
data: {
message: I18n.t(
"admin.config_areas.about.toasts.your_organization_saved"
),
},
});
} catch (err) {
popupAjaxError(err);
} finally {
this.args.setGlobalSavingStatus(false);
}
}
<template>
<Form @data={{this.data}} @onSubmit={{this.save}} as |form|>
<form.Field
@name="companyName"
@title={{i18n "admin.config_areas.about.company_name"}}
@subtitle={{i18n "admin.config_areas.about.company_name_help"}}
@format="large"
as |field|
>
<field.Input
placeholder={{i18n
"admin.config_areas.about.company_name_placeholder"
}}
/>
</form.Field>
<form.Alert @type="error">
{{i18n "admin.config_areas.about.company_name_warning"}}
</form.Alert>
<form.Field
@name="governingLaw"
@title={{i18n "admin.config_areas.about.governing_law"}}
@subtitle={{i18n "admin.config_areas.about.governing_law_help"}}
@format="large"
as |field|
>
<field.Input
placeholder={{i18n
"admin.config_areas.about.governing_law_placeholder"
}}
/>
</form.Field>
<form.Field
@name="cityForDisputes"
@title={{i18n "admin.config_areas.about.city_for_disputes"}}
@subtitle={{i18n "admin.config_areas.about.city_for_disputes_help"}}
@format="large"
as |field|
>
<field.Input
placeholder={{i18n
"admin.config_areas.about.city_for_disputes_placeholder"
}}
/>
</form.Field>
<form.Submit
@label="admin.config_areas.about.update"
@disabled={{@globalSavingStatus}}
/>
</Form>
</template>
}

View File

@ -1,46 +0,0 @@
<div
class="admin-config-area-sidebar-experiment"
{{did-insert this.loadDefaultNavConfig}}
>
<h4>Sidebar Experiment</h4>
<p>Changes you make here will be applied to the admin sidebar and persist
between reloads
<em>on this device only</em>. Note that in addition to the
<code>text</code>
and
<code>route</code>
options, you can also specify a
<code>icon</code>
or a
<code>href</code>, if you want to link to a specific page but don't know the
Ember route. You can also use the Ember Inspector extension to find route
names, for example for plugin routes which are not auto-generated here.
<br /><br />
<code>routeModels</code>
is an array of values that correspond to parts of the route; for example the
topic route may be
<code>/t/:id</code>, so to get a link to topic with ID 123 you would do
<code>routeModels: [123]</code>.</p>
<p>All configuration options for a section and its links looks like this:</p>
<pre><code>{{this.exampleJson}}</code></pre>
<DButton
@action={{this.resetToDefault}}
@translatedLabel="Reset to Default"
/>
<DButton
class="btn-primary"
@action={{this.applyConfig}}
@translatedLabel="Apply Config"
/>
<div class="admin-config-area-sidebar-experiment__editor">
<AceEditor
@content={{this.editedNavConfig}}
@editorId="admin-config-area-sidebar-experiment"
@save={{this.applyNav}}
/>
</div>
</div>

View File

@ -1,130 +0,0 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
import { ADMIN_NAV_MAP } from "discourse/lib/sidebar/admin-nav-map";
import {
buildAdminSidebar,
useAdminNavConfig,
} from "discourse/lib/sidebar/admin-sidebar";
import { resetPanelSections } from "discourse/lib/sidebar/custom-sections";
import { ADMIN_PANEL } from "discourse/lib/sidebar/panels";
// TODO (martin) (2024-02-01) Remove this experimental UI.
export default class AdminConfigAreaSidebarExperiment extends Component {
@service adminSidebarExperimentStateManager;
@service toasts;
@service router;
@tracked editedNavConfig;
validRouteNames = new Set();
get defaultAdminNav() {
return JSON.stringify(ADMIN_NAV_MAP, null, 2);
}
get exampleJson() {
return JSON.stringify(
{
name: "section-name",
text: "Section Name",
links: [
{
name: "admin-revamp",
route: "admin-revamp",
routeModels: [123],
text: "Revamp",
href: "https://forum.site.com/t/123",
icon: "rocket",
},
],
},
null,
2
);
}
@action
loadDefaultNavConfig() {
const savedConfig = this.adminSidebarExperimentStateManager.navConfig;
this.editedNavConfig = savedConfig
? JSON.stringify(savedConfig, null, 2)
: this.defaultAdminNav;
}
@action
resetToDefault() {
this.editedNavConfig = this.defaultAdminNav;
this.#saveConfig(ADMIN_NAV_MAP);
}
@action
applyConfig() {
let config = null;
try {
config = JSON.parse(this.editedNavConfig);
} catch {
this.toasts.error({
duration: 3000,
data: {
message: "There was an error, make sure the structure is valid JSON.",
},
});
return;
}
let invalidRoutes = [];
config.forEach((section) => {
section.links.forEach((link) => {
if (!link.route) {
return;
}
if (this.validRouteNames.has(link.route)) {
return;
}
// Using the private `_routerMicrolib` is not ideal, but Ember doesn't provide
// any other way for us to easily check for route validity.
try {
// eslint-disable-next-line ember/no-private-routing-service
this.router._router._routerMicrolib.recognizer.handlersFor(
link.route
);
this.validRouteNames.add(link.route);
} catch (err) {
// eslint-disable-next-line no-console
console.debug("[AdminSidebarExperiment]", err);
invalidRoutes.push(link.route);
}
});
});
if (invalidRoutes.length) {
this.toasts.error({
duration: 3000,
data: {
message: `There was an error with one or more of the routes provided: ${invalidRoutes.join(
", "
)}`,
},
});
return;
}
this.#saveConfig(config);
}
#saveConfig(config) {
this.adminSidebarExperimentStateManager.navConfig = config;
resetPanelSections(
ADMIN_PANEL,
useAdminNavConfig(config),
buildAdminSidebar
);
this.toasts.success({
duration: 3000,
data: { message: "Sidebar navigation applied successfully!" },
});
}
}

View File

@ -0,0 +1,91 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import i18n from "discourse-common/helpers/i18n";
import AdminConfigAreaCard from "admin/components/admin-config-area-card";
import AdminConfigAreasAboutContactInformation from "admin/components/admin-config-area-cards/about/contact-information";
import AdminConfigAreasAboutGeneralSettings from "admin/components/admin-config-area-cards/about/general-settings";
import AdminConfigAreasAboutYourOrganization from "admin/components/admin-config-area-cards/about/your-organization";
export default class AdminConfigAreasAbout extends Component {
@tracked saving = false;
get generalSettings() {
return {
title: this.#lookupSettingFromData("title"),
siteDescription: this.#lookupSettingFromData("site_description"),
extendedSiteDescription: this.#lookupSettingFromData(
"extended_site_description"
),
aboutBannerImage: this.#lookupSettingFromData("about_banner_image"),
};
}
get contactInformation() {
return {
communityOwner: this.#lookupSettingFromData("community_owner"),
contactEmail: this.#lookupSettingFromData("contact_email"),
contactURL: this.#lookupSettingFromData("contact_url"),
contactUsername: this.#lookupSettingFromData("site_contact_username"),
contactGroupName: this.#lookupSettingFromData("site_contact_group_name"),
};
}
get yourOrganization() {
return {
companyName: this.#lookupSettingFromData("company_name"),
governingLaw: this.#lookupSettingFromData("governing_law"),
cityForDisputes: this.#lookupSettingFromData("city_for_disputes"),
};
}
@action
setSavingStatus(status) {
this.saving = status;
}
#lookupSettingFromData(name) {
return this.args.data.findBy("setting", name);
}
<template>
<div class="admin-config-area">
<h2>{{i18n "admin.config_areas.about.header"}}</h2>
<div class="admin-config-area__primary-content">
<AdminConfigAreaCard
@heading="admin.config_areas.about.general_settings"
@primaryActionLabel="admin.config_areas.about.update"
class="admin-config-area-about__general-settings-section"
>
<AdminConfigAreasAboutGeneralSettings
@generalSettings={{this.generalSettings}}
@setGlobalSavingStatus={{this.setSavingStatus}}
@globalSavingStatus={{this.saving}}
/>
</AdminConfigAreaCard>
<AdminConfigAreaCard
@heading="admin.config_areas.about.contact_information"
@primaryActionLabel="admin.config_areas.about.update"
class="admin-config-area-about__contact-information-section"
>
<AdminConfigAreasAboutContactInformation
@contactInformation={{this.contactInformation}}
@setGlobalSavingStatus={{this.setSavingStatus}}
@globalSavingStatus={{this.saving}}
/>
</AdminConfigAreaCard>
<AdminConfigAreaCard
@heading="admin.config_areas.about.your_organization"
@primaryActionLabel="admin.config_areas.about.update"
class="admin-config-area-about__your-organization-section"
>
<AdminConfigAreasAboutYourOrganization
@yourOrganization={{this.yourOrganization}}
@setGlobalSavingStatus={{this.setSavingStatus}}
@globalSavingStatus={{this.saving}}
/>
</AdminConfigAreaCard>
</div>
</div>
</template>
}

View File

@ -0,0 +1,91 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import { service } from "@ember/service";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import i18n from "discourse-common/helpers/i18n";
import { bind } from "discourse-common/utils/decorators";
import AdminConfigHeader from "admin/components/admin-config-header";
import AdminFlagItem from "admin/components/admin-flag-item";
export default class AdminConfigAreasFlags extends Component {
@service site;
@tracked flags = this.site.flagTypes;
@bind
isFirstFlag(flag) {
return this.flags.indexOf(flag) === 1;
}
@bind
isLastFlag(flag) {
return this.flags.indexOf(flag) === this.flags.length - 1;
}
@action
moveFlagCallback(flag, direction) {
const fallbackFlags = [...this.flags];
const flags = this.flags;
const flagIndex = flags.indexOf(flag);
const targetFlagIndex = direction === "up" ? flagIndex - 1 : flagIndex + 1;
const targetFlag = flags[targetFlagIndex];
flags[flagIndex] = targetFlag;
flags[targetFlagIndex] = flag;
this.flags = flags;
return ajax(`/admin/config/flags/${flag.id}/reorder/${direction}`, {
type: "PUT",
}).catch((error) => {
this.flags = fallbackFlags;
return popupAjaxError(error);
});
}
@action
deleteFlagCallback(flag) {
return ajax(`/admin/config/flags/${flag.id}`, {
type: "DELETE",
})
.then(() => {
this.flags.removeObject(flag);
})
.catch((error) => popupAjaxError(error));
}
<template>
<div class="container admin-flags">
<AdminConfigHeader
@name="flags"
@heading="admin.config_areas.flags.header"
@subheading="admin.config_areas.flags.subheader"
@primaryActionRoute="adminConfig.flags.new"
@primaryActionCssClass="admin-flags__header-add-flag"
@primaryActionIcon="plus"
@primaryActionLabel="admin.config_areas.flags.add"
/>
<table class="admin-flags__items grid">
<thead>
<th>{{i18n "admin.config_areas.flags.description"}}</th>
<th>{{i18n "admin.config_areas.flags.enabled"}}</th>
</thead>
<tbody>
{{#each this.flags as |flag|}}
<AdminFlagItem
@flag={{flag}}
@moveFlagCallback={{this.moveFlagCallback}}
@deleteFlagCallback={{this.deleteFlagCallback}}
@isFirstFlag={{this.isFirstFlag flag}}
@isLastFlag={{this.isLastFlag flag}}
/>
{{/each}}
</tbody>
</table>
</div>
</template>
}

View File

@ -0,0 +1,34 @@
import Component from "@glimmer/component";
import { LinkTo } from "@ember/routing";
import concatClass from "discourse/helpers/concat-class";
import dIcon from "discourse-common/helpers/d-icon";
import i18n from "discourse-common/helpers/i18n";
export default class AdminFlagItem extends Component {
get headerCssClass() {
return `admin-${this.args.name}__header`;
}
<template>
<div class={{this.headerCssClass}}>
<h2>{{i18n @heading}}</h2>
{{#if @primaryActionRoute}}
<LinkTo
@route={{@primaryActionRoute}}
class={{concatClass
"btn-primary"
"btn"
"btn-icon-text"
@primaryActionCssClass
}}
>
{{dIcon @primaryActionIcon}}
{{i18n @primaryActionLabel}}
</LinkTo>
{{/if}}
{{#if @subheading}}
<h3>{{i18n @subheading}}</h3>
{{/if}}
</div>
</template>
}

View File

@ -0,0 +1,193 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { fn } from "@ember/helper";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { service } from "@ember/service";
import { htmlSafe } from "@ember/template";
import { not } from "truth-helpers";
import DButton from "discourse/components/d-button";
import DToggleSwitch from "discourse/components/d-toggle-switch";
import DropdownMenu from "discourse/components/dropdown-menu";
import concatClass from "discourse/helpers/concat-class";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { SYSTEM_FLAG_IDS } from "discourse/lib/constants";
import i18n from "discourse-common/helpers/i18n";
import DMenu from "float-kit/components/d-menu";
export default class AdminFlagItem extends Component {
@service dialog;
@service router;
@tracked enabled = this.args.flag.enabled;
@tracked isSaving = false;
get canMove() {
return this.args.flag.id !== SYSTEM_FLAG_IDS.notify_user;
}
get canEdit() {
return (
!Object.values(SYSTEM_FLAG_IDS).includes(this.args.flag.id) &&
!this.args.flag.is_used
);
}
get editTitle() {
return this.canEdit
? "admin.config_areas.flags.form.edit_flag"
: "admin.config_areas.flags.form.non_editable";
}
get deleteTitle() {
return this.canEdit
? "admin.config_areas.flags.form.edit_flag"
: "admin.config_areas.flags.form.non_editable";
}
@action
toggleFlagEnabled(flag) {
this.enabled = !this.enabled;
this.isSaving = true;
return ajax(`/admin/config/flags/${flag.id}/toggle`, {
type: "PUT",
})
.then(() => {
this.args.flag.enabled = this.enabled;
})
.catch((error) => {
this.enabled = !this.enabled;
return popupAjaxError(error);
})
.finally(() => {
this.isSaving = false;
});
}
@action
onRegisterApi(api) {
this.dMenu = api;
}
@action
moveUp() {
this.isSaving = true;
this.dMenu.close();
this.args.moveFlagCallback(this.args.flag, "up").finally(() => {
this.isSaving = false;
});
}
@action
moveDown() {
this.isSaving = true;
this.dMenu.close();
this.args.moveFlagCallback(this.args.flag, "down").finally(() => {
this.isSaving = false;
});
}
@action
edit() {
this.router.transitionTo("adminConfig.flags.edit", this.args.flag);
}
@action
delete() {
this.isSaving = true;
this.dialog.yesNoConfirm({
message: i18n("admin.config_areas.flags.delete_confirm", {
name: this.args.flag.name,
}),
didConfirm: () => {
this.args.deleteFlagCallback(this.args.flag).finally(() => {
this.isSaving = false;
});
},
didCancel: () => {
this.isSaving = false;
},
});
this.dMenu.close();
}
<template>
<tr
class={{concatClass
"admin-flag-item"
@flag.name_key
(if this.isSaving "saving")
}}
>
<td>
<p class="admin-flag-item__name">{{@flag.name}}</p>
<p class="admin-flag-item__description">{{htmlSafe
@flag.description
}}</p>
</td>
<td>
<div class="admin-flag-item__options">
<DToggleSwitch
@state={{this.enabled}}
class="admin-flag-item__toggle {{@flag.name_key}}"
{{on "click" (fn this.toggleFlagEnabled @flag)}}
/>
<DButton
class="btn btn-secondary admin-flag-item__edit"
@action={{this.edit}}
@label="admin.config_areas.flags.edit"
@disabled={{not this.canEdit}}
@title={{this.editTitle}}
/>
{{#if this.canMove}}
<DMenu
@identifier="flag-menu"
@title={{i18n "admin.config_areas.flags.more_options.title"}}
@icon="ellipsis-v"
@onRegisterApi={{this.onRegisterApi}}
>
<:content>
<DropdownMenu as |dropdown|>
{{#unless @isFirstFlag}}
<dropdown.item>
<DButton
@label="admin.config_areas.flags.more_options.move_up"
@icon="arrow-up"
@class="btn-transparent admin-flag-item__move-up"
@action={{this.moveUp}}
/>
</dropdown.item>
{{/unless}}
{{#unless @isLastFlag}}
<dropdown.item>
<DButton
@label="admin.config_areas.flags.more_options.move_down"
@icon="arrow-down"
@class="btn-transparent admin-flag-item__move-down"
@action={{this.moveDown}}
/>
</dropdown.item>
{{/unless}}
<dropdown.item>
<DButton
@label="admin.config_areas.flags.delete"
@icon="trash-alt"
class="btn-transparent admin-flag-item__delete"
@action={{this.delete}}
@disabled={{not this.canEdit}}
@title={{this.deleteTitle}}
/>
</dropdown.item>
</DropdownMenu>
</:content>
</DMenu>
{{/if}}
</div>
</td>
</tr>
</template>
}

View File

@ -0,0 +1,228 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { fn, hash } from "@ember/helper";
import { TextArea } from "@ember/legacy-built-in-components";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { LinkTo } from "@ember/routing";
import { service } from "@ember/service";
import { isEmpty } from "@ember/utils";
import { not } from "truth-helpers";
import DButton from "discourse/components/d-button";
import withEventValue from "discourse/helpers/with-event-value";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import dIcon from "discourse-common/helpers/d-icon";
import i18n from "discourse-common/helpers/i18n";
import { bind } from "discourse-common/utils/decorators";
import I18n from "discourse-i18n";
import AdminConfigAreaCard from "admin/components/admin-config-area-card";
import MultiSelect from "select-kit/components/multi-select";
export default class AdminFlagsForm extends Component {
@service router;
@service site;
@tracked enabled = true;
@tracked requireMessage = false;
@tracked name;
@tracked description;
@tracked appliesTo;
constructor() {
super(...arguments);
if (this.isUpdate) {
this.name = this.args.flag.name;
this.description = this.args.flag.description;
this.appliesTo = this.args.flag.applies_to;
this.requireMessage = this.args.flag.require_message;
this.enabled = this.args.flag.enabled;
}
}
get isUpdate() {
return this.args.flag;
}
get isValid() {
return (
!isEmpty(this.name) &&
!isEmpty(this.description) &&
!isEmpty(this.appliesTo)
);
}
get header() {
return this.isUpdate
? "admin.config_areas.flags.form.edit_header"
: "admin.config_areas.flags.form.add_header";
}
get appliesToValues() {
return this.site.valid_flag_applies_to_types.map((type) => {
return {
name: I18n.t(
`admin.config_areas.flags.form.${type
.toLowerCase()
.replace("::", "_")}`
),
id: type,
};
});
}
@action
save() {
this.isUpdate ? this.update() : this.create();
}
@action
onToggleRequireMessage(e) {
this.requireMessage = e.target.checked;
}
@action
onToggleEnabled(e) {
this.enabled = e.target.checked;
}
@bind
create() {
return ajax(`/admin/config/flags`, {
type: "POST",
data: this.#formData,
})
.then((response) => {
this.site.flagTypes.push(response.flag);
this.router.transitionTo("adminConfig.flags");
})
.catch((error) => {
return popupAjaxError(error);
});
}
@bind
update() {
return ajax(`/admin/config/flags/${this.args.flag.id}`, {
type: "PUT",
data: this.#formData,
})
.then((response) => {
this.args.flag.name = response.flag.name;
this.args.flag.description = response.flag.description;
this.args.flag.applies_to = response.flag.applies_to;
this.args.flag.require_message = response.flag.require_message;
this.args.flag.enabled = response.flag.enabled;
this.router.transitionTo("adminConfig.flags");
})
.catch((error) => {
return popupAjaxError(error);
});
}
@bind
get #formData() {
return {
name: this.name,
description: this.description,
applies_to: this.appliesTo,
require_message: this.requireMessage,
enabled: this.enabled,
};
}
<template>
<div class="admin-config-area">
<h2>{{i18n "admin.config_areas.flags.header"}}</h2>
<LinkTo
@route="adminConfig.flags"
class="btn-default btn btn-icon-text btn-back"
>
{{dIcon "chevron-left"}}
{{i18n "admin.config_areas.flags.back"}}
</LinkTo>
<div class="admin-config-area__primary-content admin-flag-form">
<AdminConfigAreaCard @heading={{this.header}}>
<div class="control-group">
<label for="name">
{{i18n "admin.config_areas.flags.form.name"}}
</label>
<input
name="name"
type="text"
value={{this.name}}
maxlength="200"
class="admin-flag-form__name"
{{on "input" (withEventValue (fn (mut this.name)))}}
/>
</div>
<div class="control-group">
<label for="description">
{{i18n "admin.config_areas.flags.form.description"}}
</label>
<TextArea
@value={{this.description}}
maxlength="1000"
class="admin-flag-form__description"
/>
</div>
<div class="control-group">
<label for="applies-to">
{{i18n "admin.config_areas.flags.form.applies_to"}}
</label>
<MultiSelect
@value={{this.appliesTo}}
@content={{this.appliesToValues}}
@options={{hash allowAny=false}}
class="admin-flag-form__applies-to"
/>
</div>
<div class="control-group">
<label class="checkbox-label admin-flag-form__require-reason">
<input
{{on "input" this.onToggleRequireMessage}}
type="checkbox"
checked={{this.requireMessage}}
/>
<div>
{{i18n "admin.config_areas.flags.form.require_message"}}
<div class="admin-flag-form__require-message-description">
{{i18n
"admin.config_areas.flags.form.require_message_description"
}}
</div>
</div>
</label>
</div>
<div class="control-group">
<label class="checkbox-label admin-flag-form__enabled">
<input
{{on "input" this.onToggleEnabled}}
type="checkbox"
checked={{this.enabled}}
/>
{{i18n "admin.config_areas.flags.form.enabled"}}
</label>
</div>
<div class="alert alert-info admin_flag_form__info">
{{dIcon "info-circle"}}
{{i18n "admin.config_areas.flags.form.alert"}}
</div>
<DButton
@action={{this.save}}
@label="admin.config_areas.flags.form.save"
@ariaLabel="admin.config_areas.flags.form.save"
@disabled={{not this.isValid}}
class="btn-primary admin-flag-form__save"
/>
</AdminConfigAreaCard>
</div>
</div>
</template>
}

View File

@ -34,7 +34,7 @@ export default class AdminPenaltyPostAction extends Component {
@afterRender
_focusEditTextarea() {
const elem = this.element;
const body = elem.closest(".modal-body");
const body = elem.closest(".d-modal__body");
body.scrollTo(0, body.clientHeight);
elem.querySelector(".post-editor").focus();
}

View File

@ -0,0 +1,48 @@
import Component from "@glimmer/component";
import { LinkTo } from "@ember/routing";
import { service } from "@ember/service";
import concatClass from "discourse/helpers/concat-class";
import I18n from "discourse-i18n";
export default class AdminPluginConfigArea extends Component {
@service adminPluginNavManager;
linkText(navLink) {
if (navLink.label) {
return I18n.t(navLink.label);
} else {
return navLink.text;
}
}
<template>
{{#if this.adminPluginNavManager.isSidebarMode}}
<nav class="admin-nav admin-plugin-inner-sidebar-nav pull-left">
<ul class="nav nav-stacked">
{{#each
this.adminPluginNavManager.currentConfigNav.links
as |navLink|
}}
<li
class={{concatClass
"admin-plugin-inner-sidebar-nav__item"
navLink.route
}}
>
<LinkTo
@route={{navLink.route}}
@model={{navLink.model}}
title={{this.linkText navLink}}
>
{{this.linkText navLink}}
</LinkTo>
</li>
{{/each}}
</ul>
</nav>
{{/if}}
<section class="admin-plugin-config-area">
{{yield}}
</section>
</template>
}

View File

@ -0,0 +1,22 @@
import i18n from "discourse-common/helpers/i18n";
const AdminPluginConfigMetadata = <template>
<div class="admin-plugin-config-page__metadata">
<div class="admin-plugin-config-area__metadata-title">
<h2>
{{@plugin.nameTitleized}}
</h2>
<p>
{{@plugin.about}}
{{#if @plugin.linkUrl}}
|
<a href={{@plugin.linkUrl}} rel="noopener noreferrer" target="_blank">
{{i18n "admin.plugins.learn_more"}}
</a>
{{/if}}
</p>
</div>
</div>
</template>;
export default AdminPluginConfigMetadata;

View File

@ -0,0 +1,55 @@
import Component from "@glimmer/component";
import { service } from "@ember/service";
import DBreadcrumbsContainer from "discourse/components/d-breadcrumbs-container";
import DBreadcrumbsItem from "discourse/components/d-breadcrumbs-item";
import i18n from "discourse-common/helpers/i18n";
import AdminPluginConfigArea from "./admin-plugin-config-area";
import AdminPluginConfigMetadata from "./admin-plugin-config-metadata";
import AdminPluginConfigTopNav from "./admin-plugin-config-top-nav";
export default class AdminPluginConfigPage extends Component {
@service currentUser;
@service adminPluginNavManager;
get mainAreaClasses() {
let classes = ["admin-plugin-config-page__main-area"];
if (this.adminPluginNavManager.isSidebarMode) {
classes.push("-with-inner-sidebar");
} else {
classes.push("-without-inner-sidebar");
}
return classes.join(" ");
}
<template>
<div class="admin-plugin-config-page">
<DBreadcrumbsContainer />
<DBreadcrumbsItem @path="/admin" @label={{i18n "admin_title"}} />
<DBreadcrumbsItem
@path="/admin/plugins"
@label={{i18n "admin.plugins.title"}}
/>
<DBreadcrumbsItem
@path="/admin/plugins/{{@plugin.name}}"
@label={{@plugin.nameTitleized}}
/>
<AdminPluginConfigMetadata @plugin={{@plugin}} />
{{#if this.adminPluginNavManager.isTopMode}}
<AdminPluginConfigTopNav />
{{/if}}
<div class="admin-plugin-config-page__content">
<div class={{this.mainAreaClasses}}>
<AdminPluginConfigArea>
{{yield}}
</AdminPluginConfigArea>
</div>
</div>
</div>
</template>
}

View File

@ -0,0 +1,36 @@
import Component from "@glimmer/component";
import { service } from "@ember/service";
import HorizontalOverflowNav from "discourse/components/horizontal-overflow-nav";
import NavItem from "discourse/components/nav-item";
import i18n from "discourse-common/helpers/i18n";
export default class AdminPluginConfigTopNav extends Component {
@service adminPluginNavManager;
linkText(navLink) {
if (navLink.label) {
return i18n(navLink.label);
} else {
return navLink.text;
}
}
<template>
<div class="admin-nav-submenu">
<HorizontalOverflowNav
class="plugin-nav admin-plugin-config-page__top-nav"
>
{{#each this.adminPluginNavManager.currentConfigNav.links as |navLink|}}
<NavItem
@route={{navLink.route}}
@i18nLabel={{this.linkText navLink}}
title={{this.linkText navLink}}
class="admin-plugin-config-page__top-nav-item"
>
{{this.linkText navLink}}
</NavItem>
{{/each}}
</HorizontalOverflowNav>
</div>
</template>
}

View File

@ -0,0 +1,75 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import { cancel } from "@ember/runloop";
import { service } from "@ember/service";
import { isEmpty } from "@ember/utils";
import ConditionalLoadingSpinner from "discourse/components/conditional-loading-spinner";
import SiteSettingFilter from "discourse/lib/site-setting-filter";
import i18n from "discourse-common/helpers/i18n";
import discourseDebounce from "discourse-common/lib/debounce";
import AdminSiteSettingsFilterControls from "admin/components/admin-site-settings-filter-controls";
import SiteSetting from "admin/components/site-setting";
export default class AdminPluginFilteredSiteSettings extends Component {
@service currentUser;
@tracked visibleSettings;
@tracked loading = true;
siteSettingFilter = new SiteSettingFilter(this.args.settings);
constructor() {
super(...arguments);
this.filterChanged({ filter: "", onlyOverridden: false });
}
@action
filterChanged(filterData) {
this._debouncedOnChangeFilter(filterData);
}
get noResults() {
return isEmpty(this.visibleSettings) && !this.loading;
}
_debouncedOnChangeFilter(filterData) {
cancel(this.onChangeFilterHandler);
this.onChangeFilterHandler = discourseDebounce(
this,
this.filterSettings,
filterData,
100
);
}
filterSettings(filterData) {
this.args.onFilterChanged(filterData);
this.visibleSettings = this.siteSettingFilter.filterSettings(
filterData.filter,
{
includeAllCategory: false,
onlyOverridden: filterData.onlyOverridden,
}
)[0]?.siteSettings;
this.loading = false;
}
<template>
<AdminSiteSettingsFilterControls
@onChangeFilter={{this.filterChanged}}
@initialFilter={{@initialFilter}}
/>
<ConditionalLoadingSpinner @condition={{this.loading}}>
<section class="form-horizontal settings">
{{#each this.visibleSettings as |setting|}}
<SiteSetting @setting={{setting}} />
{{/each}}
{{#if this.noResults}}
{{i18n "admin.site_settings.no_results"}}
{{/if}}
</section>
</ConditionalLoadingSpinner>
</template>
}

View File

@ -3,8 +3,9 @@ import { concat, fn, hash } from "@ember/helper";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { LinkTo } from "@ember/routing";
import { inject as service } from "@ember/service";
import { service } from "@ember/service";
import DToggleSwitch from "discourse/components/d-toggle-switch";
import PluginOutlet from "discourse/components/plugin-outlet";
import { popupAjaxError } from "discourse/lib/ajax-error";
import icon from "discourse-common/helpers/d-icon";
import i18n from "discourse-common/helpers/i18n";
@ -14,6 +15,7 @@ import PluginCommitHash from "./plugin-commit-hash";
export default class AdminPluginsListItem extends Component {
@service session;
@service currentUser;
@service sidebarState;
@action
async togglePluginEnabled(plugin) {
@ -30,20 +32,43 @@ export default class AdminPluginsListItem extends Component {
}
}
get isAdminSearchFiltered() {
if (!this.sidebarState.filter) {
return false;
}
return this.args.plugin.nameTitleizedLower.match(this.sidebarState.filter);
}
get showPluginSettingsButton() {
return this.currentUser.admin && this.args.plugin.hasSettings;
}
get disablePluginSettingsButton() {
return (
this.showPluginSettingsButton && this.args.plugin.hasOnlyEnabledSetting
);
}
get settingsButtonTitle() {
if (this.disablePluginSettingsButton) {
return i18n("admin.plugins.settings_disabled");
}
return "";
}
<template>
<tr data-plugin-name={{@plugin.name}}>
<td class="admin-plugins-list__row">
<tr
data-plugin-name={{@plugin.name}}
class={{concat
"admin-plugins-list__row"
(if this.isAdminSearchFiltered "-admin-search-filtered")
}}
>
<td class="admin-plugins-list__name-details">
<div class="admin-plugins-list__name-with-badges">
<div class="admin-plugins-list__name">
{{#if @plugin.linkUrl}}
<a
href={{@plugin.linkUrl}}
rel="noopener noreferrer"
target="_blank"
>{{@plugin.nameTitleized}}</a>
{{else}}
{{@plugin.nameTitleized}}
{{/if}}
{{@plugin.nameTitleized}}
</div>
<div class="badges">
@ -53,6 +78,12 @@ export default class AdminPluginsListItem extends Component {
</span>
{{/if}}
</div>
<PluginOutlet
@name="admin-plugin-list-name-badge-after"
@connectorTagName="span"
@outletArgs={{hash plugin=@plugin}}
/>
</div>
<div class="admin-plugins-list__author">
{{@plugin.author}}
@ -66,41 +97,69 @@ export default class AdminPluginsListItem extends Component {
target="_blank"
>
{{i18n "admin.plugins.learn_more"}}
{{icon "external-link-alt"}}
</a>
{{/if}}
</div>
</td>
<td class="admin-plugins-list__version">
<div class="label">{{i18n "admin.plugins.version"}}</div>
{{@plugin.version}}<br />
<PluginCommitHash @plugin={{@plugin}} />
<PluginOutlet
@name="admin-plugin-list-item-version"
@outletArgs={{hash plugin=@plugin}}
>
<div class="label">{{i18n "admin.plugins.version"}}</div>
{{@plugin.version}}<br />
<PluginCommitHash @plugin={{@plugin}} />
</PluginOutlet>
</td>
<td class="admin-plugins-list__enabled">
<div class="label">{{i18n "admin.plugins.enabled"}}</div>
{{#if @plugin.enabledSetting}}
<DToggleSwitch
@state={{@plugin.enabled}}
{{on "click" (fn this.togglePluginEnabled @plugin)}}
/>
{{else}}
<DToggleSwitch @state={{@plugin.enabled}} disabled={{true}} />
{{/if}}
<PluginOutlet
@name="admin-plugin-list-item-enabled"
@outletArgs={{hash plugin=@plugin}}
>
<div class="label">{{i18n "admin.plugins.enabled"}}</div>
{{#if @plugin.enabledSetting}}
<DToggleSwitch
@state={{@plugin.enabled}}
{{on "click" (fn this.togglePluginEnabled @plugin)}}
/>
{{else}}
<DToggleSwitch @state={{@plugin.enabled}} disabled={{true}} />
{{/if}}
</PluginOutlet>
</td>
<td class="admin-plugins-list__settings">
{{#if this.currentUser.admin}}
{{#if @plugin.hasSettings}}
<LinkTo
class="btn-default btn btn-icon-text"
@route="adminSiteSettingsCategory"
@model={{@plugin.settingCategoryName}}
@query={{hash filter=(concat "plugin:" @plugin.name)}}
data-plugin-setting-button={{@plugin.name}}
>
{{icon "cog"}}
{{i18n "admin.plugins.change_settings_short"}}
</LinkTo>
<PluginOutlet
@name="admin-plugin-list-item-settings"
@outletArgs={{hash plugin=@plugin}}
>
{{#if this.showPluginSettingsButton}}
{{#if @plugin.useNewShowRoute}}
<LinkTo
class="btn btn-text btn-small"
@route="adminPlugins.show"
@model={{@plugin}}
@disabled={{this.disablePluginSettingsButton}}
title={{this.settingsButtonTitle}}
data-plugin-setting-button={{@plugin.name}}
>
{{i18n "admin.plugins.change_settings_short"}}
</LinkTo>
{{else}}
<LinkTo
class="btn btn-text btn-small"
@route="adminSiteSettingsCategory"
@model={{@plugin.settingCategoryName}}
@query={{hash filter=(concat "plugin:" @plugin.name)}}
@disabled={{this.disablePluginSettingsButton}}
title={{this.settingsButtonTitle}}
data-plugin-setting-button={{@plugin.name}}
>
{{i18n "admin.plugins.change_settings_short"}}
</LinkTo>
{{/if}}
{{/if}}
{{/if}}
</PluginOutlet>
</td>
</tr>
</template>

View File

@ -0,0 +1,92 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { Input } from "@ember/component";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import { LinkTo } from "@ember/routing";
import { service } from "@ember/service";
import { htmlSafe } from "@ember/template";
import ConditionalLoadingSpinner from "discourse/components/conditional-loading-spinner";
import { ajax } from "discourse/lib/ajax";
import dIcon from "discourse-common/helpers/d-icon";
import i18n from "discourse-common/helpers/i18n";
import { bind } from "discourse-common/utils/decorators";
export default class AdminReports extends Component {
@service siteSettings;
@tracked reports = null;
@tracked filter = "";
@tracked isLoading = false;
@bind
loadReports() {
this.isLoading = true;
ajax("/admin/reports")
.then((json) => {
this.reports = json.reports;
})
.finally(() => (this.isLoading = false));
}
get filteredReports() {
if (!this.reports) {
return [];
}
let filteredReports = this.reports;
if (this.filter) {
const lowerCaseFilter = this.filter.toLowerCase();
filteredReports = filteredReports.filter((report) => {
return (
(report.title || "").toLowerCase().includes(lowerCaseFilter) ||
(report.description || "").toLowerCase().includes(lowerCaseFilter)
);
});
}
const hiddenReports = (this.siteSettings.dashboard_hidden_reports || "")
.split("|")
.filter(Boolean);
filteredReports = filteredReports.filter(
(report) => !hiddenReports.includes(report.type)
);
return filteredReports;
}
<template>
<div {{didInsert this.loadReports}}>
<ConditionalLoadingSpinner @condition={{this.isLoading}}>
<div class="admin-reports-header">
<h2>{{i18n "admin.reports.title"}}</h2>
<Input
class="admin-reports-header__filter"
placeholder={{i18n "admin.filter_reports"}}
@value={{this.filter}}
/>
</div>
<div class="alert alert-info">
{{dIcon "book"}}
{{htmlSafe (i18n "admin.reports.meta_doc")}}
</div>
<ul class="admin-reports-list">
{{#each this.filteredReports as |report|}}
<li class="admin-reports-list__report">
<LinkTo @route="adminReports.show" @model={{report.type}}>
<h3
class="admin-reports-list__report-title"
>{{report.title}}</h3>
{{#if report.description}}
<p class="admin-reports-list__report-description">
{{report.description}}
</p>
{{/if}}
</LinkTo>
</li>
{{/each}}
</ul>
</ConditionalLoadingSpinner>
</div>
</template>
}

View File

@ -0,0 +1,104 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { Input } from "@ember/component";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import didUpdate from "@ember/render-modifiers/modifiers/did-update";
import DButton from "discourse/components/d-button";
import i18n from "discourse-common/helpers/i18n";
export default class AdminSiteSettingsFilterControls extends Component {
@tracked filter = this.args.initialFilter || "";
@tracked onlyOverridden = false;
@tracked isMenuOpen = false;
@action
clearFilter() {
this.filter = "";
this.onlyOverridden = false;
this.onChangeFilter();
}
@action
onChangeFilter() {
this.args.onChangeFilter({
filter: this.filter,
onlyOverridden: this.onlyOverridden,
});
}
@action
onChangeFilterInput(event) {
this.filter = event.target.value;
this.onChangeFilter();
}
@action
onToggleOverridden(event) {
this.onlyOverridden = event.target.checked;
this.onChangeFilter();
}
@action
runInitialFilter() {
if (this.args.initialFilter !== this.filter) {
this.filter = this.args.initialFilter;
}
this.onChangeFilter();
}
@action
toggleMenu() {
this.isMenuOpen = !this.isMenuOpen;
this.args.onToggleMenu();
}
<template>
<div
class="admin-controls admin-site-settings-filter-controls"
{{didInsert this.runInitialFilter}}
{{didUpdate this.runInitialFilter @initialFilter}}
>
<div class="controls">
<div class="inline-form">
{{#if @showMenu}}
<DButton
@action={{this.toggleMenu}}
@icon={{if this.isMenuOpen "times" "bars"}}
class="menu-toggle"
/>
{{/if}}
<input
{{on "input" this.onChangeFilterInput}}
id="setting-filter"
class="no-blur admin-site-settings-filter-controls__input"
placeholder={{i18n "type_to_filter"}}
autocomplete="off"
type="text"
value={{this.filter}}
/>
<DButton
@action={{this.clearFilter}}
@label="admin.site_settings.clear_filter"
id="clear-filter"
class="btn-default"
/>
</div>
</div>
<div class="search controls">
<label>
<Input
@type="checkbox"
@checked={{this.onlyOverridden}}
class="toggle-overridden"
id="setting-filter-toggle-overridden"
{{on "click" this.onToggleOverridden}}
/>
{{i18n "admin.settings.show_overriden"}}
</label>
</div>
</div>
</template>
}

View File

@ -52,7 +52,7 @@ export default class AdminThemeEditor extends Component {
if (fieldName && fieldName === "color_definitions") {
const example =
":root {\n" +
" --mytheme-tertiary-or-quaternary: #{dark-light-choose($tertiary, $quaternary)};\n" +
" --mytheme-tertiary-or-highlight: #{dark-light-choose($tertiary, $highlight)};\n" +
"}";
return I18n.t("admin.customize.theme.color_definitions.placeholder", {

View File

@ -4,7 +4,7 @@
<ComboBox
@content={{this.fieldTypes}}
@value={{this.buffered.field_type}}
@onChange={{action (mut this.buffered.field_type)}}
@onChange={{fn (mut this.buffered.field_type)}}
/>
</AdminFormRow>
@ -20,7 +20,7 @@
<Input
@value={{this.buffered.description}}
class="user-field-desc"
maxlength="255"
maxlength="1000"
/>
</AdminFormRow>
@ -30,29 +30,74 @@
</AdminFormRow>
{{/if}}
<AdminFormRow @wrapLabel="true" @type="checkbox">
<Input @type="checkbox" @checked={{this.buffered.editable}} />
<span>{{i18n "admin.user_fields.editable.title"}}</span>
<AdminFormRow @label="admin.user_fields.requirement.title">
<label class="optional">
<RadioButton
@value="optional"
@name="requirement"
@selection={{this.buffered.requirement}}
@onChange={{action "changeRequirementType"}}
/>
<span>{{i18n "admin.user_fields.requirement.optional.title"}}</span>
</label>
<label class="for_all_users">
<RadioButton
@value="for_all_users"
@name="requirement"
@selection={{this.buffered.requirement}}
@onChange={{action "changeRequirementType"}}
/>
<div class="label-text">
<span>{{i18n
"admin.user_fields.requirement.for_all_users.title"
}}</span>
<div class="description">{{i18n
"admin.user_fields.requirement.for_all_users.description"
}}</div>
</div>
</label>
<label class="on_signup">
<RadioButton
@value="on_signup"
@name="requirement"
@selection={{this.buffered.requirement}}
@onChange={{action "changeRequirementType"}}
/>
<div class="label-text">
<span>{{i18n "admin.user_fields.requirement.on_signup.title"}}</span>
<div class="description">{{i18n
"admin.user_fields.requirement.on_signup.description"
}}</div>
</div>
</label>
</AdminFormRow>
<AdminFormRow @wrapLabel="true" @type="checkbox">
<Input @type="checkbox" @checked={{this.buffered.required}} />
<span>{{i18n "admin.user_fields.required.title"}}</span>
</AdminFormRow>
<AdminFormRow @label="admin.user_fields.preferences">
<label>
<Input
@type="checkbox"
@checked={{this.buffered.editable}}
disabled={{this.editableDisabled}}
/>
<span>{{i18n "admin.user_fields.editable.title"}}</span>
</label>
<AdminFormRow @wrapLabel="true" @type="checkbox">
<Input @type="checkbox" @checked={{this.buffered.show_on_profile}} />
<span>{{i18n "admin.user_fields.show_on_profile.title"}}</span>
</AdminFormRow>
<label>
<Input @type="checkbox" @checked={{this.buffered.show_on_profile}} />
<span>{{i18n "admin.user_fields.show_on_profile.title"}}</span>
</label>
<AdminFormRow @wrapLabel="true" @type="checkbox">
<Input @type="checkbox" @checked={{this.buffered.show_on_user_card}} />
<span>{{i18n "admin.user_fields.show_on_user_card.title"}}</span>
</AdminFormRow>
<label>
<Input @type="checkbox" @checked={{this.buffered.show_on_user_card}} />
<span>{{i18n "admin.user_fields.show_on_user_card.title"}}</span>
</label>
<AdminFormRow @wrapLabel="true" @type="checkbox">
<Input @type="checkbox" @checked={{this.buffered.searchable}} />
<span>{{i18n "admin.user_fields.searchable.title"}}</span>
<label>
<Input @type="checkbox" @checked={{this.buffered.searchable}} />
<span>{{i18n "admin.user_fields.searchable.title"}}</span>
</label>
</AdminFormRow>
<PluginOutlet

View File

@ -1,8 +1,9 @@
import Component from "@ember/component";
import { action } from "@ember/object";
import { schedule } from "@ember/runloop";
import { inject as service } from "@ember/service";
import { service } from "@ember/service";
import { isEmpty } from "@ember/utils";
import { Promise } from "rsvp";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { i18n, propertyEqual } from "discourse/lib/computed";
import { bufferedProperty } from "discourse/mixins/buffered-content";
@ -12,6 +13,7 @@ import UserField from "admin/models/user-field";
export default Component.extend(bufferedProperty("userField"), {
adminCustomUserFields: service(),
dialog: service(),
tagName: "",
isEditing: false,
@ -44,16 +46,13 @@ export default Component.extend(bufferedProperty("userField"), {
},
@discourseComputed(
"userField.{editable,required,show_on_profile,show_on_user_card,searchable}"
"userField.{editable,show_on_profile,show_on_user_card,searchable}"
)
flags(userField) {
const ret = [];
if (userField.editable) {
ret.push(I18n.t("admin.user_fields.editable.enabled"));
}
if (userField.required) {
ret.push(I18n.t("admin.user_fields.required.enabled"));
}
if (userField.show_on_profile) {
ret.push(I18n.t("admin.user_fields.show_on_profile.enabled"));
}
@ -67,14 +66,35 @@ export default Component.extend(bufferedProperty("userField"), {
return ret.join(", ");
},
@discourseComputed("buffered.requirement")
editableDisabled(requirement) {
return requirement === "for_all_users";
},
@action
save() {
changeRequirementType(requirement) {
this.buffered.set("requirement", requirement);
this.buffered.set("editable", requirement === "for_all_users");
},
async _confirmChanges() {
return new Promise((resolve) => {
this.dialog.yesNoConfirm({
message: I18n.t("admin.user_fields.requirement.confirmation"),
didCancel: () => resolve(false),
didConfirm: () => resolve(true),
});
});
},
@action
async save() {
const attrs = this.buffered.getProperties(
"name",
"description",
"field_type",
"editable",
"required",
"requirement",
"show_on_profile",
"show_on_user_card",
"searchable",
@ -82,6 +102,16 @@ export default Component.extend(bufferedProperty("userField"), {
...this.adminCustomUserFields.additionalProperties
);
let confirm = true;
if (attrs.requirement === "for_all_users") {
confirm = await this._confirmChanges();
}
if (!confirm) {
return;
}
return this.userField
.save(attrs)
.then(() => {

View File

@ -17,4 +17,7 @@
<span class="case-sensitive">{{i18n
"admin.watched_words.case_sensitive"
}}</span>
{{/if}}
{{#if this.isHtml}}
<span class="html">{{i18n "admin.watched_words.html"}}</span>
{{/if}}

View File

@ -1,7 +1,7 @@
import Component from "@ember/component";
import { action } from "@ember/object";
import { alias, equal } from "@ember/object/computed";
import { inject as service } from "@ember/service";
import { service } from "@ember/service";
import { classNames } from "@ember-decorators/component";
import discourseComputed from "discourse-common/utils/decorators";
import I18n from "discourse-i18n";
@ -11,12 +11,10 @@ export default class AdminWatchedWord extends Component {
@service dialog;
@equal("actionKey", "replace") isReplace;
@equal("actionKey", "tag") isTag;
@equal("actionKey", "link") isLink;
@alias("word.case_sensitive") isCaseSensitive;
@alias("word.html") isHtml;
@discourseComputed("word.replacement")
tags(replacement) {

View File

@ -1,5 +0,0 @@
import Component from "@ember/component";
import { tagName } from "@ember-decorators/component";
@tagName("")
export default class CancelLink extends Component {}

View File

@ -1,7 +1,6 @@
import { and, not } from "truth-helpers";
import CookText from "discourse/components/cook-text";
import i18n from "discourse-common/helpers/i18n";
import and from "truth-helpers/helpers/and";
import not from "truth-helpers/helpers/not";
const DashboardNewFeatureItem = <template>
<div class="admin-new-feature-item">
@ -15,6 +14,11 @@ const DashboardNewFeatureItem = <template>
<h3>
{{@item.title}}
</h3>
{{#if @item.discourse_version}}
<div class="admin-new-feature-item__new-feature-version">
{{@item.discourse_version}}
</div>
{{/if}}
</div>
{{#if @item.screenshot_url}}

View File

@ -1,21 +1,42 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import { service } from "@ember/service";
import { htmlSafe } from "@ember/template";
import { ajax } from "discourse/lib/ajax";
import i18n from "discourse-common/helpers/i18n";
import { bind } from "discourse-common/utils/decorators";
import AdminConfigAreaCard from "admin/components/admin-config-area-card";
import DashboardNewFeatureItem from "admin/components/dashboard-new-feature-item";
export default class DashboardNewFeatures extends Component {
@service currentUser;
@tracked newFeatures = null;
@tracked groupedNewFeatures = null;
@tracked isLoaded = false;
@bind
loadNewFeatures() {
ajax("/admin/dashboard/whats-new.json")
.then((json) => {
this.newFeatures = json.new_features;
const items = json.new_features.reduce((acc, feature) => {
const key = moment(feature.released_at || feature.created_at).format(
"YYYY-MM"
);
acc[key] = acc[key] || [];
acc[key].push(feature);
return acc;
}, {});
this.groupedNewFeatures = Object.keys(items).map((date) => {
return {
date: moment
.tz(date, this.currentUser.user_option.timezone)
.format("MMMM YYYY"),
features: items[date],
};
});
this.isLoaded = true;
})
.finally(() => {
@ -24,10 +45,14 @@ export default class DashboardNewFeatures extends Component {
}
<template>
<div class="section-body" {{didInsert this.loadNewFeatures}}>
{{#if this.newFeatures}}
{{#each this.newFeatures as |feature|}}
<DashboardNewFeatureItem @item={{feature}} />
<div class="admin-config-area" {{didInsert this.loadNewFeatures}}>
{{#if this.groupedNewFeatures}}
{{#each this.groupedNewFeatures as |groupedFeatures|}}
<AdminConfigAreaCard @translatedHeading={{groupedFeatures.date}}>
{{#each groupedFeatures.features as |feature|}}
<DashboardNewFeatureItem @item={{feature}} />
{{/each}}
</AdminConfigAreaCard>
{{/each}}
{{else if this.isLoaded}}
{{htmlSafe

View File

@ -1,6 +1,6 @@
import Component from "@glimmer/component";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
import { service } from "@ember/service";
import DButton from "discourse/components/d-button";
import PeriodChooser from "select-kit/components/period-chooser";
import CustomDateRangeModal from "../components/modal/custom-date-range";

View File

@ -1,7 +1,7 @@
import Component from "@ember/component";
import { action, computed } from "@ember/object";
import { reads } from "@ember/object/computed";
import { inject as service } from "@ember/service";
import { service } from "@ember/service";
import discourseComputed from "discourse-common/utils/decorators";
import I18n from "discourse-i18n";

View File

@ -21,11 +21,30 @@
<td class="editing-input">
<div class="label">{{i18n "admin.embedding.category"}}</div>
<CategoryChooser
@value={{this.categoryId}}
@onChange={{action (mut this.categoryId)}}
@value={{this.category.id}}
@onChangeCategory={{fn (mut this.category)}}
class="small"
/>
</td>
<td class="editing-input">
<div class="label">{{i18n "admin.embedding.tags"}}</div>
<TagChooser
@tags={{this.tags}}
@everyTag={{true}}
@excludeSynonyms={{true}}
@unlimitedTagCount={{true}}
@onChange={{fn (mut this.tags)}}
@options={{hash filterPlaceholder="category.tags_placeholder"}}
/>
</td>
<td class="editing-input">
<div class="label">{{i18n "admin.embedding.user"}}</div>
<UserChooser
@value={{this.user}}
@onChange={{action "onUserChange"}}
@options={{hash maximum=1 excludeCurrentUser=false}}
/>
</td>
<td class="editing-controls">
<DButton
@icon="check"
@ -53,7 +72,13 @@
</td>
<td>
<div class="label">{{i18n "admin.embedding.category"}}</div>
{{category-badge this.host.category allowUncategorized=true}}
{{category-badge this.category allowUncategorized=true}}
</td>
<td>
{{this.tags}}
</td>
<td>
{{this.user}}
</td>
<td class="controls">
<DButton @icon="pencil-alt" @action={{this.edit}} />

View File

@ -1,7 +1,7 @@
import Component from "@ember/component";
import { action } from "@ember/object";
import { or } from "@ember/object/computed";
import { inject as service } from "@ember/service";
import { service } from "@ember/service";
import { isEmpty } from "@ember/utils";
import { tagName } from "@ember-decorators/component";
import { popupAjaxError } from "discourse/lib/ajax-error";
@ -18,6 +18,8 @@ export default class EmbeddableHost extends Component.extend(
editToggled = false;
categoryId = null;
category = null;
tags = null;
user = null;
@or("host.isNew", "editToggled") editing;
@ -28,7 +30,9 @@ export default class EmbeddableHost extends Component.extend(
const categoryId = host.category_id || this.site.uncategorized_category_id;
const category = Category.findById(categoryId);
host.set("category", category);
this.set("category", category);
this.set("tags", host.tags || []);
this.set("user", host.user);
}
@discourseComputed("buffered.host", "host.isSaving")
@ -38,10 +42,12 @@ export default class EmbeddableHost extends Component.extend(
@action
edit() {
this.set("categoryId", this.get("host.category.id"));
this.set("editToggled", true);
}
@action
onUserChange(user) {
this.set("user", user);
}
@action
save() {
if (this.cantSave) {
@ -53,14 +59,16 @@ export default class EmbeddableHost extends Component.extend(
"allowed_paths",
"class_name"
);
props.category_id = this.categoryId;
props.category_id = this.category.id;
props.tags = this.tags;
props.user =
Array.isArray(this.user) && this.user.length > 0 ? this.user[0] : null;
const host = this.host;
host
.save(props)
.then(() => {
host.set("category", Category.findById(this.categoryId));
this.set("editToggled", false);
})
.catch(popupAjaxError);

View File

@ -1,7 +1,7 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
import { service } from "@ember/service";
import { popupAjaxError } from "discourse/lib/ajax-error";
import I18n from "discourse-i18n";
import FormTemplateValidationOptionsModal from "admin/components/modal/form-template-validation-options";

View File

@ -1,6 +1,6 @@
import Component from "@glimmer/component";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
import { service } from "@ember/service";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import I18n from "discourse-i18n";

View File

@ -1,6 +1,6 @@
import Component from "@ember/component";
import EmberObject, { action } from "@ember/object";
import { inject as service } from "@ember/service";
import { service } from "@ember/service";
import { classNames } from "@ember-decorators/component";
import $ from "jquery";
import { ajax } from "discourse/lib/ajax";

View File

@ -42,7 +42,7 @@ export default class BadgePreview extends Component {
}
get queryPlanHtml() {
let output = `<pre class="badge-query-plan">`;
let output = `<pre>`;
this.args.model.badge.query_plan.forEach((linehash) => {
output += escapeExpression(linehash["QUERY PLAN"]);
output += "<br>";

View File

@ -7,7 +7,7 @@
<ComboBox
@content={{@model.baseColorSchemes}}
@value={{this.selectedBaseThemeId}}
@onChange={{action (mut this.selectedBaseThemeId)}}
@onChange={{fn (mut this.selectedBaseThemeId)}}
@valueProperty="base_scheme_id"
/>
</:body>

View File

@ -2,7 +2,7 @@ import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { A } from "@ember/array";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
import { service } from "@ember/service";
import { TrackedArray } from "@ember-compat/tracked-built-ins";
import { ajax } from "discourse/lib/ajax";
import I18n from "discourse-i18n";

View File

@ -31,138 +31,148 @@
</div>
{{/unless}}
<div class="install-theme-content">
{{#if this.popular}}
<div class="popular-theme-items">
{{#each this.themes as |theme|}}
<div class="popular-theme-item" data-name={{theme.name}}>
<div class="popular-theme-name">
<a
href={{theme.meta_url}}
rel="noopener noreferrer"
target="_blank"
>
{{#if theme.component}}
{{d-icon
"puzzle-piece"
title="admin.customize.theme.component"
}}
<ConditionalLoadingSection
@isLoading={{this.loading}}
@title={{i18n "admin.customize.theme.installing_message"}}
>
{{#if this.popular}}
<div class="popular-theme-items">
{{#each this.themes as |theme|}}
<div class="popular-theme-item" data-name={{theme.name}}>
<div class="popular-theme-name">
<a
href={{theme.meta_url}}
rel="noopener noreferrer"
target="_blank"
>
{{#if theme.component}}
{{d-icon
"puzzle-piece"
title="admin.customize.theme.component"
}}
{{/if}}
{{theme.name}}
</a>
<div class="popular-theme-description">
{{theme.description}}
</div>
</div>
<div class="popular-theme-buttons">
{{#if theme.installed}}
<span>{{i18n "admin.customize.theme.installed"}}</span>
{{else}}
<DButton
class="btn-small"
@label="admin.customize.theme.install"
@disabled={{this.installDisabled}}
@icon="upload"
@action={{fn this.installThemeFromList theme.value}}
/>
{{#if theme.preview}}
<a
href={{theme.preview}}
rel="noopener noreferrer"
target="_blank"
>
{{d-icon "desktop"}}
{{i18n "admin.customize.theme.preview"}}
</a>
{{/if}}
{{/if}}
{{theme.name}}
</a>
<div class="popular-theme-description">
{{theme.description}}
</div>
</div>
<div class="popular-theme-buttons">
{{#if theme.installed}}
<span>{{i18n "admin.customize.theme.installed"}}</span>
{{else}}
<DButton
class="btn-small"
@label="admin.customize.theme.install"
@disabled={{this.installDisabled}}
@icon="upload"
@action={{fn this.installThemeFromList theme.value}}
/>
{{#if theme.preview}}
<a
href={{theme.preview}}
rel="noopener noreferrer"
target="_blank"
>
{{d-icon "desktop"}}
{{i18n "admin.customize.theme.preview"}}
</a>
{{/if}}
{{/if}}
{{/each}}
</div>
{{/if}}
{{#if this.local}}
<div class="inputs">
<input
{{on "change" this.uploadLocaleFile}}
type="file"
id="file-input"
accept=".dcstyle.json,application/json,.tar.gz,application/x-gzip,.zip,application/zip"
/>
<br />
<span class="description">
{{i18n "admin.customize.theme.import_file_tip"}}
</span>
</div>
{{/if}}
{{#if this.remote}}
<div class="inputs">
<div class="repo">
<div class="label">
{{i18n "admin.customize.theme.import_web_tip"}}
</div>
<Input
@value={{this.uploadUrl}}
placeholder={{this.urlPlaceholder}}
/>
</div>
{{/each}}
</div>
{{/if}}
{{#if this.local}}
<div class="inputs">
<input
{{on "change" this.uploadLocaleFile}}
type="file"
id="file-input"
accept=".dcstyle.json,application/json,.tar.gz,application/x-gzip,.zip,application/zip"
/>
<br />
<span class="description">
{{i18n "admin.customize.theme.import_file_tip"}}
</span>
</div>
{{/if}}
{{#if this.remote}}
<div class="inputs">
<div class="repo">
<div class="label">
{{i18n "admin.customize.theme.import_web_tip"}}
</div>
<Input
@value={{this.uploadUrl}}
placeholder={{this.urlPlaceholder}}
<DButton
class="btn-small advanced-repo"
@action={{this.toggleAdvanced}}
@label="admin.customize.theme.import_web_advanced"
/>
{{#if this.advancedVisible}}
<div class="branch">
<div class="label">
{{i18n "admin.customize.theme.remote_branch"}}
</div>
<Input @value={{this.branch}} placeholder="main" />
</div>
{{/if}}
{{#if this.showPublicKey}}
<div class="public-key">
<div class="label">
{{i18n "admin.customize.theme.public_key"}}
</div>
<div class="public-key-text-wrapper">
<Textarea
class="public-key-value"
readonly={{true}}
@value={{this.publicKey}}
{{did-insert this.generatePublicKey}}
/>
<CopyButton @selector="textarea.public-key-value" />
</div>
</div>
{{/if}}
</div>
{{/if}}
{{#if this.create}}
<div class="inputs">
<div class="label">{{i18n
"admin.customize.theme.create_name"
}}</div>
<Input @value={{this.name}} placeholder={{this.placeholder}} />
<div class="label">{{i18n
"admin.customize.theme.create_type"
}}</div>
<ComboBox
@valueProperty="value"
@content={{this.createTypes}}
@value={{this.selectedType}}
@onChange={{this.updateSelectedType}}
/>
</div>
<DButton
class="btn-small advanced-repo"
@action={{this.toggleAdvanced}}
@label="admin.customize.theme.import_web_advanced"
/>
{{#if this.advancedVisible}}
<div class="branch">
<div class="label">
{{i18n "admin.customize.theme.remote_branch"}}
</div>
<Input @value={{this.branch}} placeholder="main" />
{{/if}}
{{#if this.directRepoInstall}}
<div class="repo">
<div class="label">
{{html-safe
(i18n
"admin.customize.theme.direct_install_tip"
name=this.uploadName
)
}}
</div>
{{/if}}
{{#if this.showPublicKey}}
<div class="public-key">
<div class="label">
{{i18n "admin.customize.theme.public_key"}}
</div>
<div class="public-key-text-wrapper">
<Textarea
class="public-key-value"
readonly={{true}}
@value={{this.publicKey}}
{{did-insert this.generatePublicKey}}
/>
<CopyButton @selector="textarea.public-key-value" />
</div>
</div>
{{/if}}
</div>
{{/if}}
{{#if this.create}}
<div class="inputs">
<div class="label">{{i18n "admin.customize.theme.create_name"}}</div>
<Input @value={{this.name}} placeholder={{this.placeholder}} />
<div class="label">{{i18n "admin.customize.theme.create_type"}}</div>
<ComboBox
@valueProperty="value"
@content={{this.createTypes}}
@value={{this.selectedType}}
@onChange={{this.updateSelectedType}}
/>
</div>
{{/if}}
{{#if this.directRepoInstall}}
<div class="repo">
<div class="label">
{{html-safe
(i18n
"admin.customize.theme.direct_install_tip" name=this.uploadName
)
}}
<pre><code>{{this.uploadUrl}}</code></pre>
</div>
<pre><code>{{this.uploadUrl}}</code></pre>
</div>
{{/if}}
{{/if}}
</ConditionalLoadingSection>
</div>
</:body>
<:footer>

View File

@ -1,7 +1,7 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
import { service } from "@ember/service";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { POPULAR_THEMES } from "discourse-common/lib/popular-themes";

View File

@ -1,6 +1,6 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { inject as service } from "@ember/service";
import { service } from "@ember/service";
import DiscourseURL from "discourse/lib/url";
import { bind } from "discourse-common/utils/decorators";
import I18n from "discourse-i18n";

View File

@ -12,7 +12,7 @@
@label="admin.user.suspend_duration"
@clearable={{false}}
@input={{this.penalizeUntil}}
@onChangeInput={{action (mut this.penalizeUntil)}}
@onChangeInput={{fn (mut this.penalizeUntil)}}
class="suspend-until"
/>
{{else if (eq @model.penaltyType "silence")}}
@ -20,7 +20,7 @@
@label="admin.user.silence_duration"
@clearable={{false}}
@input={{this.penalizeUntil}}
@onChangeInput={{action (mut this.penalizeUntil)}}
@onChangeInput={{fn (mut this.penalizeUntil)}}
class="silence-until"
/>
{{/if}}

View File

@ -1,7 +1,7 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
import { service } from "@ember/service";
import { isEmpty } from "@ember/utils";
import { extractError } from "discourse/lib/ajax-error";
import I18n from "discourse-i18n";

View File

@ -1,7 +1,7 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
import { service } from "@ember/service";
import { ajax } from "discourse/lib/ajax";
import I18n from "discourse-i18n";

View File

@ -1,7 +1,7 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
import { service } from "@ember/service";
import I18n from "discourse-i18n";
export default class StartBackup extends Component {

View File

@ -8,6 +8,9 @@
{{#each this.images as |image|}}
<a href class="selectable-avatar" {{on "click" (fn this.remove image)}}>
{{bound-avatar-template image "huge"}}
<span class="selectable-avatar__remove">{{d-icon
"times-circle"
}}</span>
</a>
{{else}}
<p>{{i18n "admin.site_settings.uploaded_image_list.empty"}}</p>

View File

@ -9,7 +9,8 @@ export default class UploadedImageList extends Component {
: [];
@action
remove(url) {
remove(url, event) {
event.preventDefault();
this.images.removeObject(url);
}

View File

@ -1,9 +1,5 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import {
createWatchedWordRegExp,
toWatchedWord,
} from "discourse-common/utils/watched-words";
export default class WatchedWordTest extends Component {
@tracked value;
@ -31,7 +27,10 @@ export default class WatchedWordTest extends Component {
if (this.isReplace || this.isLink) {
const matches = [];
this.args.model.watchedWord.words.forEach((word) => {
const regexp = createWatchedWordRegExp(word);
const regexp = new RegExp(
word.regexp,
word.case_sensitive ? "gu" : "gui"
);
let match;
while ((match = regexp.exec(this.value)) !== null) {
@ -42,38 +41,44 @@ export default class WatchedWordTest extends Component {
}
});
return matches;
} else if (this.isTag) {
const matches = {};
}
if (this.isTag) {
const matches = new Map();
this.args.model.watchedWord.words.forEach((word) => {
const regexp = createWatchedWordRegExp(word);
const regexp = new RegExp(
word.regexp,
word.case_sensitive ? "gu" : "gui"
);
let match;
while ((match = regexp.exec(this.value)) !== null) {
if (!matches[match[1]]) {
matches[match[1]] = new Set();
if (!matches.has(match[1])) {
matches.set(match[1], new Set());
}
let tags = matches[match[1]];
word.replacement.split(",").forEach((tag) => {
tags.add(tag);
});
const tags = matches.get(match[1]);
word.replacement.split(",").forEach((tag) => tags.add(tag));
}
});
return Object.entries(matches).map((entry) => ({
match: entry[0],
tags: Array.from(entry[1]),
return Array.from(matches, ([match, tagsSet]) => ({
match,
tags: Array.from(tagsSet),
}));
} else {
let matches = [];
this.args.model.watchedWord.compiledRegularExpression.forEach(
(regexp) => {
const wordRegexp = createWatchedWordRegExp(toWatchedWord(regexp));
matches.push(...(this.value.match(wordRegexp) || []));
}
}
let matches = [];
this.args.model.watchedWord.compiledRegularExpression.forEach((entry) => {
const [regexp, options] = Object.entries(entry)[0];
const wordRegexp = new RegExp(
regexp,
options.case_sensitive ? "gu" : "gui"
);
return matches;
}
matches.push(...(this.value.match(wordRegexp) || []));
});
return matches;
}
}

View File

@ -14,7 +14,7 @@
<ComboBox
@content={{this.permalinkTypes}}
@value={{this.permalinkType}}
@onChange={{action (mut this.permalinkType)}}
@onChange={{fn (mut this.permalinkType)}}
class="permalink-type"
/>

View File

@ -1,7 +1,7 @@
import Component from "@ember/component";
import { action } from "@ember/object";
import { schedule } from "@ember/runloop";
import { inject as service } from "@ember/service";
import { service } from "@ember/service";
import { tagName } from "@ember-decorators/component";
import { fmt } from "discourse/lib/computed";
import discourseComputed, { bind } from "discourse-common/utils/decorators";
@ -28,6 +28,7 @@ export default class PermalinkForm extends Component {
{ id: "category_id", name: I18n.t("admin.permalink.category_id") },
{ id: "tag_name", name: I18n.t("admin.permalink.tag_name") },
{ id: "external_url", name: I18n.t("admin.permalink.external_url") },
{ id: "user_id", name: I18n.t("admin.permalink.user_id") },
];
}

View File

@ -0,0 +1,19 @@
import Component from "@glimmer/component";
export default class PluginCommitHash extends Component {
get shortCommitHash() {
return this.args.plugin.commitHash?.slice(0, 7);
}
<template>
{{#if @plugin.commitHash}}
<a
href={{@plugin.commitUrl}}
target="_blank"
rel="noopener noreferrer"
class="current commit-hash"
title={{@plugin.commitHash}}
>{{this.shortCommitHash}}</a>
{{/if}}
</template>
}

View File

@ -1,9 +0,0 @@
{{#if this.commitHash}}
<a
href={{@plugin.commitUrl}}
target="_blank"
rel="noopener noreferrer"
class="current commit-hash"
title={{this.commitHash}}
>{{this.shortCommitHash}}</a>
{{/if}}

View File

@ -1,11 +0,0 @@
import Component from "@glimmer/component";
export default class PluginCommitHash extends Component {
get shortCommitHash() {
return this.commitHash?.slice(0, 7);
}
get commitHash() {
return this.args.plugin.commitHash;
}
}

View File

@ -5,6 +5,7 @@
@onChange={{this.onChange}}
@options={{hash
allowAny=this.filter.allow_any
autoInsertNoneItem=this.filter.auto_insert_none_item
filterable=true
none="admin.dashboard.reports.groups"
}}

View File

@ -4,6 +4,7 @@
@onChange={{this.onChange}}
@options={{hash
allowAny=this.filter.allow_any
autoInsertNoneItem=this.filter.auto_insert_none_item
filterable=true
none="admin.dashboard.report_filter_any"
}}

View File

@ -0,0 +1,319 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { fn } from "@ember/helper";
import { action } from "@ember/object";
import { service } from "@ember/service";
import { gt } from "truth-helpers";
import DButton from "discourse/components/d-button";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { cloneJSON } from "discourse-common/lib/object";
import I18n from "discourse-i18n";
import Tree from "admin/components/schema-theme-setting/editor/tree";
import FieldInput from "admin/components/schema-theme-setting/field";
export default class SchemaThemeSettingNewEditor extends Component {
@service router;
@tracked history = [];
@tracked activeIndex = 0;
@tracked activeDataPaths = [];
@tracked activeSchemaPaths = [];
@tracked saveButtonDisabled = false;
@tracked validationErrorMessage;
inputFieldObserver = new Map();
data = cloneJSON(this.args.setting.value);
schema = this.args.setting.objects_schema;
@action
onChildClick(index, propertyName, parentNodeIndex) {
this.history.pushObject({
dataPaths: [...this.activeDataPaths],
schemaPaths: [...this.activeSchemaPaths],
index: this.activeIndex,
});
this.activeIndex = index;
this.activeDataPaths.pushObjects([parentNodeIndex, propertyName]);
this.activeSchemaPaths.pushObject(propertyName);
this.inputFieldObserver.clear();
}
@action
updateIndex(index) {
this.activeIndex = index;
}
@action
generateSchemaTitle(object, schema, index) {
let title;
if (schema.properties[schema.identifier]?.type === "categories") {
title = this.activeData[index][schema.identifier]
?.map((categoryId) => {
return this.args.setting.metadata.categories[categoryId].name;
})
.join(", ");
} else {
title = object[schema.identifier];
}
return title || `${schema.name} ${index + 1}`;
}
get backButtonText() {
if (this.history.length === 0) {
return;
}
const lastHistory = this.history[this.history.length - 1];
return I18n.t("admin.customize.theme.schema.back_button", {
name: this.generateSchemaTitle(
this.#resolveDataFromPaths(lastHistory.dataPaths)[lastHistory.index],
this.#resolveSchemaFromPaths(lastHistory.schemaPaths),
lastHistory.index
),
});
}
get activeData() {
return this.#resolveDataFromPaths(this.activeDataPaths);
}
#resolveDataFromPaths(paths) {
if (paths.length === 0) {
return this.data;
}
let data = this.data;
paths.forEach((path) => {
data = data[path];
});
return data;
}
get activeSchema() {
return this.#resolveSchemaFromPaths(this.activeSchemaPaths);
}
#resolveSchemaFromPaths(paths) {
if (paths.length === 0) {
return this.schema;
}
let schema = this.schema;
paths.forEach((path) => {
schema = schema.properties[path].schema;
});
return schema;
}
@action
registerInputFieldObserver(index, callback) {
this.inputFieldObserver[index] = callback;
}
@action
unregisterInputFieldObserver(index) {
delete this.inputFieldObserver[index];
}
descriptions(fieldName, key) {
// The `property_descriptions` metadata is an object with keys in the following format as an example:
//
// {
// some_property.description: <some description>,
// some_property.label: <some label>,
// some_objects_property.some_other_property.description: <some description>,
// some_objects_property.some_other_property.label: <some label>,
// }
const descriptions = this.args.setting.metadata?.property_descriptions;
if (!descriptions) {
return;
}
if (this.activeSchemaPaths.length > 0) {
key = `${this.activeSchemaPaths.join(".")}.${fieldName}.${key}`;
} else {
key = `${fieldName}.${key}`;
}
return descriptions[key];
}
fieldLabel(fieldName) {
return this.descriptions(fieldName, "label") || fieldName;
}
fieldDescription(fieldName) {
return this.descriptions(fieldName, "description");
}
get fields() {
const list = [];
const activeObject = this.activeData[this.activeIndex];
if (activeObject) {
for (const [name, spec] of Object.entries(this.activeSchema.properties)) {
if (spec.type === "objects") {
continue;
}
list.push({
name,
spec,
value: activeObject[name],
description: this.fieldDescription(name),
label: this.fieldLabel(name),
});
}
}
return list;
}
@action
clickBack() {
const {
dataPaths: lastDataPaths,
schemaPaths: lastSchemaPaths,
index: lastIndex,
} = this.history.popObject();
this.activeDataPaths = lastDataPaths;
this.activeSchemaPaths = lastSchemaPaths;
this.activeIndex = lastIndex;
this.inputFieldObserver.clear();
}
@action
addChildItem(propertyName, parentNodeIndex) {
this.activeData[parentNodeIndex][propertyName].pushObject({});
this.onChildClick(
this.activeData[parentNodeIndex][propertyName].length - 1,
propertyName,
parentNodeIndex
);
}
@action
addItem() {
this.activeData.pushObject({});
this.activeIndex = this.activeData.length - 1;
}
@action
removeItem() {
this.activeData.removeAt(this.activeIndex);
if (this.activeData.length > 0) {
this.activeIndex = Math.max(this.activeIndex - 1, 0);
} else if (this.history.length > 0) {
this.clickBack();
} else {
this.activeIndex = 0;
}
}
@action
inputFieldChanged(field, newVal) {
this.activeData[this.activeIndex][field.name] = newVal;
if (field.name === this.activeSchema.identifier) {
this.inputFieldObserver[this.activeIndex]();
}
}
@action
saveChanges() {
this.saveButtonDisabled = true;
this.args.setting
.updateSetting(this.args.themeId, this.data)
.then((result) => {
this.args.setting.set("value", result[this.args.setting.setting]);
this.router.transitionTo(
"adminCustomizeThemes.show",
this.args.themeId
);
})
.catch((e) => {
if (e.jqXHR.responseJSON && e.jqXHR.responseJSON.errors) {
this.validationErrorMessage = e.jqXHR.responseJSON.errors[0];
} else {
popupAjaxError(e);
}
})
.finally(() => (this.saveButtonDisabled = false));
}
<template>
<div class="schema-theme-setting-editor">
{{#if this.validationErrorMessage}}
<div class="schema-theme-setting-editor__errors">
<div class="alert alert-error">
{{this.validationErrorMessage}}
</div>
</div>
{{/if}}
<div class="schema-theme-setting-editor__wrapper">
<div class="schema-theme-setting-editor__navigation">
<Tree
@data={{this.activeData}}
@schema={{this.activeSchema}}
@onChildClick={{this.onChildClick}}
@clickBack={{this.clickBack}}
@backButtonText={{this.backButtonText}}
@activeIndex={{this.activeIndex}}
@updateIndex={{this.updateIndex}}
@addItem={{this.addItem}}
@addChildItem={{this.addChildItem}}
@generateSchemaTitle={{this.generateSchemaTitle}}
@registerInputFieldObserver={{this.registerInputFieldObserver}}
@unregisterInputFieldObserver={{this.unregisterInputFieldObserver}}
/>
<div class="schema-theme-setting-editor__footer">
<DButton
@disabled={{this.saveButtonDisabled}}
@action={{this.saveChanges}}
@label="save"
class="btn-primary"
/>
</div>
</div>
<div class="schema-theme-setting-editor__fields">
{{#each this.fields as |field|}}
<FieldInput
@name={{field.name}}
@value={{field.value}}
@spec={{field.spec}}
@onValueChange={{fn this.inputFieldChanged field}}
@description={{field.description}}
@label={{field.label}}
@setting={{@setting}}
/>
{{/each}}
{{#if (gt this.fields.length 0)}}
<DButton
@action={{this.removeItem}}
@icon="trash-alt"
class="btn-danger schema-theme-setting-editor__remove-btn"
/>
{{/if}}
</div>
</div>
</div>
</template>
}

View File

@ -0,0 +1,16 @@
import { on } from "@ember/modifier";
import dIcon from "discourse-common/helpers/d-icon";
<template>
<li
role="link"
class="schema-theme-setting-editor__tree-node --child"
...attributes
{{on "click" @onChildClick}}
>
<div class="schema-theme-setting-editor__tree-node-text">
<span>{{@generateSchemaTitle @object @schema @index}}</span>
{{dIcon "chevron-right"}}
</div>
</li>
</template>

View File

@ -0,0 +1,63 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { fn } from "@ember/helper";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import DButton from "discourse/components/d-button";
import dIcon from "discourse-common/helpers/d-icon";
import ChildTreeNode from "admin/components/schema-theme-setting/editor/child-tree-node";
export default class SchemaThemeSettingNewEditorChildTree extends Component {
@tracked expanded = true;
@action
toggleVisibility() {
this.expanded = !this.expanded;
}
@action
onChildClick(index) {
return this.args.onChildClick(
index,
this.args.name,
this.args.parentNodeIndex,
this.args.parentNodeText
);
}
<template>
<div
class="schema-theme-setting-editor__tree-node --heading"
role="button"
{{on "click" this.toggleVisibility}}
>
{{@name}}
{{dIcon (if this.expanded "chevron-down" "chevron-right")}}
</div>
{{#if this.expanded}}
<ul>
{{#each @objects as |object index|}}
<ChildTreeNode
@index={{index}}
@object={{object}}
@onChildClick={{fn this.onChildClick index}}
@schema={{@schema}}
@generateSchemaTitle={{@generateSchemaTitle}}
data-test-parent-index={{@parentNodeIndex}}
/>
{{/each}}
<li class="schema-theme-setting-editor__tree-node --child --add-button">
<DButton
@action={{fn @addChildItem @name @parentNodeIndex}}
@translatedLabel={{@schema.name}}
@icon="plus"
class="btn-transparent schema-theme-setting-editor__tree-add-button --child"
data-test-parent-index={{@parentNodeIndex}}
/>
</li>
</ul>
{{/if}}
</template>
}

View File

@ -0,0 +1,90 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { fn, get } from "@ember/helper";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import { gt } from "truth-helpers";
import dIcon from "discourse-common/helpers/d-icon";
import ChildTree from "admin/components/schema-theme-setting/editor/child-tree";
export default class SchemaThemeSettingNewEditorTreeNode extends Component {
@tracked text;
childObjectsProperties = this.findChildObjectsProperties(
this.args.schema.properties
);
constructor() {
super(...arguments);
this.#setText();
}
@action
registerInputFieldObserver() {
this.args.registerInputFieldObserver(
this.args.index,
this.#setText.bind(this)
);
}
#setText() {
this.text = this.args.generateSchemaTitle(
this.args.object,
this.args.schema,
this.args.index
);
}
findChildObjectsProperties(properties) {
const list = [];
for (const [name, spec] of Object.entries(properties)) {
if (spec.type === "objects") {
this.args.object[name] ||= [];
list.push({
name,
schema: spec.schema,
});
}
}
return list;
}
<template>
<li
role="link"
class="schema-theme-setting-editor__tree-node --parent
{{if @active ' --active'}}"
{{on "click" (fn @onClick @index)}}
>
<div class="schema-theme-setting-editor__tree-node-text">
<span {{didInsert this.registerInputFieldObserver}}>{{this.text}}</span>
{{#if (gt this.childObjectsProperties.length 0)}}
{{dIcon (if @active "chevron-down" "chevron-right")}}
{{else}}
{{dIcon "chevron-right"}}
{{/if}}
</div>
{{#if @active}}
{{#each this.childObjectsProperties as |childObjectsProperty|}}
<ChildTree
@name={{childObjectsProperty.name}}
@schema={{childObjectsProperty.schema}}
@objects={{get @object childObjectsProperty.name}}
@parentNodeText={{this.text}}
@parentNodeIndex={{@index}}
@onChildClick={{@onChildClick}}
@addChildItem={{@addChildItem}}
@generateSchemaTitle={{@generateSchemaTitle}}
/>
{{/each}}
{{/if}}
</li>
</template>
}

View File

@ -0,0 +1,47 @@
import { fn } from "@ember/helper";
import { on } from "@ember/modifier";
import { eq } from "truth-helpers";
import DButton from "discourse/components/d-button";
import dIcon from "discourse-common/helpers/d-icon";
import TreeNode from "admin/components/schema-theme-setting/editor/tree-node";
<template>
<ul class="schema-theme-setting-editor__tree">
{{#if @backButtonText}}
<li
role="link"
class="schema-theme-setting-editor__tree-node --back-btn"
{{on "click" @clickBack}}
>
<div class="schema-theme-setting-editor__tree-node-text">
{{dIcon "arrow-left"}}
{{@backButtonText}}
</div>
</li>
{{/if}}
{{#each @data as |object index|}}
<TreeNode
@index={{index}}
@object={{object}}
@active={{eq @activeIndex index}}
@onClick={{fn @updateIndex index}}
@onChildClick={{@onChildClick}}
@schema={{@schema}}
@addChildItem={{@addChildItem}}
@generateSchemaTitle={{@generateSchemaTitle}}
@registerInputFieldObserver={{@registerInputFieldObserver}}
@unregisterInputFieldObserver={{@unregisterInputFieldObserver}}
/>
{{/each}}
<li class="schema-theme-setting-editor__tree-node --parent --add-button">
<DButton
@action={{@addItem}}
@translatedLabel={{@schema.name}}
@icon="plus"
class="btn-transparent schema-theme-setting-editor__tree-add-button --root"
/>
</li>
</ul>
</template>

View File

@ -0,0 +1,5 @@
<template>
<div class="schema-field__input-description">
{{@description}}
</div>
</template>

View File

@ -0,0 +1,66 @@
import Component from "@glimmer/component";
import { cached } from "@glimmer/tracking";
import { htmlSafe } from "@ember/template";
import BooleanField from "admin/components/schema-theme-setting/types/boolean";
import CategoriesField from "admin/components/schema-theme-setting/types/categories";
import EnumField from "admin/components/schema-theme-setting/types/enum";
import FloatField from "admin/components/schema-theme-setting/types/float";
import GroupsField from "admin/components/schema-theme-setting/types/groups";
import IntegerField from "admin/components/schema-theme-setting/types/integer";
import StringField from "admin/components/schema-theme-setting/types/string";
import TagsField from "admin/components/schema-theme-setting/types/tags";
export default class SchemaThemeSettingField extends Component {
get component() {
const type = this.args.spec.type;
switch (this.args.spec.type) {
case "string":
return StringField;
case "integer":
return IntegerField;
case "float":
return FloatField;
case "boolean":
return BooleanField;
case "enum":
return EnumField;
case "categories":
return CategoriesField;
case "tags":
return TagsField;
case "groups":
return GroupsField;
default:
throw new Error(`unknown type ${type}`);
}
}
@cached
get description() {
if (!this.args.description) {
return;
}
return htmlSafe(this.args.description.trim().replace(/\n/g, "<br>"));
}
<template>
<div class="schema-field" data-name={{@name}} data-type={{@spec.type}}>
<label class="schema-field__label">{{@label}}{{if
@spec.required
"*"
}}</label>
<div class="schema-field__input">
<this.component
@value={{@value}}
@spec={{@spec}}
@onChange={{@onValueChange}}
@description={{this.description}}
@setting={{@setting}}
/>
</div>
</div>
</template>
}

View File

@ -0,0 +1,90 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { Input } from "@ember/component";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { and, not } from "truth-helpers";
import I18n from "discourse-i18n";
import FieldInputDescription from "admin/components/schema-theme-setting/field-input-description";
export default class SchemaThemeSettingNumberField extends Component {
@tracked touched = false;
@tracked value = this.args.value;
min = this.args.spec.validations?.min;
max = this.args.spec.validations?.max;
required = this.args.spec.required;
@action
onInput(event) {
this.touched = true;
let inputValue = event.currentTarget.value;
if (isNaN(inputValue)) {
this.value = null;
} else {
this.value = this.parseValue(inputValue);
}
this.args.onChange(this.value);
}
/**
* @param {string} value - The value of the input field to parse into a number
* @returns {number}
*/
parseFunc() {
throw "Not implemented";
}
get validationErrorMessage() {
if (!this.touched) {
return;
}
if (!this.value) {
if (this.required) {
return I18n.t("admin.customize.theme.schema.fields.required");
} else {
return;
}
}
if (this.min && this.value < this.min) {
return I18n.t("admin.customize.theme.schema.fields.number.too_small", {
count: this.min,
});
}
if (this.max && this.value > this.max) {
return I18n.t("admin.customize.theme.schema.fields.number.too_large", {
count: this.max,
});
}
}
<template>
<Input
@value={{this.value}}
{{on "input" this.onInput}}
@type="number"
inputmode={{this.inputmode}}
pattern={{this.pattern}}
step={{this.step}}
max={{this.max}}
min={{this.min}}
required={{this.required}}
/>
<div class="schema-field__input-supporting-text">
{{#if (and @description (not this.validationErrorMessage))}}
<FieldInputDescription @description={{@description}} />
{{/if}}
{{#if this.validationErrorMessage}}
<div class="schema-field__input-error">
{{this.validationErrorMessage}}
</div>
{{/if}}
</div>
</template>
}

View File

@ -0,0 +1,17 @@
import Component from "@glimmer/component";
import { Input } from "@ember/component";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import FieldInputDescription from "admin/components/schema-theme-setting/field-input-description";
export default class SchemaThemeSettingTypeBoolean extends Component {
@action
onInput(event) {
this.args.onChange(event.currentTarget.checked);
}
<template>
<Input @checked={{@value}} {{on "input" this.onInput}} @type="checkbox" />
<FieldInputDescription @description={{@description}} />
</template>
}

View File

@ -0,0 +1,43 @@
import { tracked } from "@glimmer/tracking";
import { hash } from "@ember/helper";
import { and, not } from "truth-helpers";
import FieldInputDescription from "admin/components/schema-theme-setting/field-input-description";
import SchemaThemeSettingTypeModels from "admin/components/schema-theme-setting/types/models";
import CategorySelector from "select-kit/components/category-selector";
export default class SchemaThemeSettingTypeCategories extends SchemaThemeSettingTypeModels {
@tracked
value =
this.args.value?.map((categoryId) => {
return this.args.setting.metadata.categories[categoryId];
}) || [];
type = "categories";
onChange(categories) {
return categories.map((category) => {
this.args.setting.metadata.categories[category.id] ||= category;
return category.id;
});
}
<template>
<CategorySelector
@categories={{this.value}}
@onChange={{this.onInput}}
@options={{hash allowUncategorized=false maximum=this.max}}
/>
<div class="schema-field__input-supporting-text">
{{#if (and @description (not this.validationErrorMessage))}}
<FieldInputDescription @description={{@description}} />
{{/if}}
{{#if this.validationErrorMessage}}
<div class="schema-field__input-error">
{{this.validationErrorMessage}}
</div>
{{/if}}
</div>
</template>
}

View File

@ -0,0 +1,35 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import FieldInputDescription from "admin/components/schema-theme-setting/field-input-description";
import ComboBox from "select-kit/components/combo-box";
export default class SchemaThemeSettingTypeEnum extends Component {
@tracked
value =
this.args.value || (this.args.spec.required && this.args.spec.default);
get content() {
return this.args.spec.choices.map((choice) => {
return {
name: choice,
id: choice,
};
});
}
@action
onInput(newVal) {
this.value = newVal;
this.args.onChange(newVal);
}
<template>
<ComboBox
@content={{this.content}}
@value={{this.value}}
@onChange={{this.onInput}}
/>
<FieldInputDescription @description={{@description}} />
</template>
}

View File

@ -0,0 +1,9 @@
import SchemaThemeSettingNumberField from "admin/components/schema-theme-setting/number-field";
export default class SchemaThemeSettingTypeFloat extends SchemaThemeSettingNumberField {
step = 0.1;
parseValue(value) {
return parseFloat(value);
}
}

View File

@ -0,0 +1,40 @@
import { service } from "@ember/service";
import { and, not } from "truth-helpers";
import FieldInputDescription from "admin/components/schema-theme-setting/field-input-description";
import SchemaThemeSettingTypeModels from "admin/components/schema-theme-setting/types/models";
import GroupChooser from "select-kit/components/group-chooser";
export default class SchemaThemeSettingTypeGroups extends SchemaThemeSettingTypeModels {
@service site;
type = "groups";
get groupChooserOptions() {
return {
clearable: !this.required,
filterable: true,
maximum: this.max,
};
}
<template>
<GroupChooser
@content={{this.site.groups}}
@value={{this.value}}
@onChange={{this.onInput}}
@options={{this.groupChooserOptions}}
/>
<div class="schema-field__input-supporting-text">
{{#if (and @description (not this.validationErrorMessage))}}
<FieldInputDescription @description={{@description}} />
{{/if}}
{{#if this.validationErrorMessage}}
<div class="schema-field__input-error">
{{this.validationErrorMessage}}
</div>
{{/if}}
</div>
</template>
}

View File

@ -0,0 +1,10 @@
import SchemaThemeSettingNumberField from "admin/components/schema-theme-setting/number-field";
export default class SchemaThemeSettingTypeInteger extends SchemaThemeSettingNumberField {
inputMode = "numeric";
pattern = "[0-9]*";
parseValue(value) {
return parseInt(value, 10);
}
}

View File

@ -0,0 +1,44 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import { isBlank } from "@ember/utils";
import I18n from "discourse-i18n";
export default class SchemaThemeSettingTypeModels extends Component {
@tracked value = this.args.value;
required = this.args.spec.required;
min = this.args.spec.validations?.min;
max = this.args.spec.validations?.max;
type;
@action
onInput(newValue) {
this.value = newValue;
this.args.onChange(this.onChange(newValue));
}
onChange(newValue) {
return newValue;
}
get validationErrorMessage() {
const isValueBlank = isBlank(this.value);
if (!this.required && isValueBlank) {
return;
}
if (
(this.min && this.value && this.value.length < this.min) ||
(this.required && isValueBlank)
) {
return I18n.t(
`admin.customize.theme.schema.fields.${this.type}.at_least`,
{
count: this.min || 1,
}
);
}
}
}

View File

@ -0,0 +1,81 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { Input } from "@ember/component";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { and, not } from "truth-helpers";
import concatClass from "discourse/helpers/concat-class";
import I18n from "discourse-i18n";
import FieldInputDescription from "admin/components/schema-theme-setting/field-input-description";
export default class SchemaThemeSettingTypeString extends Component {
@tracked touched = false;
@tracked value = this.args.value || "";
minLength = this.args.spec.validations?.min_length;
maxLength = this.args.spec.validations?.max_length;
required = this.args.spec.required;
@action
onInput(event) {
this.touched = true;
const newValue = event.currentTarget.value;
this.args.onChange(newValue);
this.value = newValue;
}
get validationErrorMessage() {
if (!this.touched) {
return;
}
const valueLength = this.value.length;
if (valueLength === 0) {
if (this.required) {
return I18n.t("admin.customize.theme.schema.fields.required");
} else {
return;
}
}
if (this.minLength && valueLength < this.minLength) {
return I18n.t("admin.customize.theme.schema.fields.string.too_short", {
count: this.minLength,
});
}
}
<template>
<Input
class="--string"
@value={{this.value}}
{{on "input" this.onInput}}
required={{this.required}}
minLength={{this.minLength}}
maxLength={{this.maxLength}}
/>
<div class="schema-field__input-supporting-text">
{{#if (and @description (not this.validationErrorMessage))}}
<FieldInputDescription @description={{@description}} />
{{/if}}
{{#if this.validationErrorMessage}}
<div class="schema-field__input-error">
{{this.validationErrorMessage}}
</div>
{{/if}}
{{#if this.maxLength}}
<div
class={{concatClass
"schema-field__input-count"
(if this.validationErrorMessage " --error")
}}
>
{{this.value.length}}/{{this.maxLength}}
</div>
{{/if}}
</div>
</template>
}

View File

@ -0,0 +1,36 @@
import { and, not } from "truth-helpers";
import FieldInputDescription from "admin/components/schema-theme-setting/field-input-description";
import SchemaThemeSettingTypeModels from "admin/components/schema-theme-setting/types/models";
import TagChooser from "select-kit/components/tag-chooser";
export default class SchemaThemeSettingTypeTags extends SchemaThemeSettingTypeModels {
type = "tags";
get tagChooserOption() {
return {
allowAny: false,
maximum: this.max,
};
}
<template>
<TagChooser
@tags={{this.value}}
@onChange={{this.onInput}}
@options={{this.tagChooserOption}}
class={{if this.validationErrorMessage "--invalid"}}
/>
<div class="schema-field__input-supporting-text">
{{#if (and @description (not this.validationErrorMessage))}}
<FieldInputDescription @description={{@description}} />
{{/if}}
{{#if this.validationErrorMessage}}
<div class="schema-field__input-error">
{{this.validationErrorMessage}}
</div>
{{/if}}
</div>
</template>
}

View File

@ -11,7 +11,7 @@
<ComboBox
@content={{this.actionNames}}
@value={{this.actionName}}
@onChange={{action (mut this.actionName)}}
@onChange={{fn (mut this.actionName)}}
/>
<DButton

View File

@ -1,7 +1,7 @@
import Component from "@ember/component";
import { action } from "@ember/object";
import { schedule } from "@ember/runloop";
import { inject as service } from "@ember/service";
import { service } from "@ember/service";
import { classNames, tagName } from "@ember-decorators/component";
import discourseComputed from "discourse-common/utils/decorators";
import I18n from "discourse-i18n";

View File

@ -2,7 +2,6 @@ import Component from "@ember/component";
import { action, set } from "@ember/object";
import { isEmpty } from "@ember/utils";
import { classNameBindings } from "@ember-decorators/component";
import { on } from "@ember-decorators/object";
import I18n from "discourse-i18n";
@classNameBindings(":value-list", ":secret-value-list")
@ -12,13 +11,12 @@ export default class SecretValueList extends Component {
values = null;
validationMessage = null;
@on("didReceiveAttrs")
_setupCollection() {
const values = this.values;
didReceiveAttrs() {
super.didReceiveAttrs(...arguments);
this.set(
"collection",
this._splitValues(values, this.inputDelimiter || "\n")
this._splitValues(this.values, this.inputDelimiter || "\n")
);
}

View File

@ -38,7 +38,7 @@
<ComboBox
@content={{this.validValues}}
@value={{this.newValue}}
@onChange={{action this.addValue}}
@onChange={{this.addValue}}
@valueProperty={{this.setting.computedValueProperty}}
@nameProperty={{this.setting.computedNameProperty}}
@options={{hash castInteger=true allowAny=false}}

View File

@ -4,7 +4,6 @@ import { action } from "@ember/object";
import { empty } from "@ember/object/computed";
import { isEmpty } from "@ember/utils";
import { classNameBindings } from "@ember-decorators/component";
import { on } from "@ember-decorators/object";
import discourseComputed from "discourse-common/utils/decorators";
@classNameBindings(":simple-list", ":value-list")
@ -18,8 +17,8 @@ export default class SimpleList extends Component {
choices = null;
allowAny = false;
@on("didReceiveAttrs")
_setupCollection() {
didReceiveAttrs() {
super.didReceiveAttrs(...arguments);
this.set("collection", this._splitValues(this.values, this.inputDelimiter));
this.set("isPredefinedList", !this.allowAny && !isEmpty(this.choices));
}

View File

@ -25,26 +25,49 @@
</div>
<div class="setting-value">
{{component
this.componentName
setting=this.setting
value=this.buffered.value
validationMessage=this.validationMessage
preview=this.preview
isSecret=this.isSecret
allowAny=this.allowAny
changeValueCallback=this.changeValueCallback
}}
{{#if this.settingEditButton}}
<DButton
@action={{this.settingEditButton.action}}
@icon={{this.settingEditButton.icon}}
@label={{this.settingEditButton.label}}
class="setting-value-edit-button"
/>
<SiteSettings::Description @description={{this.setting.description}} />
{{else}}
{{component
this.componentName
setting=this.setting
value=this.buffered.value
validationMessage=this.validationMessage
preview=this.preview
isSecret=this.isSecret
allowAny=this.allowAny
changeValueCallback=this.changeValueCallback
}}
{{/if}}
</div>
{{#if this.dirty}}
<div class="setting-controls">
<DButton class="ok" @action={{this.update}} @icon="check" />
<DButton class="cancel" @action={{this.cancel}} @icon="times" />
<DButton
@action={{this.update}}
@icon="check"
class="ok setting-controls__ok"
/>
<DButton
@action={{this.cancel}}
@icon="times"
class="cancel setting-controls__cancel"
/>
</div>
{{else if this.setting.overridden}}
{{else if this.overridden}}
{{#if this.setting.secret}}
<DButton @action={{this.toggleSecret}} @icon="far-eye-slash" />
<DButton
@action={{this.toggleSecret}}
@icon="far-eye-slash"
class="setting-toggle-secret"
/>
{{/if}}
<DButton

View File

@ -0,0 +1,58 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import didUpdate from "@ember/render-modifiers/modifiers/did-update";
import Category from "discourse/models/category";
import SettingValidationMessage from "admin/components/setting-validation-message";
import SiteSettingsDescription from "admin/components/site-settings/description";
import CategorySelector from "select-kit/components/category-selector";
export default class CategoryList extends Component {
@tracked selectedCategories = [];
constructor() {
super(...arguments);
this.pendingCategoriesRequest = Promise.resolve();
this.valueChanged();
}
get categoryIds() {
return this.args.value.split("|").filter(Boolean);
}
async updateSelectedCategories(previousRequest) {
const categories = await Category.asyncFindByIds(this.categoryIds);
// This is to prevent a race. We want to ensure that the update to
// selectedCategories for this request happens after the update for the
// previous request.
await previousRequest;
this.selectedCategories = categories;
}
@action
valueChanged() {
const previousRequest = this.pendingCategoriesRequest;
this.pendingCategoriesRequest =
this.updateSelectedCategories(previousRequest);
}
@action
onChangeSelectedCategories(value) {
this.args.changeValueCallback((value || []).mapBy("id").join("|"));
}
<template>
<div ...attributes {{didUpdate this.valueChanged @value}}>
<CategorySelector
@categories={{this.selectedCategories}}
@onChange={{this.onChangeSelectedCategories}}
/>
<SiteSettingsDescription @description={{@setting.description}} />
<SettingValidationMessage @message={{@validationMessage}} />
</div>
</template>
}

View File

@ -1,7 +0,0 @@
<CategorySelector
@categories={{this.selectedCategories}}
@onChange={{this.onChangeSelectedCategories}}
/>
<div class="desc">{{html-safe this.setting.description}}</div>
<SettingValidationMessage @message={{this.validationMessage}} />

View File

@ -1,15 +0,0 @@
import Component from "@ember/component";
import { action, computed } from "@ember/object";
import Category from "discourse/models/category";
export default class CategoryList extends Component {
@computed("value")
get selectedCategories() {
return Category.findByIds(this.value.split("|").filter(Boolean));
}
@action
onChangeSelectedCategories(value) {
this.set("value", (value || []).mapBy("id").join("|"));
}
}

View File

@ -1,7 +1,7 @@
<CategoryChooser
@value={{this.value}}
@onChange={{action (mut this.value)}}
@onChange={{fn (mut this.value)}}
@options={{hash allowUncategorized=true none=(eq this.setting.default "")}}
/>
<SettingValidationMessage @message={{this.validationMessage}} />
<div class="desc">{{html-safe this.setting.description}}</div>
<SiteSettings::Description @description={{this.setting.description}} />

Some files were not shown because too many files have changed in this diff Show More