Merge branch 'main' into theme-cards

This commit is contained in:
Martin Brennan 2024-10-03 14:36:33 +10:00
commit c446caa6e9
No known key found for this signature in database
GPG Key ID: BD981EFEEC8F5675
436 changed files with 9658 additions and 4652 deletions

View File

@ -10,7 +10,7 @@ permissions:
jobs:
triage:
if: github.actor != 'discourse-translator-bot'
runs-on: debian-12
runs-on: ubuntu-latest
steps:
- uses: actions/labeler@v5

View File

@ -17,7 +17,7 @@ jobs:
build:
if: github.event_name == 'pull_request' || github.repository != 'discourse/discourse-private-mirror'
name: run
runs-on: debian-12
runs-on: ${{ (github.repository != 'discourse/discourse' && 'ubuntu-latest') || 'debian-12' }}
container: discourse/discourse_test:slim
timeout-minutes: 10

View File

@ -17,7 +17,7 @@ jobs:
build:
if: github.event_name == 'pull_request' || github.repository != 'discourse/discourse-private-mirror'
name: run
runs-on: debian-12
runs-on: ${{ (github.repository != 'discourse/discourse' && 'ubuntu-latest') || 'debian-12' }}
container: discourse/discourse_test:slim
timeout-minutes: 30

View File

@ -24,7 +24,7 @@ jobs:
tests:
if: github.event_name == 'pull_request' || github.repository != 'discourse/discourse-private-mirror'
name: Tests with Ruby ${{ matrix.ruby }}
runs-on: debian-12
runs-on: ${{ (github.repository != 'discourse/discourse' && 'ubuntu-latest') || 'debian-12' }}
container: discourse/discourse_test:slim
timeout-minutes: 20
@ -126,11 +126,11 @@ 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 --default-path migrations/spec

View File

@ -4,14 +4,14 @@ 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.'
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'
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.'
description: "Ending ref (inclusive). Can be a tag, branch or commit ref. `HEAD` refers to the most recent commit."
required: true
default: 'HEAD'
default: "HEAD"
type: string
permissions:
@ -20,7 +20,7 @@ permissions:
jobs:
build:
name: run
runs-on: debian-12
runs-on: ${{ (github.repository != 'discourse/discourse' && 'ubuntu-latest') || 'debian-12' }}
container: discourse/discourse_test:slim
timeout-minutes: 10
env:
@ -87,7 +87,7 @@ jobs:
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

View File

@ -1,7 +1,7 @@
name: 'Close stale PRs'
name: "Close stale PRs"
on:
schedule:
- cron: '30 1 * * *'
- cron: "30 1 * * *"
workflow_dispatch:
permissions:
@ -9,11 +9,11 @@ permissions:
jobs:
stale:
runs-on: debian-12
runs-on: ${{ (github.repository != 'discourse/discourse' && 'ubuntu-latest') || 'debian-12' }}
steps:
- uses: actions/stale@v9
with:
days-before-stale: 60
days-before-close: 14
stale-pr-message: 'This pull request has been automatically marked as stale because it has been open for 60 days with no activity. To keep it open, remove the stale tag, push code, or add a comment. Otherwise, it will be closed in 14 days.'
stale-pr-message: "This pull request has been automatically marked as stale because it has been open for 60 days with no activity. To keep it open, remove the stale tag, push code, or add a comment. Otherwise, it will be closed in 14 days."
exempt-pr-labels: dependencies

View File

@ -29,7 +29,7 @@ jobs:
build:
if: github.event_name == 'pull_request' || github.repository != 'discourse/discourse-private-mirror'
name: ${{ matrix.target }} ${{ matrix.build_type }} # Update fetch-job-id step if changing this
runs-on: ${{ ((github.ref == 'refs/heads/main') && 'debian-12') || 'ubuntu-22.04-8core' }}
runs-on: ${{ (github.repository != 'discourse/discourse' && 'ubuntu-22.04-8core') || 'debian-12' }}
container: discourse/discourse_test:slim${{ (matrix.build_type == 'frontend' || matrix.build_type == 'system') && '-browsers' || '' }}
timeout-minutes: 20
@ -44,6 +44,7 @@ jobs:
DISCOURSE_TURBO_RSPEC_RETRY_AND_LOG_FLAKY_TESTS: ${{ (matrix.build_type == 'system' || matrix.build_type == 'backend') && github.ref == 'refs/heads/main' && '1' }}
CHEAP_SOURCE_MAPS: "1"
TESTEM_DEFAULT_BROWSER: Chrome
MINIO_RUNNER_INSTALL_DIR: /home/discourse/.minio_runner
strategy:
fail-fast: false
@ -255,6 +256,13 @@ jobs:
if: matrix.build_type == 'system'
run: bin/ember-cli --build
- name: Minio cache
if: matrix.build_type == 'system' && matrix.target == 'core'
uses: actions/cache@v4
with:
path: ${{ env.MINIO_RUNNER_INSTALL_DIR }}
key: ${{ runner.os }}-${{ steps.container-envs.outputs.ruby_version }}-${{ steps.container-envs.outputs.debian_release }}-gem-${{ hashFiles('**/Gemfile.lock') }}
- name: Ensure latest minio binary installed for Core System Tests
if: matrix.build_type == 'system' && matrix.target == 'core'
run: bundle exec ruby script/install_minio_binaries.rb
@ -358,7 +366,7 @@ jobs:
core_frontend_tests:
if: github.event_name == 'pull_request' || github.repository != 'discourse/discourse-private-mirror'
name: core frontend (${{ matrix.browser }})
runs-on: ${{ ((github.ref == 'refs/heads/main') && 'debian-12') || 'ubuntu-22.04-8core' }}
runs-on: ${{ (github.repository != 'discourse/discourse' && 'ubuntu-22.04-8core') || 'debian-12' }}
container:
image: discourse/discourse_test:slim-browsers
options: --user discourse
@ -397,7 +405,7 @@ jobs:
- name: Core QUnit
working-directory: ./app/assets/javascripts/discourse
run: |
pnpm ember exam --path /tmp/emberbuild --load-balance --parallel=$(($(nproc) / 2 + 1)) --launch "${{ env.TESTEM_BROWSER }}" --write-execution-file --random
pnpm ember exam --path /tmp/emberbuild --load-balance --parallel=$(($(nproc) / 2)) --launch "${{ env.TESTEM_BROWSER }}" --write-execution-file --random
timeout-minutes: 15
- uses: actions/upload-artifact@v4

View File

@ -45,6 +45,7 @@ reviewed:
- concurrent-ruby # MIT
- css_parser # MIT
- drb # BSD-2-Clause
- dry-initializer # MIT
- excon # MIT
- faraday-em_http # MIT
- faraday-em_synchrony # MIT

View File

@ -287,3 +287,5 @@ group :migrations, optional: true do
# CLI
gem "ruby-progressbar"
end
gem "dry-initializer", "~> 3.1"

View File

@ -132,9 +132,10 @@ GEM
literate_randomizer
docile (1.4.1)
drb (2.2.1)
dry-initializer (3.1.1)
email_reply_trimmer (0.1.13)
erubi (1.13.0)
excon (0.111.0)
excon (0.112.0)
execjs (2.9.1)
exifr (1.4.0)
extralite-bundle (2.8.2)
@ -142,8 +143,9 @@ GEM
faker (2.23.0)
i18n (>= 1.8.11, < 2)
fakeweb (1.3.0)
faraday (2.11.0)
faraday (2.12.0)
faraday-net_http (>= 2.0, < 3.4)
json
logger
faraday-net_http (3.3.0)
net-http
@ -158,16 +160,16 @@ GEM
fspath (3.1.2)
globalid (1.2.1)
activesupport (>= 6.1)
google-protobuf (4.28.1-aarch64-linux)
google-protobuf (4.28.2-aarch64-linux)
bigdecimal
rake (>= 13)
google-protobuf (4.28.1-arm64-darwin)
google-protobuf (4.28.2-arm64-darwin)
bigdecimal
rake (>= 13)
google-protobuf (4.28.1-x86_64-darwin)
google-protobuf (4.28.2-x86_64-darwin)
bigdecimal
rake (>= 13)
google-protobuf (4.28.1-x86_64-linux)
google-protobuf (4.28.2-x86_64-linux)
bigdecimal
rake (>= 13)
guess_html_encoding (0.0.11)
@ -178,7 +180,7 @@ GEM
reline
htmlentities (4.3.4)
http_accept_language (2.1.1)
i18n (1.14.5)
i18n (1.14.6)
concurrent-ruby (~> 1.0)
image_optim (0.31.3)
exifr (~> 1.2, >= 1.2.2)
@ -189,7 +191,7 @@ GEM
image_size (3.4.0)
in_threads (1.6.0)
io-console (0.7.2)
irb (1.14.0)
irb (1.14.1)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
iso8601 (0.13.0)
@ -234,7 +236,7 @@ GEM
net-smtp
matrix (0.4.2)
maxminddb (0.1.22)
memory_profiler (1.0.2)
memory_profiler (1.1.0)
message_bus (4.3.8)
rack (>= 1.1.3)
messageformat-wrapper (1.1.0)
@ -344,7 +346,7 @@ GEM
psych (5.1.2)
stringio
public_suffix (6.0.1)
puma (6.4.2)
puma (6.4.3)
nio4r (~> 2.0)
racc (1.8.1)
rack (2.2.9)
@ -404,10 +406,10 @@ GEM
io-console (~> 0.5)
request_store (1.7.0)
rack (>= 1.4)
rexml (3.3.7)
rexml (3.3.8)
rinku (2.0.6)
rotp (6.3.0)
rouge (4.3.0)
rouge (4.4.0)
rqrcode (2.2.0)
chunky_png (~> 1.0)
rqrcode_core (~> 1.0)
@ -426,7 +428,7 @@ GEM
rspec-html-matchers (0.10.0)
nokogiri (~> 1)
rspec (>= 3.0.0.a)
rspec-mocks (3.13.1)
rspec-mocks (3.13.2)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-multi-mock (0.3.1)
@ -473,12 +475,12 @@ GEM
rubocop-rspec_rails (>= 2.30.0)
rubocop-factory_bot (2.26.1)
rubocop (~> 1.61)
rubocop-rails (2.26.1)
rubocop-rails (2.26.2)
activesupport (>= 4.2.0)
rack (>= 1.1)
rubocop (>= 1.52.0, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0)
rubocop-rspec (3.0.5)
rubocop-rspec (3.1.0)
rubocop (~> 1.61)
rubocop-rspec_rails (2.30.0)
rubocop (~> 1.61)
@ -503,9 +505,9 @@ GEM
google-protobuf (>= 3.25, < 5.0)
sassc-embedded (1.77.7)
sass-embedded (~> 1.77)
selenium-devtools (0.128.0)
selenium-devtools (0.129.0)
selenium-webdriver (~> 4.2)
selenium-webdriver (4.24.0)
selenium-webdriver (4.25.0)
base64 (~> 0.2)
logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5)
@ -527,7 +529,7 @@ GEM
snaky_hash (2.0.1)
hashie
version_gem (~> 1.1, >= 1.1.1)
sprockets (3.7.4)
sprockets (3.7.5)
base64
concurrent-ruby (~> 1.0)
rack (> 1, < 3)
@ -535,10 +537,10 @@ GEM
actionpack (>= 6.1)
activesupport (>= 6.1)
sprockets (>= 3.0.0)
sqlite3 (2.0.4-aarch64-linux-gnu)
sqlite3 (2.0.4-arm64-darwin)
sqlite3 (2.0.4-x86_64-darwin)
sqlite3 (2.0.4-x86_64-linux-gnu)
sqlite3 (2.1.0-aarch64-linux-gnu)
sqlite3 (2.1.0-arm64-darwin)
sqlite3 (2.1.0-x86_64-darwin)
sqlite3 (2.1.0-x86_64-linux-gnu)
sshkey (3.0.0)
stackprof (0.2.26)
stringio (3.1.1)
@ -553,10 +555,10 @@ GEM
concurrent-ruby (~> 1.0)
tzinfo-data (1.2024.2)
tzinfo (>= 1.0.0)
uglifier (4.2.0)
uglifier (4.2.1)
execjs (>= 0.3.0, < 3)
unf (0.2.0)
unicode-display_width (2.5.0)
unicode-display_width (2.6.0)
unicorn (6.1.0)
kgio (~> 2.6)
raindrops (~> 0.7)
@ -566,7 +568,7 @@ GEM
web-push (3.0.1)
jwt (~> 2.0)
openssl (~> 3.0)
webmock (3.23.1)
webmock (3.24.0)
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
@ -623,6 +625,7 @@ DEPENDENCIES
discourse-fonts
discourse-seed-fu
discourse_dev_assets
dry-initializer (~> 3.1)
email_reply_trimmer
excon
execjs

View File

@ -82,7 +82,7 @@ export default class AdminConfigAreasAboutContactInformation extends Component {
<form.Field
@name="communityOwner"
@title={{i18n "admin.config_areas.about.community_owner"}}
@subtitle={{i18n "admin.config_areas.about.community_owner_help"}}
@description={{i18n "admin.config_areas.about.community_owner_help"}}
@format="large"
as |field|
>
@ -96,7 +96,7 @@ export default class AdminConfigAreasAboutContactInformation extends Component {
<form.Field
@name="contactEmail"
@title={{i18n "admin.config_areas.about.contact_email"}}
@subtitle={{i18n "admin.config_areas.about.contact_email_help"}}
@description={{i18n "admin.config_areas.about.contact_email_help"}}
@type="email"
@format="large"
as |field|
@ -111,7 +111,7 @@ export default class AdminConfigAreasAboutContactInformation extends Component {
<form.Field
@name="contactURL"
@title={{i18n "admin.config_areas.about.contact_url"}}
@subtitle={{i18n "admin.config_areas.about.contact_url_help"}}
@description={{i18n "admin.config_areas.about.contact_url_help"}}
@type="url"
@format="large"
as |field|
@ -126,7 +126,7 @@ export default class AdminConfigAreasAboutContactInformation extends Component {
<form.Field
@name="contactUsername"
@title={{i18n "admin.config_areas.about.site_contact_name"}}
@subtitle={{i18n "admin.config_areas.about.site_contact_name_help"}}
@description={{i18n "admin.config_areas.about.site_contact_name_help"}}
@onSet={{this.setContactUsername}}
@format="large"
as |field|
@ -143,7 +143,7 @@ export default class AdminConfigAreasAboutContactInformation extends Component {
<form.Field
@name="contactGroupName"
@title={{i18n "admin.config_areas.about.site_contact_group"}}
@subtitle={{i18n "admin.config_areas.about.site_contact_group_help"}}
@description={{i18n "admin.config_areas.about.site_contact_group_help"}}
@onSet={{this.setContactGroup}}
@format="large"
as |field|

View File

@ -98,7 +98,7 @@ export default class AdminConfigAreasAboutGeneralSettings extends Component {
<form.Field
@name="aboutBannerImage"
@title={{i18n "admin.config_areas.about.banner_image"}}
@subtitle={{i18n "admin.config_areas.about.banner_image_help"}}
@description={{i18n "admin.config_areas.about.banner_image_help"}}
@onSet={{this.setImage}}
as |field|
>

View File

@ -58,7 +58,7 @@ export default class AdminConfigAreasAboutYourOrganization extends Component {
<form.Field
@name="companyName"
@title={{i18n "admin.config_areas.about.company_name"}}
@subtitle={{i18n "admin.config_areas.about.company_name_help"}}
@description={{i18n "admin.config_areas.about.company_name_help"}}
@format="large"
as |field|
>
@ -75,7 +75,7 @@ export default class AdminConfigAreasAboutYourOrganization extends Component {
<form.Field
@name="governingLaw"
@title={{i18n "admin.config_areas.about.governing_law"}}
@subtitle={{i18n "admin.config_areas.about.governing_law_help"}}
@description={{i18n "admin.config_areas.about.governing_law_help"}}
@format="large"
as |field|
>
@ -89,7 +89,7 @@ export default class AdminConfigAreasAboutYourOrganization extends Component {
<form.Field
@name="cityForDisputes"
@title={{i18n "admin.config_areas.about.city_for_disputes"}}
@subtitle={{i18n "admin.config_areas.about.city_for_disputes_help"}}
@description={{i18n "admin.config_areas.about.city_for_disputes_help"}}
@format="large"
as |field|
>

View File

@ -61,7 +61,7 @@ export default class AdminFilteredSiteSettings extends Component {
/>
<ConditionalLoadingSpinner @condition={{this.loading}}>
<section class="form-horizontal settings">
<section class="admin-filtered-site-settings form-horizontal settings">
{{#each this.visibleSettings as |setting|}}
<SiteSetting @setting={{setting}} />
{{/each}}

View File

@ -0,0 +1,25 @@
import Component from "@glimmer/component";
import { action } from "@ember/object";
import { htmlSafe } from "@ember/template";
import DButton from "discourse/components/d-button";
import icon from "discourse-common/helpers/d-icon";
export default class AdminNotice extends Component {
@action
dismiss() {
this.args.dismissCallback(this.args.problem);
}
<template>
<div class="notice">
<div class="message">
{{if @icon (icon @icon)}}
{{htmlSafe @problem.message}}
</div>
<DButton
@action={{this.dismiss}}
@label="admin.dashboard.dismiss_notice"
/>
</div>
</template>
}

View File

@ -24,6 +24,10 @@ export default class AdminPluginConfigPage extends Component {
return classes.join(" ");
}
get actionsOutletName() {
return `admin-plugin-config-page-actions-${this.args.plugin.kebabCaseName}`;
}
linkText(navLink) {
if (navLink.label) {
return i18n(navLink.label);
@ -68,10 +72,12 @@ export default class AdminPluginConfigPage extends Component {
{{/if}}
</:tabs>
<:actions as |actions|>
<PluginOutlet
@name="admin-plugin-config-page-actions"
@outletArgs={{hash plugin=@plugin actions=actions}}
/>
<div class={{this.actionsOutletName}}>
<PluginOutlet
@name={{this.actionsOutletName}}
@outletArgs={{hash plugin=@plugin actions=actions}}
/>
</div>
</:actions>
</AdminPageHeader>

View File

@ -0,0 +1,77 @@
import Component from "@glimmer/component";
import { hash } from "@ember/helper";
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";
import {
DangerButton,
DefaultButton,
PrimaryButton,
} from "admin/components/admin-page-action-button";
export default class AdminSectionLandingItem extends Component {
get title() {
if (this.args.titleLabelTranslated) {
return this.args.titleLabelTranslated;
} else if (this.args.titleLabel) {
return i18n(this.args.titleLabel);
}
}
get description() {
if (this.args.descriptionLabelTranslated) {
return this.args.descriptionLabelTranslated;
} else if (this.args.descriptionLabel) {
return i18n(this.args.descriptionLabel);
}
}
get tagline() {
if (this.args.taglineLabelTranslated) {
return this.args.taglineLabelTranslated;
} else if (this.args.taglineLabel) {
return i18n(this.args.taglineLabel);
}
}
<template>
<div
class={{concatClass "admin-section-landing-item" (if @icon "-has-icon")}}
...attributes
>
{{#if @imageUrl}}
<img class="admin-section-landing-item__image" src={{@imageUrl}} />
{{/if}}
{{#if @icon}}
<div class="admin-section-landing-item__icon">
{{dIcon @icon}}
</div>
{{/if}}
<div class="admin-section-landing-item__content">
{{#if this.tagline}}
<h4 class="admin-section-landing-item__tagline">{{this.tagline}}</h4>
{{/if}}
{{#if @titleRoute}}
<LinkTo @route={{@titleRoute}}><h3
class="admin-section-landing-item__title"
>{{this.title}}</h3></LinkTo>
{{else}}
<h3 class="admin-section-landing-item__title">{{this.title}}</h3>
{{/if}}
<p
class="admin-section-landing-item__description"
>{{this.description}}</p>
</div>
<div class="admin-section-landing-item__buttons">
{{yield
(hash Primary=PrimaryButton Default=DefaultButton Danger=DangerButton)
to="buttons"
}}
</div>
</div>
</template>
}

View File

@ -0,0 +1,7 @@
const AdminSectionLandingWrapper = <template>
<div class="admin-section-landing-wrapper" ...attributes>
{{yield}}
</div>
</template>;
export default AdminSectionLandingWrapper;

View File

@ -0,0 +1,78 @@
import Component from "@glimmer/component";
import { concat } from "@ember/helper";
import { action } from "@ember/object";
import { eq } from "truth-helpers";
import ConditionalLoadingSection from "discourse/components/conditional-loading-section";
import DButton from "discourse/components/d-button";
import concatClass from "discourse/helpers/concat-class";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import icon from "discourse-common/helpers/d-icon";
import i18n from "discourse-common/helpers/i18n";
import AdminNotice from "admin/components/admin-notice";
export default class DashboardProblems extends Component {
@action
async dismissProblem(problem) {
try {
await ajax(`/admin/admin_notices/${problem.id}`, { type: "DELETE" });
this.args.problems.removeObject(problem);
} catch (error) {
popupAjaxError(error);
}
}
get problems() {
return this.args.problems.sortBy("priority");
}
<template>
{{#if @problems.length}}
<div class="section dashboard-problems">
<div class="section-title">
<h2>
{{icon "heart"}}
{{i18n "admin.dashboard.problems_found"}}
</h2>
</div>
<div class="section-body">
<ConditionalLoadingSection @isLoading={{@loadingProblems}}>
<div class="problem-messages">
<ul>
{{#each this.problems as |problem|}}
<li
class={{concatClass
"dashboard-problem"
(concat "priority-" problem.priority)
}}
>
<AdminNotice
@icon={{if
(eq problem.priority "high")
"triangle-exclamation"
}}
@problem={{problem}}
@dismissCallback={{this.dismissProblem}}
/>
</li>
{{/each}}
</ul>
</div>
<p class="actions">
<DButton
@action={{@refreshProblems}}
@icon="arrows-rotate"
@label="admin.dashboard.refresh_problems"
class="btn-default"
/>
{{i18n "admin.dashboard.last_checked"}}:
{{@problemsTimestamp}}
</p>
</ConditionalLoadingSection>
</div>
</div>
{{/if}}
</template>
}

View File

@ -1,58 +0,0 @@
{{#if this.foundProblems}}
<div class="section dashboard-problems">
<div class="section-title">
<h2>
{{d-icon "heart"}}
{{i18n "admin.dashboard.problems_found"}}
</h2>
</div>
<div class="section-body">
<ConditionalLoadingSection @isLoading={{this.loadingProblems}}>
{{#if this.highPriorityProblems.length}}
<div class="problem-messages priority-high">
<ul>
{{#each this.highPriorityProblems as |problem|}}
<li
class={{concat
"dashboard-problem "
"priority-"
problem.priority
}}
>
{{d-icon "triangle-exclamation"}}
{{html-safe problem.message}}
</li>
{{/each}}
</ul>
</div>
{{/if}}
<div class="problem-messages priority-low">
<ul>
{{#each this.lowPriorityProblems as |problem|}}
<li
class={{concat
"dashboard-problem "
"priority-"
problem.priority
}}
>{{html-safe problem.message}}</li>
{{/each}}
</ul>
</div>
<p class="actions">
<DButton
@action={{this.refreshProblems}}
@icon="arrows-rotate"
@label="admin.dashboard.refresh_problems"
class="btn-default"
/>
{{i18n "admin.dashboard.last_checked"}}:
{{this.problemsTimestamp}}
</p>
</ConditionalLoadingSection>
</div>
</div>
{{/if}}

View File

@ -1,3 +0,0 @@
import Component from "@ember/component";
export default class DashboardProblems extends Component {}

View File

@ -0,0 +1,25 @@
import Component from "@glimmer/component";
import { service } from "@ember/service";
import { modifier } from "ember-modifier";
import highlightSyntax from "discourse/lib/highlight-syntax";
export default class HighlightedCode extends Component {
@service session;
@service siteSettings;
highlight = modifier(async (element) => {
const code = document.createElement("code");
code.classList.add(`lang-${this.args.lang}`);
code.textContent = this.args.code;
const pre = document.createElement("pre");
pre.appendChild(code);
element.replaceChildren(pre);
await highlightSyntax(pre, this.siteSettings, this.session);
});
<template>
<div {{this.highlight}}></div>
</template>
}

View File

@ -1 +0,0 @@
<pre><code class="lang-{{this.lang}}">{{this.code}}</code></pre>

View File

@ -1,11 +0,0 @@
import Component from "@ember/component";
import { observes, on } from "@ember-decorators/object";
import highlightSyntax from "discourse/lib/highlight-syntax";
export default class HighlightedCode extends Component {
@on("didInsertElement")
@observes("code")
_refresh() {
highlightSyntax(this.element, this.siteSettings, this.session);
}
}

View File

@ -18,16 +18,6 @@ export default class AdminDashboardController extends Controller {
@setting("version_checks") showVersionChecks;
@discourseComputed(
"lowPriorityProblems.length",
"highPriorityProblems.length"
)
foundProblems(lowPriorityProblemsLength, highPriorityProblemsLength) {
const problemsLength =
lowPriorityProblemsLength + highPriorityProblemsLength;
return this.currentUser.admin && problemsLength > 0;
}
@computed("siteSettings.dashboard_visible_tabs")
get visibleTabs() {
return (this.siteSettings.dashboard_visible_tabs || "")
@ -106,16 +96,7 @@ export default class AdminDashboardController extends Controller {
});
AdminDashboard.fetchProblems()
.then((model) => {
this.set(
"highPriorityProblems",
model.problems.filterBy("priority", "high")
);
this.set(
"lowPriorityProblems",
model.problems.filterBy("priority", "low")
);
})
.then((model) => this.set("problems", model.problems))
.finally(() => this.set("loadingProblems", false));
}

View File

@ -78,8 +78,8 @@ export default class AdminUserIndexController extends Controller.extend(
@discourseComputed("model.associated_accounts")
associatedAccounts(associatedAccounts) {
return associatedAccounts
.map((provider) => `${provider.name} (${provider.description})`)
.join(", ");
?.map((provider) => `${provider.name} (${provider.description})`)
?.join(", ");
}
@discourseComputed("model.user_fields.[]")
@ -319,6 +319,16 @@ export default class AdminUserIndexController extends Controller.extend(
return this.model.silence();
}
@action
deleteAssociatedAccounts() {
this.dialog.yesNoConfirm({
message: I18n.t("admin.user.delete_associated_accounts_confirm"),
didConfirm: () => {
this.model.deleteAssociatedAccounts().catch(popupAjaxError);
},
});
}
@action
anonymize() {
const user = this.model;

View File

@ -24,6 +24,10 @@ export default class AdminPlugin {
return this.name.replaceAll("-", "_");
}
get kebabCaseName() {
return this.name.replaceAll(" ", "-").replaceAll("_", "-");
}
get translatedCategoryName() {
// We do this because the site setting list is grouped by category,
// with plugins that have their root site setting key defined as `plugins:`

View File

@ -287,6 +287,17 @@ export default class AdminUser extends User {
});
}
deleteAssociatedAccounts() {
return ajax(`/admin/users/${this.id}/delete_associated_accounts`, {
type: "PUT",
data: {
context: window.location.pathname,
},
}).then(() => {
this.set("associated_accounts", []);
});
}
destroy(formData) {
return ajax(`/admin/users/${this.id}.json`, {
type: "DELETE",

View File

@ -239,5 +239,13 @@ export default function () {
path: "/whats-new",
resetNamespace: true,
});
this.route(
"adminSection",
{ path: "/section", resetNamespace: true },
function () {
this.route("account");
}
);
});
}

View File

@ -18,9 +18,7 @@
<DashboardProblems
@loadingProblems={{this.loadingProblems}}
@foundProblems={{this.foundProblems}}
@lowPriorityProblems={{this.lowPriorityProblems}}
@highPriorityProblems={{this.highPriorityProblems}}
@problems={{this.problems}}
@problemsTimestamp={{this.problemsTimestamp}}
@refreshProblems={{action "refreshProblems"}}
/>

View File

@ -0,0 +1,26 @@
<AdminPageHeader
@titleLabel="admin.section_landing_pages.account.title"
@hideTabs={{true}}
>
<:breadcrumbs>
<DBreadcrumbsItem
@path="/admin/section/account"
@label={{i18n "admin.section_landing_pages.account.title"}}
/>
</:breadcrumbs>
</AdminPageHeader>
<AdminSectionLandingWrapper>
<AdminSectionLandingItem
@icon="box-archive"
@titleLabel="admin.section_landing_pages.account.backups.title"
@descriptionLabel="admin.section_landing_pages.account.backups.description"
@titleRoute="admin.backups"
/>
<AdminSectionLandingItem
@icon="gift"
@titleLabel="admin.section_landing_pages.account.whats_new.title"
@descriptionLabel="admin.section_landing_pages.account.whats_new.description"
@titleRoute="admin.whatsNew"
/>
</AdminSectionLandingWrapper>

View File

@ -157,6 +157,17 @@
/>
{{/if}}
</div>
{{#if (and this.currentUser.admin this.associatedAccounts)}}
<div class="controls">
<DButton
@action={{this.deleteAssociatedAccounts}}
@icon="trash-can"
@label="admin.users.delete_associated_accounts.text"
@title="admin.users.delete_associated_accounts.title"
class="btn-danger"
/>
</div>
{{/if}}
</div>
{{/if}}

View File

@ -14,32 +14,32 @@
"start": "ember serve"
},
"dependencies": {
"@babel/core": "^7.25.2",
"@babel/core": "^7.25.7",
"@ember/string": "^4.0.0",
"discourse-common": "workspace:1.0.0",
"ember-cli-babel": "^8.2.0",
"ember-cli-htmlbars": "^6.3.0",
"ember-template-imports": "^4.1.1"
"ember-template-imports": "^4.1.2"
},
"devDependencies": {
"@ember/optional-features": "^2.1.0",
"@embroider/test-setup": "^4.0.0",
"@glimmer/component": "^1.1.2",
"@types/jquery": "^3.5.30",
"@types/jquery": "^3.5.31",
"@types/qunit": "^2.19.10",
"@types/rsvp": "^4.0.9",
"broccoli-asset-rev": "^3.0.0",
"ember-cli": "~5.11.0",
"ember-cli": "~5.12.0",
"ember-cli-inject-live-reload": "^2.1.0",
"ember-cli-sri": "^2.1.1",
"ember-cli-terser": "^4.0.2",
"ember-disable-prototype-extensions": "^1.1.3",
"ember-load-initializers": "^2.1.1",
"ember-resolver": "^13.0.0",
"ember-load-initializers": "^3.0.1",
"ember-resolver": "^13.0.2",
"ember-source": "~5.5.0",
"ember-source-channel-url": "^3.0.0",
"loader.js": "^4.7.0",
"webpack": "^5.94.0"
"webpack": "^5.95.0"
},
"engines": {
"node": ">= 18",

View File

@ -9,17 +9,17 @@
],
"dependencies": {
"a11y-dialog": "8.1.1",
"ember-auto-import": "^2.7.4",
"ember-auto-import": "^2.8.1",
"ember-cli-babel": "^8.2.0",
"ember-cli-htmlbars": "^6.3.0",
"ember-template-imports": "^4.1.1",
"ember-template-imports": "^4.1.2",
"truth-helpers": "workspace:1.0.0"
},
"devDependencies": {
"@types/jquery": "^3.5.30",
"@types/jquery": "^3.5.31",
"@types/qunit": "^2.19.10",
"@types/rsvp": "^4.0.9",
"webpack": "^5.94.0"
"webpack": "^5.95.0"
},
"engines": {
"node": ">= 18",

View File

@ -44,11 +44,12 @@ export function registerRawHelpers(hbs, handlebarsClass, owner) {
}
let list = get(this, contextName);
let output = [];
let innerContext = { ...options.contexts[0] };
let innerContext = options.contexts[0];
for (let i = 0; i < list.length; i++) {
innerContext[localName] = list[i];
output.push(options.fn(innerContext));
}
delete innerContext[localName];
return output.join("");
}
);

View File

@ -14,7 +14,7 @@
"start": "ember serve"
},
"dependencies": {
"@babel/core": "^7.25.2",
"@babel/core": "^7.25.7",
"@ember/string": "^4.0.0",
"@uppy/aws-s3": "3.0.6",
"@uppy/aws-s3-multipart": "3.1.3",
@ -23,10 +23,10 @@
"@uppy/utils": "5.4.3",
"@uppy/xhr-upload": "3.1.1",
"discourse-i18n": "workspace:1.0.0",
"ember-auto-import": "^2.7.4",
"ember-auto-import": "^2.8.1",
"ember-cli-babel": "^8.2.0",
"ember-cli-htmlbars": "^6.3.0",
"ember-resolver": "^13.0.0",
"ember-resolver": "^13.0.2",
"handlebars": "^4.7.8",
"truth-helpers": "workspace:1.0.0"
},
@ -34,20 +34,20 @@
"@ember/optional-features": "^2.1.0",
"@embroider/test-setup": "^4.0.0",
"@glimmer/component": "^1.1.2",
"@types/jquery": "^3.5.30",
"@types/jquery": "^3.5.31",
"@types/qunit": "^2.19.10",
"@types/rsvp": "^4.0.9",
"broccoli-asset-rev": "^3.0.0",
"ember-cli": "~5.11.0",
"ember-cli": "~5.12.0",
"ember-cli-inject-live-reload": "^2.1.0",
"ember-cli-sri": "^2.1.1",
"ember-cli-terser": "^4.0.2",
"ember-disable-prototype-extensions": "^1.1.3",
"ember-load-initializers": "^2.1.1",
"ember-load-initializers": "^3.0.1",
"ember-source": "~5.5.0",
"ember-source-channel-url": "^3.0.0",
"loader.js": "^4.7.0",
"webpack": "^5.94.0"
"webpack": "^5.95.0"
},
"engines": {
"node": ">= 18",

View File

@ -21,7 +21,7 @@
"@embroider/addon-shim": "^1.8.9",
"discourse-common": "workspace:1.0.0",
"discourse-i18n": "workspace:1.0.0",
"ember-auto-import": "^2.7.4",
"ember-auto-import": "^2.8.1",
"markdown-it": "14.0.0",
"pretty-text": "workspace:1.0.0",
"truth-helpers": "workspace:1.0.0",

View File

@ -8,18 +8,18 @@
"ember-addon"
],
"dependencies": {
"@babel/core": "^7.25.2",
"@babel/core": "^7.25.7",
"deprecation-silencer": "workspace:1.0.0",
"discourse-hbr": "workspace:1.0.0",
"discourse-widget-hbs": "workspace:1.0.0",
"ember-cli-babel": "^8.2.0",
"ember-cli-htmlbars": "^6.3.0",
"ember-template-imports": "^4.1.1",
"ember-template-imports": "^4.1.2",
"ember-this-fallback": "^0.4.0"
},
"devDependencies": {
"ember-cli": "~5.11.0",
"webpack": "^5.94.0"
"ember-cli": "~5.12.0",
"webpack": "^5.95.0"
},
"engines": {
"node": ">= 18",

View File

@ -14,8 +14,8 @@
"start": "ember serve"
},
"dependencies": {
"@babel/core": "^7.25.2",
"ember-auto-import": "^2.7.4",
"@babel/core": "^7.25.7",
"ember-auto-import": "^2.8.1",
"ember-cli-babel": "^8.2.0",
"ember-cli-htmlbars": "^6.3.0",
"handlebars": "^4.7.8"
@ -26,17 +26,17 @@
"@glimmer/component": "^1.1.2",
"@glimmer/syntax": "^0.92.3",
"broccoli-asset-rev": "^3.0.0",
"ember-cli": "~5.11.0",
"ember-cli": "~5.12.0",
"ember-cli-inject-live-reload": "^2.1.0",
"ember-cli-sri": "^2.1.1",
"ember-cli-terser": "^4.0.2",
"ember-disable-prototype-extensions": "^1.1.3",
"ember-load-initializers": "^2.1.1",
"ember-resolver": "^13.0.0",
"ember-load-initializers": "^3.0.1",
"ember-resolver": "^13.0.2",
"ember-source": "~5.5.0",
"ember-source-channel-url": "^3.0.0",
"loader.js": "^4.7.0",
"webpack": "^5.94.0"
"webpack": "^5.95.0"
},
"engines": {
"node": ">= 18",

View File

@ -3,7 +3,7 @@ import { tracked } from "@glimmer/tracking";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import didUpdate from "@ember/render-modifiers/modifiers/did-update";
import { service } from "@ember/service";
import { registerWaiter } from "@ember/test";
import { buildWaiter } from "@ember/test-waiters";
import { modifier } from "ember-modifier";
import ConditionalLoadingSpinner from "discourse/components/conditional-loading-spinner";
import loadAce from "discourse/lib/load-ace-editor";
@ -11,6 +11,7 @@ import { isTesting } from "discourse-common/config/environment";
import { bind } from "discourse-common/utils/decorators";
import I18n from "discourse-i18n";
const WAITER = buildWaiter("ace-editor");
const COLOR_VARS_REGEX =
/\$(primary|secondary|tertiary|quaternary|header_background|header_primary|highlight|danger|success|love)(\s|;|-(low|medium|high))/g;
@ -72,11 +73,10 @@ export default class AceEditor extends Component {
this.editor.getSession().setValue(this.args.content || "");
this.skipChangePropagation = false;
if (isTesting()) {
let finished = false;
registerWaiter(() => finished);
this.editor.renderer.once("afterRender", () => (finished = true));
}
const token = WAITER.beginAsync();
this.editor.renderer.once("afterRender", () => WAITER.endAsync(token));
return () => WAITER.endAsync(token);
});
constructor() {
@ -94,7 +94,7 @@ export default class AceEditor extends Component {
this.appEvents.on("ace:resize", this.resize);
window.addEventListener("resize", this.resize);
this._darkModeListener = window.matchMedia("(prefers-color-scheme: dark)");
this._darkModeListener.addListener(this.setAceTheme);
this._darkModeListener.addEventListener("change", this.setAceTheme);
}
willDestroy() {
@ -102,7 +102,7 @@ export default class AceEditor extends Component {
this.editor?.destroy();
this._darkModeListener?.removeListener(this.setAceTheme);
this._darkModeListener?.removeEventListener("change", this.setAceTheme);
window.removeEventListener("resize", this.resize);
this.appEvents.off("ace:resize", this.resize);
}

View File

@ -1,100 +1,110 @@
{{#each this.categories as |c|}}
<PluginOutlet
@name="category-box-before-each-box"
@outletArgs={{hash category=c}}
/>
<div
style={{category-color-variable c.color}}
data-category-id={{c.id}}
data-notification-level={{c.notificationLevelString}}
data-url={{c.url}}
class="category category-box category-box-{{c.slug}}
{{if c.isMuted 'muted'}}"
>
<div class="category-box-inner">
{{#unless c.isMuted}}
<div class="category-logo">
{{#if c.uploaded_logo.url}}
<CategoryLogo @category={{c}} />
{{/if}}
</div>
{{/unless}}
<div class="category-details">
<div class="category-box-heading">
<a class="parent-box-link" href={{c.url}}>
<h3>
<CategoryTitleBefore @category={{c}} />
{{#if c.read_restricted}}
{{d-icon this.lockIcon}}
{{/if}}
{{c.displayName}}
</h3>
</a>
</div>
<PluginOutlet
@name="categories-boxes-wrapper"
@outletArgs={{hash categories=this.categories}}
>
{{#each this.categories as |c|}}
<PluginOutlet
@name="category-box-before-each-box"
@outletArgs={{hash category=c}}
/>
<div
style={{category-color-variable c.color}}
data-category-id={{c.id}}
data-notification-level={{c.notificationLevelString}}
data-url={{c.url}}
class="category category-box category-box-{{c.slug}}
{{if c.isMuted 'muted'}}"
>
<div class="category-box-inner">
{{#unless c.isMuted}}
<div class="description">
{{html-safe c.description_excerpt}}
<div class="category-logo">
{{#if c.uploaded_logo.url}}
<CategoryLogo @category={{c}} />
{{/if}}
</div>
{{/unless}}
<div class="category-details">
<div class="category-box-heading">
<a class="parent-box-link" href={{c.url}}>
<h3>
<CategoryTitleBefore @category={{c}} />
{{#if c.read_restricted}}
{{d-icon this.lockIcon}}
{{/if}}
{{c.displayName}}
</h3>
</a>
</div>
{{#if c.isGrandParent}}
{{#each c.subcategories as |subcategory|}}
<div
data-category-id={{subcategory.id}}
data-notification-level={{subcategory.notificationLevelString}}
style={{border-color subcategory.color}}
class="subcategory with-subcategories
{{if subcategory.uploaded_logo.url 'has-logo' 'no-logo'}}"
>
<div class="subcategory-box-inner">
<CategoryTitleLink @tagName="h4" @category={{subcategory}} />
{{#if subcategory.subcategories}}
<div class="subcategories">
{{#each subcategory.subcategories as |subsubcategory|}}
{{#unless subsubcategory.isMuted}}
<span class="subcategory">
<CategoryTitleBefore @category={{subsubcategory}} />
{{category-link subsubcategory hideParent="true"}}
</span>
{{/unless}}
{{/each}}
</div>
{{/if}}
</div>
</div>
{{/each}}
{{else if c.subcategories}}
<div class="subcategories">
{{#each c.subcategories as |sc|}}
<a class="subcategory" href={{sc.url}}>
<span class="subcategory-image-placeholder">
{{#if sc.uploaded_logo.url}}
<CategoryLogo @category={{sc}} />
{{/if}}
</span>
{{category-link sc hideParent="true"}}
</a>
{{/each}}
{{#unless c.isMuted}}
<div class="description">
{{html-safe c.description_excerpt}}
</div>
{{/if}}
{{/unless}}
{{#if c.isGrandParent}}
{{#each c.subcategories as |subcategory|}}
<div
data-category-id={{subcategory.id}}
data-notification-level={{subcategory.notificationLevelString}}
style={{border-color subcategory.color}}
class="subcategory with-subcategories
{{if subcategory.uploaded_logo.url 'has-logo' 'no-logo'}}"
>
<div class="subcategory-box-inner">
<CategoryTitleLink
@tagName="h4"
@category={{subcategory}}
/>
{{#if subcategory.subcategories}}
<div class="subcategories">
{{#each subcategory.subcategories as |subsubcategory|}}
{{#unless subsubcategory.isMuted}}
<span class="subcategory">
<CategoryTitleBefore
@category={{subsubcategory}}
/>
{{category-link subsubcategory hideParent="true"}}
</span>
{{/unless}}
{{/each}}
</div>
{{/if}}
</div>
</div>
{{/each}}
{{else if c.subcategories}}
<div class="subcategories">
{{#each c.subcategories as |sc|}}
<a class="subcategory" href={{sc.url}}>
<span class="subcategory-image-placeholder">
{{#if sc.uploaded_logo.url}}
<CategoryLogo @category={{sc}} />
{{/if}}
</span>
{{category-link sc hideParent="true"}}
</a>
{{/each}}
</div>
{{/if}}
{{/unless}}
</div>
<PluginOutlet
@name="category-box-below-each-category"
@outletArgs={{hash category=c}}
/>
</div>
<PluginOutlet
@name="category-box-below-each-category"
@outletArgs={{hash category=c}}
/>
</div>
</div>
<PluginOutlet
@name="category-box-after-each-box"
@outletArgs={{hash category=c}}
/>
{{/each}}
<PluginOutlet
@name="category-box-after-each-box"
@outletArgs={{hash category=c}}
/>
{{/each}}
</PluginOutlet>
<PluginOutlet
@name="category-boxes-after-boxes"

View File

@ -1,49 +1,16 @@
{{#if this.categories}}
{{#if this.filteredCategories}}
<table class="category-list {{if this.showTopics 'with-topics'}}">
<thead>
<tr>
<th class="category"><span
role="heading"
aria-level="2"
id="categories-only-category"
>{{i18n "categories.category"}}</span></th>
<th class="topics">{{i18n "categories.topics"}}</th>
{{#if this.showTopics}}
<th class="latest">{{i18n "categories.latest"}}</th>
{{/if}}
</tr>
</thead>
<tbody aria-labelledby="categories-only-category">
{{#each this.categories as |category|}}
<ParentCategoryRow
@category={{category}}
@showTopics={{this.showTopics}}
/>
{{/each}}
</tbody>
</table>
{{/if}}
{{#if this.mutedCategories}}
<div class="muted-categories">
<a href class="muted-categories-link" {{on "click" this.toggleShowMuted}}>
<h3 class="muted-categories-heading">{{i18n "categories.muted"}}</h3>
{{#if this.mutedToggleIcon}}
{{d-icon this.mutedToggleIcon}}
{{/if}}
</a>
<table
class="category-list
{{if this.showTopics 'with-topics'}}
{{unless this.showMutedCategories 'hidden'}}"
>
<PluginOutlet
@name="categories-only-wrapper"
@outletArgs={{hash categories=this.categories}}
>
{{#if this.categories}}
{{#if this.filteredCategories}}
<table class="category-list {{if this.showTopics 'with-topics'}}">
<thead>
<tr>
<th class="category"><span
role="heading"
aria-level="2"
id="categories-only-category-muted"
id="categories-only-category"
>{{i18n "categories.category"}}</span></th>
<th class="topics">{{i18n "categories.topics"}}</th>
{{#if this.showTopics}}
@ -51,19 +18,61 @@
{{/if}}
</tr>
</thead>
<tbody aria-labelledby="categories-only-category-muted">
<tbody aria-labelledby="categories-only-category">
{{#each this.categories as |category|}}
<ParentCategoryRow
@category={{category}}
@showTopics={{this.showTopics}}
@listType="muted"
/>
{{/each}}
</tbody>
</table>
</div>
{{/if}}
{{#if this.mutedCategories}}
<div class="muted-categories">
<a
href
class="muted-categories-link"
{{on "click" this.toggleShowMuted}}
>
<h3 class="muted-categories-heading">{{i18n "categories.muted"}}</h3>
{{#if this.mutedToggleIcon}}
{{d-icon this.mutedToggleIcon}}
{{/if}}
</a>
<table
class="category-list
{{if this.showTopics 'with-topics'}}
{{unless this.showMutedCategories 'hidden'}}"
>
<thead>
<tr>
<th class="category"><span
role="heading"
aria-level="2"
id="categories-only-category-muted"
>{{i18n "categories.category"}}</span></th>
<th class="topics">{{i18n "categories.topics"}}</th>
{{#if this.showTopics}}
<th class="latest">{{i18n "categories.latest"}}</th>
{{/if}}
</tr>
</thead>
<tbody aria-labelledby="categories-only-category-muted">
{{#each this.categories as |category|}}
<ParentCategoryRow
@category={{category}}
@showTopics={{this.showTopics}}
@listType="muted"
/>
{{/each}}
</tbody>
</table>
</div>
{{/if}}
{{/if}}
{{/if}}
</PluginOutlet>
<PluginOutlet
@name="below-categories-only"

View File

@ -1,21 +1,26 @@
<section class="field category-name-fields">
{{#unless this.category.isUncategorizedCategory}}
<PluginOutlet
@name="category-name-fields-details"
@outletArgs={{hash category=this.category}}
>
<section class="field category-name-fields">
{{#unless this.category.isUncategorizedCategory}}
<section class="field-item">
<label>{{i18n "category.name"}}</label>
<TextField
@value={{this.category.name}}
@placeholderKey="category.name_placeholder"
@maxlength="50"
class="category-name"
/>
</section>
{{/unless}}
<section class="field-item">
<label>{{i18n "category.name"}}</label>
<label>{{i18n "category.slug"}}</label>
<TextField
@value={{this.category.name}}
@placeholderKey="category.name_placeholder"
@maxlength="50"
class="category-name"
@value={{this.category.slug}}
@placeholderKey="category.slug_placeholder"
@maxlength="255"
/>
</section>
{{/unless}}
<section class="field-item">
<label>{{i18n "category.slug"}}</label>
<TextField
@value={{this.category.slug}}
@placeholderKey="category.slug_placeholder"
@maxlength="255"
/>
</section>
</section>
</PluginOutlet>

View File

@ -222,9 +222,7 @@ export default class GlimmerSiteHeader extends Component {
}
).finished;
if (isTesting()) {
waitForPromise(animationFinished);
}
waitForPromise(animationFinished);
cloakElement.animate([{ opacity: 0 }], { fill: "forwards" });
cloakElement.style.display = "block";

View File

@ -3,7 +3,6 @@ import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import { waitForPromise } from "@ember/test-waiters";
import { create } from "virtual-dom";
import { isTesting } from "discourse-common/config/environment";
import { iconNode } from "discourse-common/lib/icon-library";
export default class JsonSchemaEditorModal extends Component {
@ -38,9 +37,7 @@ export default class JsonSchemaEditorModal extends Component {
@action
async buildJsonEditor(element) {
const promise = import("@json-editor/json-editor");
if (isTesting()) {
waitForPromise(promise);
}
waitForPromise(promise);
const { JSONEditor } = await promise;
JSONEditor.defaults.options.theme = "barebones";

View File

@ -77,7 +77,8 @@ export default class TopicAdminMenu extends Component {
return (
this.currentUser?.canManageTopic ||
this.details?.can_archive_topic ||
this.details?.can_close_topic
this.details?.can_close_topic ||
this.details?.can_split_merge_topic
);
}

View File

@ -1,39 +1,29 @@
import Component from "@glimmer/component";
import { hash } from "@ember/helper";
import { service } from "@ember/service";
import { htmlSafe } from "@ember/template";
import PluginOutlet from "discourse/components/plugin-outlet";
import coldAgeClass from "discourse/helpers/cold-age-class";
import concatClass from "discourse/helpers/concat-class";
import element from "discourse/helpers/element";
import formatDate from "discourse/helpers/format-date";
export default class ActivityColumn extends Component {
@service siteSettings;
get wrapperElement() {
return element(this.args.tagName ?? "td");
}
<template>
<this.wrapperElement
title={{htmlSafe @topic.bumpedAtTitle}}
class={{concatClass
"activity"
(coldAgeClass @topic.createdAt startDate=@topic.bumpedAt class="")
}}
...attributes
>
<a
href={{@topic.lastPostUrl}}
class="post-activity"
>{{! no whitespace
const ActivityColumn = <template>
<td
title={{htmlSafe @topic.bumpedAtTitle}}
class={{concatClass
"activity"
(coldAgeClass @topic.createdAt startDate=@topic.bumpedAt class="")
}}
...attributes
>
<a
href={{@topic.lastPostUrl}}
class="post-activity"
>{{! no whitespace
}}<PluginOutlet
@name="topic-list-before-relative-date"
@outletArgs={{hash topic=@topic}}
/>
{{~formatDate @topic.bumpedAt format="tiny" noTitle="true"~}}
</a>
</this.wrapperElement>
</template>
}
@name="topic-list-before-relative-date"
@outletArgs={{hash topic=@topic}}
/>
{{~formatDate @topic.bumpedAt format="tiny" noTitle="true"~}}
</a>
</td>
</template>;
export default ActivityColumn;

View File

@ -1,6 +1,5 @@
import { on } from "@ember/modifier";
import { htmlSafe } from "@ember/template";
import TopicEntrance from "discourse/components/topic-list/topic-entrance";
import TopicPostBadges from "discourse/components/topic-post-badges";
import TopicStatus from "discourse/components/topic-status";
import formatAge from "discourse/helpers/format-age";
@ -30,13 +29,11 @@ const FeaturedTopic = <template>
@url={{@topic.lastUnreadUrl}}
/>
<TopicEntrance @topic={{@topic}}>
<a
{{on "click" onTimestampClick}}
href={{@topic.lastPostUrl}}
class="last-posted-at"
>{{formatAge @topic.last_posted_at}}</a>
</TopicEntrance>
<a
{{on "click" onTimestampClick}}
href={{@topic.lastPostUrl}}
class="last-posted-at"
>{{formatAge @topic.last_posted_at}}</a>
</div>
</template>;

View File

@ -1,6 +1,5 @@
import Component from "@glimmer/component";
import { concat, hash } from "@ember/helper";
import { service } from "@ember/service";
import PluginOutlet from "discourse/components/plugin-outlet";
import PostsCountColumn from "discourse/components/topic-list/posts-count-column";
import TopicPostBadges from "discourse/components/topic-post-badges";
@ -16,12 +15,8 @@ import topicFeaturedLink from "discourse/helpers/topic-featured-link";
import topicLink from "discourse/helpers/topic-link";
export default class LatestTopicListItem extends Component {
@service appEvents;
get tagClassNames() {
if (this.args.topic.tags) {
return this.args.topic.tags.map((tagName) => `tag-${tagName}`);
}
return this.args.topic.tags?.map((tagName) => `tag-${tagName}`);
}
<template>

View File

@ -11,8 +11,6 @@ import i18n from "discourse-common/helpers/i18n";
export default class TopicList extends Component {
@service currentUser;
@service router;
@service siteSettings;
@tracked lastCheckedElementId;

View File

@ -2,7 +2,6 @@ import Component from "@glimmer/component";
import { hash } from "@ember/helper";
import { service } from "@ember/service";
import PluginOutlet from "discourse/components/plugin-outlet";
import TopicEntrance from "discourse/components/topic-list/topic-entrance";
import element from "discourse/helpers/element";
import number from "discourse/helpers/number";
import I18n from "discourse-i18n";
@ -55,17 +54,13 @@ export default class PostsCountColumn extends Component {
<this.wrapperElement
class="num posts-map posts {{this.likesHeat}} topic-list-data"
>
<TopicEntrance
@topic={{@topic}}
@title={{this.title}}
@triggerClass="btn-link posts-map badge-posts {{this.likesHeat}}"
>
<a href={{@topic.firstPostUrl}} class="badge-posts">
<PluginOutlet
@name="topic-list-before-reply-count"
@outletArgs={{hash topic=@topic}}
/>
{{number @topic.replyCount noTitle="true"}}
</TopicEntrance>
</a>
</this.wrapperElement>
</template>
}

View File

@ -0,0 +1,168 @@
import Component from "@glimmer/component";
import { hash } from "@ember/helper";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { service } from "@ember/service";
import PluginOutlet from "discourse/components/plugin-outlet";
import ActionList from "discourse/components/topic-list/action-list";
import ParticipantGroups from "discourse/components/topic-list/participant-groups";
import TopicExcerpt from "discourse/components/topic-list/topic-excerpt";
import TopicLink from "discourse/components/topic-list/topic-link";
import UnreadIndicator from "discourse/components/topic-list/unread-indicator";
import TopicPostBadges from "discourse/components/topic-post-badges";
import TopicStatus from "discourse/components/topic-status";
import categoryLink from "discourse/helpers/category-link";
import discourseTags from "discourse/helpers/discourse-tags";
import topicFeaturedLink from "discourse/helpers/topic-featured-link";
import { groupPath } from "discourse/lib/url";
import { bind } from "discourse-common/utils/decorators";
import I18n from "discourse-i18n";
export default class TopicCell extends Component {
@service currentUser;
@service messageBus;
constructor() {
super(...arguments);
if (this.includeUnreadIndicator) {
this.messageBus.subscribe(this.unreadIndicatorChannel, this.onMessage);
}
}
willDestroy() {
super.willDestroy(...arguments);
this.messageBus.unsubscribe(this.unreadIndicatorChannel, this.onMessage);
}
@bind
onMessage(data) {
const nodeClassList = document.querySelector(
`.indicator-topic-${data.topic_id}`
).classList;
nodeClassList.toggle("read", !data.show_indicator);
}
get unreadIndicatorChannel() {
return `/private-messages/unread-indicator/${this.args.topic.id}`;
}
get includeUnreadIndicator() {
return typeof this.args.topic.unread_by_group_member !== "undefined";
}
get unreadClass() {
return this.args.topic.unread_by_group_member ? "" : "read";
}
get newDotText() {
return this.currentUser?.trust_level > 0
? ""
: I18n.t("filters.new.lower_title");
}
get participantGroups() {
if (!this.args.topic.participant_groups) {
return [];
}
return this.args.topic.participant_groups.map((name) => ({
name,
url: groupPath(name),
}));
}
@action
onTitleFocus(event) {
event.target.classList.add("selected");
}
@action
onTitleBlur(event) {
event.target.classList.remove("selected");
}
<template>
<td class="main-link clearfix topic-list-data" colspan="1">
<PluginOutlet
@name="topic-list-before-link"
@outletArgs={{hash topic=@topic}}
/>
<span class="link-top-line">
{{~! no whitespace ~}}
<PluginOutlet
@name="topic-list-before-status"
@outletArgs={{hash topic=@topic}}
/>
{{~! no whitespace ~}}
<TopicStatus @topic={{@topic}} />
{{~! no whitespace ~}}
<TopicLink
{{on "focus" this.onTitleFocus}}
{{on "blur" this.onTitleBlur}}
@topic={{@topic}}
class="raw-link raw-topic-link"
/>
{{~#if @topic.featured_link~}}
&nbsp;
{{~topicFeaturedLink @topic}}
{{~/if~}}
<PluginOutlet
@name="topic-list-after-title"
@outletArgs={{hash topic=@topic}}
/>
{{~! no whitespace ~}}
<UnreadIndicator
@includeUnreadIndicator={{this.includeUnreadIndicator}}
@topicId={{@topic.id}}
class={{this.unreadClass}}
/>
{{~#if @showTopicPostBadges~}}
<TopicPostBadges
@unreadPosts={{@topic.unread_posts}}
@unseen={{@topic.unseen}}
@newDotText={{this.newDotText}}
@url={{@topic.lastUnreadUrl}}
/>
{{~/if~}}
</span>
<div class="link-bottom-line">
{{#unless @hideCategory}}
{{#unless @topic.isPinnedUncategorized}}
<PluginOutlet
@name="topic-list-before-category"
@outletArgs={{hash topic=@topic}}
/>
{{categoryLink @topic.category}}
{{/unless}}
{{/unless}}
{{discourseTags @topic mode="list" tagsForUser=@tagsForUser}}
{{#if this.participantGroups}}
<ParticipantGroups @groups={{this.participantGroups}} />
{{/if}}
<ActionList
@topic={{@topic}}
@postNumbers={{@topic.liked_post_numbers}}
@icon="heart"
class="likes"
/>
</div>
{{#if @expandPinned}}
<TopicExcerpt @topic={{@topic}} />
{{/if}}
<PluginOutlet
@name="topic-list-main-link-bottom"
@outletArgs={{hash topic=@topic}}
/>
</td>
</template>
}

View File

@ -1,111 +0,0 @@
import Component from "@glimmer/component";
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 DiscourseURL from "discourse/lib/url";
import icon from "discourse-common/helpers/d-icon";
import i18n from "discourse-common/helpers/i18n";
import I18n from "discourse-i18n";
import DMenu from "float-kit/components/d-menu";
function entranceDate(dt, showTime) {
const today = new Date();
if (dt.toDateString() === today.toDateString()) {
return moment(dt).format(I18n.t("dates.time"));
}
if (dt.getYear() === today.getYear()) {
// No year
return moment(dt).format(
showTime
? I18n.t("dates.long_date_without_year_with_linebreak")
: I18n.t("dates.long_no_year_no_time")
);
}
return moment(dt).format(
showTime
? I18n.t("dates.long_date_with_year_with_linebreak")
: I18n.t("dates.long_date_with_year_without_time")
);
}
export default class TopicEntrance extends Component {
@service historyStore;
get createdDate() {
return new Date(this.args.topic.created_at);
}
get bumpedDate() {
return new Date(this.args.topic.bumped_at);
}
get showTime() {
return (
this.bumpedDate.getTime() - this.createdDate.getTime() <
1000 * 60 * 60 * 24 * 2
);
}
get topDate() {
return entranceDate(this.createdDate, this.showTime);
}
get bottomDate() {
return entranceDate(this.bumpedDate, this.showTime);
}
@action
jumpTo(destination) {
this.historyStore.set("lastTopicIdViewed", this.args.topic.id);
DiscourseURL.routeTo(destination);
}
<template>
<DMenu
@title={{@title}}
@ariaLabel={{@title}}
@placement="center"
@autofocus={{true}}
@triggerClass={{@triggerClass}}
>
<:trigger>
{{yield}}
</:trigger>
<:content>
<div id="topic-entrance" class="--glimmer">
<button
{{on "click" (fn this.jumpTo @topic.url)}}
aria-label={{i18n
"topic_entrance.sr_jump_top_button"
date=this.topDate
}}
title={{i18n "topic_entrance.jump_top_button_title"}}
class="btn btn-default full jump-top"
>
{{icon "backward-step"}}
{{htmlSafe this.topDate}}
</button>
<button
{{on "click" (fn this.jumpTo @topic.lastPostUrl)}}
aria-label={{i18n
"topic_entrance.sr_jump_bottom_button"
date=this.bottomDate
}}
title={{i18n "topic_entrance.jump_bottom_button_title"}}
class="btn btn-default full jump-bottom"
>
{{htmlSafe this.bottomDate}}
{{icon "forward-step"}}
</button>
</div>
</:content>
</DMenu>
</template>
}

View File

@ -9,7 +9,6 @@ import icon from "discourse-common/helpers/d-icon";
import i18n from "discourse-common/helpers/i18n";
export default class TopicListHeaderColumn extends Component {
@service modal;
@service router;
get localizedName() {

View File

@ -8,16 +8,13 @@ import { service } from "@ember/service";
import { modifier } from "ember-modifier";
import { eq, gt } from "truth-helpers";
import PluginOutlet from "discourse/components/plugin-outlet";
import ActionList from "discourse/components/topic-list/action-list";
import ActivityColumn from "discourse/components/topic-list/activity-column";
import ParticipantGroups from "discourse/components/topic-list/participant-groups";
import PostCountOrBadges from "discourse/components/topic-list/post-count-or-badges";
import PostersColumn from "discourse/components/topic-list/posters-column";
import PostsCountColumn from "discourse/components/topic-list/posts-count-column";
import TopicCell from "discourse/components/topic-list/topic-cell";
import TopicExcerpt from "discourse/components/topic-list/topic-excerpt";
import TopicLink from "discourse/components/topic-list/topic-link";
import UnreadIndicator from "discourse/components/topic-list/unread-indicator";
import TopicPostBadges from "discourse/components/topic-post-badges";
import TopicStatus from "discourse/components/topic-status";
import { topicTitleDecorators } from "discourse/components/topic-title";
import avatar from "discourse/helpers/avatar";
@ -28,18 +25,12 @@ import formatDate from "discourse/helpers/format-date";
import number from "discourse/helpers/number";
import topicFeaturedLink from "discourse/helpers/topic-featured-link";
import { wantsNewWindow } from "discourse/lib/intercept-click";
import DiscourseURL, { groupPath } from "discourse/lib/url";
import DiscourseURL from "discourse/lib/url";
import icon 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";
export default class TopicListItem extends Component {
@service appEvents;
@service currentUser;
@service historyStore;
@service messageBus;
@service router;
@service site;
@service siteSettings;
@ -60,58 +51,10 @@ export default class TopicListItem extends Component {
}
});
constructor() {
super(...arguments);
if (this.includeUnreadIndicator) {
this.messageBus.subscribe(this.unreadIndicatorChannel, this.onMessage);
}
}
willDestroy() {
super.willDestroy(...arguments);
this.messageBus.unsubscribe(this.unreadIndicatorChannel, this.onMessage);
}
@bind
onMessage(data) {
const nodeClassList = document.querySelector(
`.indicator-topic-${data.topic_id}`
).classList;
nodeClassList.toggle("read", !data.show_indicator);
}
get unreadIndicatorChannel() {
return `/private-messages/unread-indicator/${this.args.topic.id}`;
}
get includeUnreadIndicator() {
return typeof this.args.topic.unread_by_group_member !== "undefined";
}
get isSelected() {
return this.args.selected?.includes(this.args.topic);
}
get participantGroups() {
if (!this.args.topic.participant_groups) {
return [];
}
return this.args.topic.participant_groups.map((name) => ({
name,
url: groupPath(name),
}));
}
get newDotText() {
return this.currentUser?.trust_level > 0
? ""
: I18n.t("filters.new.lower_title");
}
get tagClassNames() {
return this.args.topic.tags?.map((tagName) => `tag-${tagName}`);
}
@ -135,10 +78,6 @@ export default class TopicListItem extends Component {
return this.site.desktopView && this.args.focusLastVisitedTopic;
}
get unreadClass() {
return this.args.topic.unread_by_group_member ? "" : "read";
}
navigateToTopic(topic, href) {
this.historyStore.set("lastTopicIdViewed", topic.id);
DiscourseURL.routeTo(href || topic.url);
@ -154,16 +93,6 @@ export default class TopicListItem extends Component {
element.classList.add("highlighted");
}
@action
onTitleFocus(event) {
event.target.classList.add("selected");
}
@action
onTitleBlur(event) {
event.target.classList.remove("selected");
}
@action
applyTitleDecorators(element) {
const rawTopicLink = element.querySelector(".raw-topic-link");
@ -300,85 +229,13 @@ export default class TopicListItem extends Component {
</td>
{{/if}}
<td class="main-link clearfix topic-list-data" colspan="1">
<PluginOutlet
@name="topic-list-before-link"
@outletArgs={{hash topic=@topic}}
/>
<span class="link-top-line">
{{~! no whitespace ~}}
<PluginOutlet
@name="topic-list-before-status"
@outletArgs={{hash topic=@topic}}
/>
{{~! no whitespace ~}}
<TopicStatus @topic={{@topic}} />
{{~! no whitespace ~}}
<TopicLink
{{on "focus" this.onTitleFocus}}
{{on "blur" this.onTitleBlur}}
@topic={{@topic}}
class="raw-link raw-topic-link"
/>
{{~#if @topic.featured_link~}}
&nbsp;
{{~topicFeaturedLink @topic}}
{{~/if~}}
<PluginOutlet
@name="topic-list-after-title"
@outletArgs={{hash topic=@topic}}
/>
{{~! no whitespace ~}}
<UnreadIndicator
@includeUnreadIndicator={{this.includeUnreadIndicator}}
@topicId={{@topic.id}}
class={{this.unreadClass}}
/>
{{~#if @showTopicPostBadges~}}
<TopicPostBadges
@unreadPosts={{@topic.unread_posts}}
@unseen={{@topic.unseen}}
@newDotText={{this.newDotText}}
@url={{@topic.lastUnreadUrl}}
/>
{{~/if~}}
</span>
<div class="link-bottom-line">
{{#unless @hideCategory}}
{{#unless @topic.isPinnedUncategorized}}
<PluginOutlet
@name="topic-list-before-category"
@outletArgs={{hash topic=@topic}}
/>
{{categoryLink @topic.category}}
{{/unless}}
{{/unless}}
{{discourseTags @topic mode="list" tagsForUser=@tagsForUser}}
{{#if this.participantGroups}}
<ParticipantGroups @groups={{this.participantGroups}} />
{{/if}}
<ActionList
@topic={{@topic}}
@postNumbers={{@topic.liked_post_numbers}}
@icon="heart"
class="likes"
/>
</div>
{{#if this.expandPinned}}
<TopicExcerpt @topic={{@topic}} />
{{/if}}
<PluginOutlet
@name="topic-list-main-link-bottom"
@outletArgs={{hash topic=@topic}}
/>
</td>
<TopicCell
@topic={{@topic}}
@showTopicPostBadges={{@showTopicPostBadges}}
@hideCategory={{@hideCategory}}
@tagsForUser={{@tagsForUser}}
@expandPinned={{this.expandPinned}}
/>
<PluginOutlet
@name="topic-list-after-main-link"

View File

@ -2,7 +2,6 @@ import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import { service } from "@ember/service";
import optionalService from "discourse/lib/optional-service";
import { bind } from "discourse-common/utils/decorators";
export default class TopicTimeline extends Component {
@ -13,8 +12,6 @@ export default class TopicTimeline extends Component {
@tracked docked = false;
@tracked dockedBottom = false;
adminTools = optionalService();
constructor() {
super(...arguments);

View File

@ -15,7 +15,7 @@ export default class UserController extends Controller.extend(CanCheckEmails) {
@service currentUser;
@service router;
@service dialog;
@optionalService("admin-tools") adminTools;
@optionalService adminTools;
@controller("user-notifications") userNotifications;

View File

@ -3,7 +3,7 @@ import { concat } from "@ember/helper";
import { action } from "@ember/object";
import FKLabel from "discourse/form-kit/components/fk/label";
import FKMeta from "discourse/form-kit/components/fk/meta";
import FormText from "discourse/form-kit/components/fk/text";
import FKText from "discourse/form-kit/components/fk/text";
import concatClass from "discourse/helpers/concat-class";
import i18n from "discourse-common/helpers/i18n";
@ -60,10 +60,10 @@ export default class FKControlWrapper extends Component {
</FKLabel>
{{/if}}
{{#if @field.subtitle}}
<FormText
class="form-kit__container-subtitle"
>{{@field.subtitle}}</FormText>
{{#if @field.description}}
<FKText
class="form-kit__container-description"
>{{@field.description}}</FKText>
{{/if}}
<div
@ -93,12 +93,7 @@ export default class FKControlWrapper extends Component {
{{yield components}}
</@component>
<FKMeta
@description={{@field.description}}
@value={{@value}}
@field={{@field}}
@error={{this.error}}
/>
<FKMeta @value={{@value}} @field={{@field}} @error={{this.error}} />
</div>
</div>
</template>

View File

@ -3,7 +3,6 @@ import { hash } from "@ember/helper";
import FKFieldset from "discourse/form-kit/components/fk/fieldset";
import FKControlRadioGroupRadio from "./radio-group/radio";
// eslint-disable-next-line ember/no-empty-glimmer-component-classes
export default class FKControlRadioGroup extends Component {
static controlType = "radio-group";

View File

@ -49,7 +49,6 @@ export default class FKField extends Component {
this.field = this.args.registerField(this.name, {
triggerRevalidationFor: this.args.triggerRevalidationFor,
title: this.args.title,
subtitle: this.args.subtitle,
description: this.args.description,
showTitle: this.args.showTitle,
collectionIndex: this.args.collectionIndex,

View File

@ -1,7 +1,6 @@
import Component from "@glimmer/component";
import FKCharCounter from "discourse/form-kit/components/fk/char-counter";
import FKErrors from "discourse/form-kit/components/fk/errors";
import FKText from "discourse/form-kit/components/fk/text";
export default class FKMeta extends Component {
get shouldRenderCharCounter() {
@ -9,12 +8,7 @@ export default class FKMeta extends Component {
}
get shouldRenderMeta() {
return (
this.showMeta &&
(this.shouldRenderCharCounter ||
this.args.error ||
this.args.description?.length)
);
return this.showMeta && (this.shouldRenderCharCounter || this.args.error);
}
get showMeta() {
@ -26,8 +20,6 @@ export default class FKMeta extends Component {
<div class="form-kit__meta">
{{#if @error}}
<FKErrors @id={{@field.errorId}} @error={{@error}} />
{{else if @description}}
<FKText class="form-kit__meta-description">{{@description}}</FKText>
{{/if}}
{{#if this.shouldRenderCharCounter}}

View File

@ -12,11 +12,8 @@ export default {
* with the hashtag type via api.registerHashtagType. The default
* ones in core are CategoryHashtagType and TagHashtagType.
*/
initialize(owner) {
this.site = owner.lookup("service:site");
initialize() {
const cssTag = document.createElement("style");
cssTag.type = "text/css";
cssTag.id = "hashtag-css-generator";
cssTag.innerHTML = Object.values(getHashtagTypeClasses())
.map((hashtagType) => hashtagType.generatePreloadedCssClasses())

View File

@ -8,14 +8,6 @@ export default {
const capabilities = owner.lookup("service:capabilities");
if (siteSettings.composer_media_optimization_image_enabled) {
// NOTE: There are various performance issues with the Canvas
// in iOS Safari that are causing crashes when processing images
// with spikes of over 100% CPU usage. The cause of this is unknown,
// but profiling points to CanvasRenderingContext2D.getImageData()
// and CanvasRenderingContext2D.drawImage().
//
// Until Safari makes some progress with OffscreenCanvas or other
// alternatives we cannot support this workflow.
if (
capabilities.isIOS &&
!siteSettings.composer_ios_media_optimisation_image_enabled
@ -23,6 +15,22 @@ export default {
return;
}
// Restrict feature to browsers that support OffscreenCanvas
if (typeof OffscreenCanvas === "undefined") {
return;
}
if (!("createImageBitmap" in self)) {
return;
}
// prior to v18, Safari has WASM memory growth bugs
// eg https://github.com/emscripten-core/emscripten/issues/19144
let match = window.navigator.userAgent.match(/Mobile\/([0-9]+)\./);
let safariVersion = match ? parseInt(match[1], 10) : null;
if (capabilities.isSafari && safariVersion && safariVersion < 18) {
return;
}
addComposerUploadPreProcessor(
UppyMediaOptimization,
({ isMobileDevice }) => {

View File

@ -7,10 +7,10 @@ export default {
Sharing.addSource({
id: "twitter",
icon: "fab-twitter",
icon: "fab-x-twitter",
generateUrl(link, title, quote = "") {
const text = quote ? `"${quote}" -- ` : title;
return `http://twitter.com/intent/tweet?url=${encodeURIComponent(
return `http://x.com/intent/tweet?url=${encodeURIComponent(
link
)}&text=${encodeURIComponent(text)}`;
},

View File

@ -4,23 +4,39 @@ import discourseLater from "discourse-common/lib/later";
// Send bg color to webview so iOS status bar matches site theme
export default {
after: "inject-objects",
retryCount: 0,
initialize(owner) {
const caps = owner.lookup("service:capabilities");
if (caps.isAppWebview) {
window
.matchMedia("(prefers-color-scheme: dark)")
.addListener(this.updateAppBackground);
.addEventListener("change", this.updateAppBackground);
this.updateAppBackground();
}
},
updateAppBackground() {
updateAppBackground(delay = 500) {
discourseLater(() => {
const header = document.querySelector(".d-header-wrap .d-header");
if (header) {
const styles = window.getComputedStyle(header);
postRNWebviewMessage("headerBg", styles.backgroundColor);
if (this.headerBgColor()) {
postRNWebviewMessage("headerBg", this.headerBgColor());
} else {
this.retry();
}
}, 500);
}, delay);
},
headerBgColor() {
const header = document.querySelector(".d-header-wrap .d-header");
if (header) {
return window.getComputedStyle(header)?.backgroundColor;
}
},
retry() {
if (this.retryCount < 2) {
this.retryCount++;
this.updateAppBackground(1000);
}
},
};

View File

@ -1,17 +1,13 @@
import DEBUG from "@glimmer/env";
import { registerWaiter } from "@ember/test";
import { isTesting } from "discourse-common/config/environment";
import { buildWaiter } from "@ember/test-waiters";
const WAITER = buildWaiter("after-frame-paint");
/**
* Runs `callback` shortly after the next browser Frame is produced.
* ref: https://webperf.tips/tip/measuring-paint-time
*/
export default function runAfterFramePaint(callback) {
let done = false;
if (DEBUG && isTesting()) {
registerWaiter(() => done);
}
const token = WAITER.beginAsync();
// Queue a "before Render Steps" callback via requestAnimationFrame.
requestAnimationFrame(() => {
@ -21,7 +17,7 @@ export default function runAfterFramePaint(callback) {
// Setup the callback to run in a Task
messageChannel.port1.onmessage = () => {
done = true;
WAITER.endAsync(token);
callback();
};

View File

@ -1,10 +1,7 @@
import { waitForPromise } from "@ember/test-waiters";
import { isTesting } from "discourse-common/config/environment";
export default async function loadAce() {
const promise = import("discourse/static/ace-editor-bundle");
if (isTesting()) {
waitForPromise(promise);
}
waitForPromise(promise);
return await promise;
}

View File

@ -1,32 +1,28 @@
import { run } from "@ember/runloop";
import { registerWaiter } from "@ember/test";
import { buildWaiter } from "@ember/test-waiters";
import { Promise } from "rsvp";
import { ajax } from "discourse/lib/ajax";
import { PUBLIC_JS_VERSIONS } from "discourse/lib/public-js-versions";
import { isTesting } from "discourse-common/config/environment";
import getURL, { getURLWithCDN } from "discourse-common/lib/get-url";
const WAITER = buildWaiter("load-script");
const _loaded = {};
const _loading = {};
function loadWithTag(path, cb) {
const head = document.getElementsByTagName("head")[0];
let finished = false;
let s = document.createElement("script");
s.src = path;
if (isTesting()) {
registerWaiter(() => finished);
}
const token = WAITER.beginAsync();
// Don't leave it hanging if something goes wrong
s.onerror = function () {
finished = true;
WAITER.endAsync(token);
};
s.onload = s.onreadystatechange = function (_, abort) {
finished = true;
if (
abort ||
!s.readyState ||
@ -38,6 +34,8 @@ function loadWithTag(path, cb) {
run(null, cb);
}
}
WAITER.endAsync(token);
};
head.appendChild(s);

View File

@ -1,35 +1,5 @@
import { Promise } from "rsvp";
import { helperContext } from "discourse-common/lib/helpers";
// Chrome and Firefox use a native method to do Image -> Bitmap Array (it happens of the main thread!)
// Safari < 15 uses the `<img async>` element due to https://bugs.webkit.org/show_bug.cgi?id=182424
// Safari > 15 still uses `<img async>` due to their buggy createImageBitmap not handling EXIF rotation
async function fileToDrawable(file) {
const caps = helperContext().capabilities;
if ("createImageBitmap" in self && !caps.isApple) {
return await createImageBitmap(file);
} else {
const url = URL.createObjectURL(file);
const img = new Image();
img.decoding = "async";
img.src = url;
const loaded = new Promise((resolve, reject) => {
img.onload = () => resolve();
img.onerror = () => reject(Error("Image loading error"));
});
if (img.decode) {
// Nice off-thread way supported in Safari/Chrome.
// Safari throws on decode if the source is SVG.
// https://bugs.webkit.org/show_bug.cgi?id=188347
await img.decode().catch(() => null);
}
// Always await loaded, as we may have bailed due to the Safari bug above.
await loaded;
return img;
}
return await createImageBitmap(file);
}
function drawableToImageData(drawable) {
@ -40,17 +10,7 @@ function drawableToImageData(drawable) {
sw = width,
sh = height;
const offscreenCanvasSupported = typeof OffscreenCanvas !== "undefined";
// Make canvas same size as image
let canvas;
if (offscreenCanvasSupported) {
canvas = new OffscreenCanvas(width, height);
} else {
canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
}
let canvas = new OffscreenCanvas(width, height);
// Draw image onto canvas
const ctx = canvas.getContext("2d");
@ -60,10 +20,6 @@ function drawableToImageData(drawable) {
ctx.drawImage(drawable, sx, sy, sw, sh, 0, 0, width, height);
const imageData = ctx.getImageData(0, 0, width, height);
if (!offscreenCanvasSupported) {
canvas.remove();
}
return imageData;
}

View File

@ -2,8 +2,16 @@ import { computed } from "@ember/object";
import { getOwner } from "@ember/owner";
import { dasherize } from "@ember/string";
export default function (name) {
return computed(function (defaultName) {
export default function (target, name, descriptor) {
name ??= target;
const decorator = computed(function (defaultName) {
return getOwner(this).lookup(`service:${name || dasherize(defaultName)}`);
});
if (descriptor) {
return decorator(target, name, descriptor);
} else {
return decorator;
}
}

View File

@ -3,7 +3,7 @@
// docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md whenever you change the version
// using the format described at https://keepachangelog.com/en/1.0.0/.
export const PLUGIN_API_VERSION = "1.37.1";
export const PLUGIN_API_VERSION = "1.37.2";
import $ from "jquery";
import { h } from "virtual-dom";
@ -2646,7 +2646,7 @@ class PluginApi {
/**
* Changes the lock icon used for a sidebar category section link to indicate that a category is read restricted.
*
* @param {String} Name of a FontAwesome 5 icon
* @param {String} Name of a FontAwesome icon
*/
registerCustomCategorySectionLinkLockIcon(icon) {
return registerCustomCategoryLockIcon(icon);
@ -2670,7 +2670,7 @@ class PluginApi {
* @param {string} arg.categoryId - The id of the category
* @param {string} arg.prefixType - The type of prefix to use. Can be "icon", "image", "text" or "span".
* @param {string} arg.prefixValue - The value of the prefix to use.
* For "icon", pass in the name of a FontAwesome 5 icon.
* For "icon", pass in the name of a FontAwesome icon.
* For "image", pass in the src of the image.
* For "text", pass in the text to display.
* For "span", pass in an array containing two hex color values. Example: `[FF0000, 000000]`.
@ -2706,7 +2706,7 @@ class PluginApi {
*
* @param {Object} arg - An object
* @param {string} arg.tagName - The name of the tag
* @param {string} arg.prefixValue - The name of a FontAwesome 5 icon.
* @param {string} arg.prefixValue - The name of a FontAwesome icon.
* @param {string} arg.prefixColor - The color represented using hexadecimal to use for the prefix. Example: "#FF0000" or "#FFF".
*/
registerCustomTagSectionLinkPrefixIcon({
@ -3002,7 +3002,7 @@ class PluginApi {
* return class extends UserMenuTab {
* id = "custom-tab-id";
* panelComponent = MyCustomPanelGlimmerComponent;
* icon = "some-fa5-icon";
* icon = "some-fa-icon";
*
* get shouldDisplay() {
* return this.siteSettings.enable_custom_tab && this.currentUser.admin;

View File

@ -16,7 +16,7 @@ export let secondaryCustomSectionLinks = [];
* @param {string} [args.route] - The Ember route name to generate the href attribute for the link.
* @param {string} [args.href] - The href attribute for the link.
* @param {string} [args.title] - The title attribute for the link.
* @param {string} [args.icon] - The FontAwesome 5 icon to display for the link.
* @param {string} [args.icon] - The FontAwesome icon to display for the link.
* @param {Boolean} [secondary] - Determines whether the section link should be added to the main or secondary section in the "More..." links drawer.
*/
export function addSectionLink(args, secondary) {

View File

@ -58,4 +58,5 @@
</span>
</div>
</div>
{{~raw-plugin-outlet name="topic-list-after-columns"}}
</td>

View File

@ -29,6 +29,8 @@ export default class MediaOptimizationWorkerService extends Service {
workerUrl = getAbsoluteURL("/javascripts/media-optimization-worker.js");
currentComposerUploadData = null;
promiseResolvers = null;
workerDoneCount = 0;
workerPendingCount = 0;
async optimizeImage(data, opts = {}) {
this.promiseResolvers = this.promiseResolvers || {};
@ -98,6 +100,7 @@ export default class MediaOptimizationWorkerService extends Service {
},
[imageData.data.buffer]
);
this.workerPendingCount++;
});
}
@ -147,7 +150,9 @@ export default class MediaOptimizationWorkerService extends Service {
this.workerInstalled = false;
this.worker.terminate();
this.worker = null;
this.workerDoneCount = 0;
}
this.workerPendingCount = 0;
}
registerMessageHandler() {
@ -163,6 +168,13 @@ export default class MediaOptimizationWorkerService extends Service {
this.promiseResolvers[e.data.fileId](optimizedFile);
this.workerDoneCount++;
this.workerPendingCount--;
if (this.workerDoneCount > 4 && this.workerPendingCount === 0) {
this.logIfDebug("Terminating worker to release memory in WASM.");
this.stopWorker();
}
break;
case "error":
this.logIfDebug(
@ -174,6 +186,7 @@ export default class MediaOptimizationWorkerService extends Service {
}
this.promiseResolvers[e.data.fileId]();
this.workerPendingCount--;
break;
case "installed":
this.logIfDebug("Worker installed.");

View File

@ -1,32 +1,43 @@
{{body-class "user-badges-page"}}
<section class="user-content" id="user-content">
{{#if this.siteSettings.max_favorite_badges}}
<p class="favorite-count">
{{i18n
"badges.favorite_count"
count=this.favoriteBadges.length
max=this.siteSettings.max_favorite_badges
}}
</p>
{{/if}}
<PluginOutlet
@name="user-badges-content"
@outletArgs={{hash
sortedBadges=this.sortedBadges
maxFavBadges=this.siteSettings.max_favorite_badges
favoriteBadges=this.favoriteBadges
canFavoriteMoreBadges=this.canFavoriteMoreBadges
favorite=this.favorite
}}
>
{{#if this.siteSettings.max_favorite_badges}}
<p class="favorite-count">
{{i18n
"badges.favorite_count"
count=this.favoriteBadges.length
max=this.siteSettings.max_favorite_badges
}}
</p>
{{/if}}
<div class="badge-group-list">
{{#each this.sortedBadges as |ub|}}
<BadgeCard
@badge={{ub.badge}}
@count={{ub.count}}
@canFavorite={{ub.can_favorite}}
@isFavorite={{ub.is_favorite}}
@username={{this.username}}
@canFavoriteMoreBadges={{this.canFavoriteMoreBadges}}
@onFavoriteClick={{action "favorite" ub}}
@filterUser="true"
<div class="badge-group-list">
{{#each this.sortedBadges as |ub|}}
<BadgeCard
@badge={{ub.badge}}
@count={{ub.count}}
@canFavorite={{ub.can_favorite}}
@isFavorite={{ub.is_favorite}}
@username={{this.username}}
@canFavoriteMoreBadges={{this.canFavoriteMoreBadges}}
@onFavoriteClick={{action "favorite" ub}}
@filterUser="true"
/>
{{/each}}
<PluginOutlet
@name="after-user-profile-badges"
@outletArgs={{hash user=this.user.model}}
/>
{{/each}}
<PluginOutlet
@name="after-user-profile-badges"
@outletArgs={{hash user=this.user.model}}
/>
</div>
</div>
</PluginOutlet>
</section>

View File

@ -62,6 +62,10 @@ export const ButtonClass = {
attributes["aria-pressed"] = attrs.ariaPressed;
}
if (attrs.ariaLive) {
attributes["aria-live"] = attrs.ariaLive;
}
if (attrs.tabAttrs) {
const tab = attrs.tabAttrs;
attributes["aria-selected"] = tab["aria-selected"];

View File

@ -354,6 +354,7 @@ registerButton("copyLink", () => {
icon: "d-post-share",
className: "post-action-menu__copy-link",
title: "post.controls.copy_title",
ariaLive: "polite",
};
});

View File

@ -13,6 +13,7 @@ const DeprecationSilencer = require("deprecation-silencer");
const { compatBuild } = require("@embroider/compat");
const { Webpack } = require("@embroider/webpack");
const { StatsWriterPlugin } = require("webpack-stats-plugin");
const { RetryChunkLoadPlugin } = require("webpack-retry-chunk-load-plugin");
const withSideWatch = require("./lib/with-side-watch");
const RawHandlebarsCompiler = require("discourse-hbr/raw-handlebars-compiler");
const crypto = require("crypto");
@ -223,6 +224,11 @@ module.exports = function (defaults) {
return JSON.stringify(output, null, 2);
},
}),
new RetryChunkLoadPlugin({
retryDelay: 200,
maxRetries: 2,
chunks: ["assets/discourse.js"],
}),
],
},
},

View File

@ -15,11 +15,11 @@
"test": "ember test"
},
"dependencies": {
"@faker-js/faker": "^9.0.0",
"@faker-js/faker": "^9.0.3",
"@glimmer/syntax": "^0.92.3",
"@highlightjs/cdn-assets": "^11.10.0",
"@json-editor/json-editor": "2.15.1",
"@messageformat/core": "^3.3.0",
"@messageformat/core": "^3.4.0",
"@messageformat/runtime": "^3.0.1",
"ace-builds": "^1.36.2",
"decorator-transforms": "^2.0.0",
@ -35,8 +35,8 @@
"pretty-text": "workspace:1.0.0"
},
"devDependencies": {
"@babel/core": "^7.25.2",
"@babel/standalone": "^7.25.6",
"@babel/core": "^7.25.7",
"@babel/standalone": "^7.25.7",
"@colors/colors": "^1.6.0",
"@discourse/backburner.js": "^2.7.1-0",
"@discourse/itsatrap": "^2.0.10",
@ -47,17 +47,17 @@
"@ember/string": "^4.0.0",
"@ember/test-helpers": "^4.0.4",
"@ember/test-waiters": "^3.1.0",
"@embroider/compat": "^3.6.1",
"@embroider/core": "^3.4.15",
"@embroider/macros": "^1.13.1",
"@embroider/compat": "^3.6.2",
"@embroider/core": "^3.4.17",
"@embroider/macros": "^1.16.7",
"@embroider/router": "^2.1.8",
"@embroider/webpack": "^4.0.5",
"@floating-ui/dom": "^1.6.10",
"@embroider/webpack": "^4.0.6",
"@floating-ui/dom": "^1.6.11",
"@glimmer/component": "^1.1.2",
"@glimmer/tracking": "^1.1.2",
"@popperjs/core": "^2.11.8",
"@swc/core": "^1.7.26",
"@types/jquery": "^3.5.30",
"@types/jquery": "^3.5.31",
"@types/qunit": "^2.19.10",
"@types/rsvp": "^4.0.9",
"@uppy/aws-s3": "3.0.6",
@ -81,10 +81,10 @@
"discourse-i18n": "workspace:1.0.0",
"discourse-markdown-it": "workspace:1.0.0",
"discourse-plugins": "workspace:1.0.0",
"ember-auto-import": "^2.7.4",
"ember-auto-import": "^2.8.1",
"ember-buffered-proxy": "^2.1.1",
"ember-cached-decorator-polyfill": "^1.0.2",
"ember-cli": "~5.11.0",
"ember-cli": "~5.12.0",
"ember-cli-app-version": "^7.0.0",
"ember-cli-babel": "^8.2.0",
"ember-cli-deprecation-workflow": "^3.0.2",
@ -95,36 +95,37 @@
"ember-cli-terser": "^4.0.2",
"ember-decorators": "^6.1.1",
"ember-exam": "^9.0.0",
"ember-load-initializers": "^2.1.1",
"ember-load-initializers": "^3.0.1",
"ember-modifier": "^4.2.0",
"ember-on-resize-modifier": "^2.0.2",
"ember-production-deprecations": "workspace:1.0.0",
"ember-qunit": "^8.1.0",
"ember-source": "~5.5.0",
"ember-template-imports": "^4.1.1",
"ember-template-imports": "^4.1.2",
"ember-test-selectors": "^7.0.0",
"float-kit": "workspace:1.0.0",
"html-entities": "^2.5.2",
"imports-loader": "^5.0.0",
"jquery": "^3.7.1",
"js-yaml": "^4.1.0",
"jsuites": "^5.6.0",
"jsuites": "^5.6.5",
"loader.js": "^4.7.0",
"make-plural": "^7.4.0",
"message-bus-client": "^4.3.8",
"pretender": "^3.4.7",
"qunit": "^2.22.0",
"qunit-dom": "^3.2.0",
"qunit-dom": "^3.2.1",
"sass": "^1.77.7",
"select-kit": "workspace:1.0.0",
"sinon": "^19.0.1",
"sinon": "^19.0.2",
"source-map": "^0.7.4",
"terser": "^5.32.0",
"terser": "^5.34.1",
"testem": "^3.15.2",
"truth-helpers": "workspace:1.0.0",
"util": "^0.12.5",
"virtual-dom": "^2.1.1",
"webpack": "^5.94.0",
"webpack": "^5.95.0",
"webpack-retry-chunk-load-plugin": "^3.1.1",
"webpack-stats-plugin": "^1.1.3",
"xss": "^1.0.15"
},

View File

@ -4,7 +4,6 @@ import {
acceptance,
count,
exists,
query,
} from "discourse/tests/helpers/qunit-helpers";
import selectKit from "discourse/tests/helpers/select-kit-helper";
@ -60,13 +59,6 @@ acceptance("Dashboard", function (needs) {
exists(".admin-report.new-contributors"),
"new-contributors report"
);
assert.strictEqual(
query(
".section.dashboard-problems .problem-messages ul li:first-child"
).innerHTML.trim(),
"Houston...",
"displays problems"
);
});
test("moderation tab", async function (assert) {

View File

@ -142,12 +142,6 @@ class FieldHelper {
}
}
hasSubtitle(subtitle, message) {
this.context
.dom(this.element.querySelector(".form-kit__container-subtitle"))
.hasText(subtitle, message);
}
hasDescription(description, message) {
switch (this.element.dataset.controlType) {
case "checkbox": {
@ -162,7 +156,7 @@ class FieldHelper {
}
default: {
this.context
.dom(this.element.querySelector(".form-kit__meta-description"))
.dom(this.element.querySelector(".form-kit__container-description"))
.hasText(description, message);
}
}

View File

@ -11,7 +11,7 @@ module(
test("default", async function (assert) {
await render(<template>
<Form as |form|>
<form.Field @name="foo" @title="Foo" @subtitle="Bar" as |field|>
<form.Field @name="foo" @title="Foo" @description="Bar" as |field|>
<field.Custom>
<input class="custom-test" />
</field.Custom>
@ -20,7 +20,7 @@ module(
</template>);
assert.dom(".form-kit__container-title").hasText("Foo (optional)");
assert.dom(".form-kit__container-subtitle").hasText("Bar");
assert.dom(".form-kit__container-description").hasText("Bar");
});
}
);

View File

@ -35,18 +35,6 @@ module("Integration | Component | FormKit | Field", function (hooks) {
assert.dom(".form-kit__row .form-kit__col.--col-8").hasText("Test");
});
test("@subtitle", async function (assert) {
await render(<template>
<Form as |form|>
<form.Field @name="foo" @title="Foo" @subtitle="foo foo" as |field|>
<field.Input />
</form.Field>
</Form>
</template>);
assert.form().field("foo").hasSubtitle("foo foo");
});
test("@description", async function (assert) {
await render(<template>
<Form as |form|>

View File

@ -0,0 +1,63 @@
import { tracked } from "@glimmer/tracking";
import { render, settled } from "@ember/test-helpers";
import { module, test } from "qunit";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import HighlightedCode from "admin/components/highlighted-code";
module("Integration | Component | highlighted-code", function (hooks) {
setupRenderingTest(hooks);
test("highlighting code", async function (assert) {
await render(<template>
<HighlightedCode @lang="ruby" @code="def test; end" />
</template>);
assert.dom("code.lang-ruby.hljs .hljs-keyword").hasText("def");
});
test("large code blocks are not highlighted", async function (assert) {
const longCodeBlock = "puts a\n".repeat(15000);
await render(<template>
<HighlightedCode @lang="ruby" @code={{longCodeBlock}} />
</template>);
assert.dom("pre code").hasText(longCodeBlock);
});
test("highlighting code with lang=auto", async function (assert) {
await render(<template>
<HighlightedCode @lang="auto" @code="def test; end" />
</template>);
assert.dom("code.hljs").hasNoClass("lang-auto", "lang-auto is removed");
assert.dom("code.hljs").hasClass(/language-/, "language is detected");
assert
.dom("code.hljs")
.hasNoAttribute(
"data-unknown-hljs-lang",
"language is found from language- class"
);
});
test("re-highlights the code when it changes", async function (assert) {
class State {
@tracked code = "def foo; end";
}
const testState = new State();
await render(<template>
<HighlightedCode @lang="ruby" @code={{testState.code}} />
{{testState.code}}
</template>);
assert.dom("code.lang-ruby.hljs .hljs-title").hasText("foo");
testState.code = "def bar; end";
await settled();
assert.dom("code.lang-ruby.hljs .hljs-title").hasText("bar");
});
});

View File

@ -1,61 +0,0 @@
import { render } from "@ember/test-helpers";
import { hbs } from "ember-cli-htmlbars";
import { module, test } from "qunit";
import highlightSyntax from "discourse/lib/highlight-syntax";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { query } from "discourse/tests/helpers/qunit-helpers";
const LONG_CODE_BLOCK = "puts a\n".repeat(15000);
module("Integration | Component | highlighted-code", function (hooks) {
setupRenderingTest(hooks);
test("highlighting code", async function (assert) {
this.set("code", "def test; end");
await render(hbs`<HighlightedCode @lang="ruby" @code={{this.code}} />`);
assert.strictEqual(
query("code.lang-ruby.hljs .hljs-keyword").innerText.trim(),
"def"
);
});
test("large code blocks are not highlighted", async function (assert) {
this.set("code", LONG_CODE_BLOCK);
await render(hbs`<HighlightedCode @lang="ruby" @code={{this.code}} />`);
assert.strictEqual(query("code").innerText.trim(), LONG_CODE_BLOCK.trim());
});
test("highlighting code with lang=auto", async function (assert) {
this.set("code", "def test; end");
await render(hbs`<HighlightedCode @lang="auto" @code={{this.code}} />`);
const codeElement = query("code.hljs");
assert.notOk(
codeElement.classList.contains("lang-auto"),
"lang-auto is removed"
);
assert.ok(
Array.from(codeElement.classList).some((className) => {
return className.startsWith("language-");
}),
"language is detected"
);
await highlightSyntax(
codeElement.parentElement, // <pre>
this.siteSettings,
this.session
);
assert.notOk(
codeElement.dataset.unknownHljsLang,
"language is found from language- class"
);
});
});

View File

@ -8,6 +8,7 @@ import raw from "discourse/helpers/raw";
import rawRenderGlimmer from "discourse/lib/raw-render-glimmer";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { compile } from "discourse-common/lib/raw-handlebars";
import { RUNTIME_OPTIONS } from "discourse-common/lib/raw-handlebars-helpers";
import {
addRawTemplate,
removeRawTemplate,
@ -121,4 +122,26 @@ module("Integration | Helper | raw", function (hooks) {
assert.dom("span").hasText("foo 1 foo 2");
});
test("#each helper handles getters", async function (assert) {
const template = `
{{#each items as |item|}}
{{string}} {{item}}
{{/each}}
`;
const compiledTemplate = compile(template);
class Test {
items = [1, 2];
get string() {
return "foo";
}
}
const object = new Test();
const output = compiledTemplate(object, RUNTIME_OPTIONS);
assert.true(/\s*foo 1\s*foo 2\s*/.test(output));
});
});

View File

@ -22,8 +22,8 @@ module("Unit | Utility | icon-library", function (hooks) {
});
test("convert icon names", function (assert) {
const fa5Icon = convertIconClass("fab fa-facebook");
assert.ok(iconHTML(fa5Icon).includes("fab-facebook"), "FA 5 syntax");
const faIcon = convertIconClass("fab fa-facebook");
assert.ok(iconHTML(faIcon).includes("fab-facebook"), "FA syntax");
const iconC = convertIconClass(" fab fa-facebook ");
assert.ok(!iconHTML(iconC).includes(" "), "trims whitespace");

View File

@ -0,0 +1,59 @@
import Component from "@ember/component";
import Service from "@ember/service";
import { render } from "@ember/test-helpers";
import { hbs } from "ember-cli-htmlbars";
import { module, test } from "qunit";
import optionalService from "discourse/lib/optional-service";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
class FooService extends Service {
name = "foo";
}
class BarService extends Service {
name = "bar";
}
const EmberObjectComponent = Component.extend({
name: "",
layout: hbs`<span class="ember-object-component">{{this.foo.name}} {{this.baz.name}}</span>`,
foo: optionalService(),
baz: optionalService("bar"),
});
class NativeComponent extends Component {
@optionalService foo;
@optionalService("bar") baz;
name = "";
layout = hbs`<span class="native-component">{{this.foo.name}} {{this.baz.name}}</span>`;
}
module("Unit | Utils | optional-service", function (hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(function () {
this.registry.register("service:foo", FooService);
this.registry.register("service:bar", BarService);
});
test("optionalService works in EmberObject classes", async function (assert) {
this.registry.register(
"component:ember-object-component",
EmberObjectComponent
);
await render(hbs`<EmberObjectComponent />`);
assert.dom(".ember-object-component").hasText("foo bar");
});
test("optionalService works in native classes", async function (assert) {
this.registry.register("component:native-component", NativeComponent);
await render(hbs`<NativeComponent />`);
assert.dom(".native-component").hasText("foo bar");
});
});

View File

@ -14,32 +14,32 @@
"start": "ember serve"
},
"dependencies": {
"@babel/core": "^7.25.2",
"ember-auto-import": "^2.7.4",
"@babel/core": "^7.25.7",
"ember-auto-import": "^2.8.1",
"ember-cli-babel": "^8.2.0",
"ember-cli-htmlbars": "^6.3.0",
"ember-template-imports": "^4.1.1",
"ember-template-imports": "^4.1.2",
"truth-helpers": "workspace:1.0.0"
},
"devDependencies": {
"@ember/optional-features": "^2.1.0",
"@embroider/test-setup": "^4.0.0",
"@glimmer/component": "^1.1.2",
"@types/jquery": "^3.5.30",
"@types/jquery": "^3.5.31",
"@types/qunit": "^2.19.10",
"@types/rsvp": "^4.0.9",
"broccoli-asset-rev": "^3.0.0",
"ember-cli": "~5.11.0",
"ember-cli": "~5.12.0",
"ember-cli-inject-live-reload": "^2.1.0",
"ember-cli-sri": "^2.1.1",
"ember-cli-terser": "^4.0.2",
"ember-disable-prototype-extensions": "^1.1.3",
"ember-load-initializers": "^2.1.1",
"ember-resolver": "^13.0.0",
"ember-load-initializers": "^3.0.1",
"ember-resolver": "^13.0.2",
"ember-source": "~5.5.0",
"ember-source-channel-url": "^3.0.0",
"loader.js": "^4.7.0",
"webpack": "^5.94.0"
"webpack": "^5.95.0"
},
"engines": {
"node": ">= 18",

View File

@ -14,10 +14,10 @@
"start": "ember serve"
},
"dependencies": {
"@babel/core": "^7.25.2",
"@babel/core": "^7.25.7",
"discourse-common": "workspace:1.0.0",
"discourse-i18n": "workspace:1.0.0",
"ember-auto-import": "^2.7.4",
"ember-auto-import": "^2.8.1",
"ember-cli-babel": "^8.2.0",
"ember-cli-htmlbars": "^6.3.0",
"xss": "^1.0.15"
@ -26,21 +26,21 @@
"@ember/optional-features": "^2.1.0",
"@embroider/test-setup": "^4.0.0",
"@glimmer/component": "^1.1.2",
"@types/jquery": "^3.5.30",
"@types/jquery": "^3.5.31",
"@types/qunit": "^2.19.10",
"@types/rsvp": "^4.0.9",
"broccoli-asset-rev": "^3.0.0",
"ember-cli": "~5.11.0",
"ember-cli": "~5.12.0",
"ember-cli-inject-live-reload": "^2.1.0",
"ember-cli-sri": "^2.1.1",
"ember-cli-terser": "^4.0.2",
"ember-disable-prototype-extensions": "^1.1.3",
"ember-load-initializers": "^2.1.1",
"ember-resolver": "^13.0.0",
"ember-load-initializers": "^3.0.1",
"ember-resolver": "^13.0.2",
"ember-source": "~5.5.0",
"ember-source-channel-url": "^3.0.0",
"loader.js": "^4.7.0",
"webpack": "^5.94.0"
"webpack": "^5.95.0"
},
"engines": {
"node": ">= 18",

View File

@ -14,33 +14,33 @@
"start": "ember serve"
},
"dependencies": {
"@babel/core": "^7.25.2",
"@babel/core": "^7.25.7",
"@ember/string": "^4.0.0",
"discourse-i18n": "workspace:1.0.0",
"ember-auto-import": "^2.7.4",
"ember-auto-import": "^2.8.1",
"ember-cli-babel": "^8.2.0",
"ember-cli-htmlbars": "^6.3.0",
"ember-template-imports": "^4.1.1"
"ember-template-imports": "^4.1.2"
},
"devDependencies": {
"@ember/optional-features": "^2.1.0",
"@embroider/test-setup": "^4.0.0",
"@glimmer/component": "^1.1.2",
"@types/jquery": "^3.5.30",
"@types/jquery": "^3.5.31",
"@types/qunit": "^2.19.10",
"@types/rsvp": "^4.0.9",
"broccoli-asset-rev": "^3.0.0",
"ember-cli": "~5.11.0",
"ember-cli": "~5.12.0",
"ember-cli-inject-live-reload": "^2.1.0",
"ember-cli-sri": "^2.1.1",
"ember-cli-terser": "^4.0.2",
"ember-disable-prototype-extensions": "^1.1.3",
"ember-load-initializers": "^2.1.1",
"ember-resolver": "^13.0.0",
"ember-load-initializers": "^3.0.1",
"ember-resolver": "^13.0.2",
"ember-source": "~5.5.0",
"ember-source-channel-url": "^3.0.0",
"loader.js": "^4.7.0",
"webpack": "^5.94.0"
"webpack": "^5.95.0"
},
"engines": {
"node": ">= 18",

View File

@ -7,7 +7,7 @@
"license": "GPL-2.0-only",
"keywords": [],
"dependencies": {
"@babel/standalone": "^7.25.6",
"@babel/standalone": "^7.25.7",
"@zxing/text-encoding": "^0.9.0",
"babel-plugin-ember-template-compilation": "^2.3.0",
"content-tag": "^2.0.1",
@ -19,7 +19,7 @@
"handlebars": "^4.7.8",
"path-browserify": "^1.0.1",
"polyfill-crypto.getrandomvalues": "^1.0.0",
"terser": "^5.32.0",
"terser": "^5.34.1",
"decorator-transforms": "^2.0.0"
},
"engines": {

View File

@ -19,7 +19,7 @@
],
"dependencies": {
"@embroider/addon-shim": "^1.8.9",
"ember-auto-import": "^2.7.4"
"ember-auto-import": "^2.8.1"
},
"engines": {
"node": ">= 18",

View File

@ -1083,6 +1083,7 @@ a.inline-editable-field {
@import "common/admin/api";
@import "common/admin/backups";
@import "common/admin/plugins";
@import "common/admin/site-settings";
@import "common/admin/admin_config_area";
@import "common/admin/admin_reports";
@import "common/admin/admin_report";
@ -1093,6 +1094,7 @@ a.inline-editable-field {
@import "common/admin/admin_report_stacked_line_chart";
@import "common/admin/admin_report_table";
@import "common/admin/admin_report_inline_table";
@import "common/admin/admin_section_landing_page";
@import "common/admin/admin_page_header";
@import "common/admin/admin_intro";
@import "common/admin/admin_emojis";

View File

@ -0,0 +1,71 @@
.admin-section-landing-wrapper {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(16em, 1fr));
gap: 1em 2em;
margin-top: 1em;
padding-top: 1em;
.admin-section-landing-item {
display: grid;
grid-template-rows: subgrid;
grid-template-columns: 1fr;
grid-row: span 2;
gap: 0;
margin-bottom: 2em;
@include breakpoint("mobile-extra-large", min-width) {
margin-bottom: 2em;
}
&.-has-icon {
grid-template-columns: 1fr 8fr;
.admin-section-landing-item__buttons {
grid-column: 2;
}
}
&__content {
grid-row: 1;
}
&__tagline {
font-size: var(--font-down-1);
font-weight: normal;
color: var(--primary-high);
margin: 0;
letter-spacing: 0.1px;
}
&__title {
margin: 0;
line-height: var(--line-height-medium);
}
&__description {
color: var(--primary-high);
margin: 0.25em 0 0.5em;
line-height: var(--line-height-large);
align-self: start;
@include breakpoint("mobile-extra-large", min-width) {
max-width: 17em;
}
}
&__icon {
font-size: var(--font-up-3);
color: var(--primary-low-mid);
grid-row: 1;
}
&__buttons {
grid-row: 2;
grid-column: 1;
}
button {
justify-self: start;
}
}
}

View File

@ -246,16 +246,21 @@
.problem-messages {
margin-bottom: 1em;
&.priority-high {
background-color: var(--danger-low);
border: 1px solid var(--danger-medium);
}
ul {
margin: 0 0 0 1.25em;
li.dashboard-problem {
padding: 0.5em 0.5em;
.notice {
display: flex;
justify-content: space-between;
align-items: center;
}
.message {
margin-right: var(--space-4);
}
}
}
}

View File

@ -10,13 +10,13 @@
.admin-plugins-list {
@media screen and (min-width: 550px) {
.admin-plugins-list__row {
grid-template-columns: 0.25fr repeat(4, 1fr);
grid-template-columns: 0fr repeat(4, 1fr);
}
}
@include breakpoint(mobile-extra-large) {
.admin-plugins-list__row {
grid-template-columns: 0.25fr repeat(3, 1fr);
grid-template-columns: 0fr repeat(3, 1fr);
}
.admin-plugins-list {
@ -175,16 +175,6 @@
}
.admin-plugin-config-area {
&__settings {
.admin-site-settings-filter-controls {
margin-bottom: 1em;
}
.setting-label {
margin-left: 18px;
}
}
&__empty-list {
padding: 1em;
border: 1px solid var(--primary-low);

View File

@ -0,0 +1,25 @@
.admin-controls.admin-site-settings-filter-controls
.controls
.admin-site-settings-filter-controls__input {
max-width: 300px;
}
.admin-controls.admin-site-settings-filter-controls .menu-toggle {
margin-left: 0.5em;
}
.admin-plugin-config-area {
&__settings {
.admin-site-settings-filter-controls {
margin-bottom: 1em;
}
.admin-filtered-site-settings {
padding: 0.5em 1em;
}
.setting-label {
margin-left: 18px;
}
}
}

View File

@ -329,6 +329,7 @@
.badge-posts {
font-weight: 700;
color: inherit;
display: inline-block;
padding: 15px 5px;
}
}

View File

@ -1098,6 +1098,10 @@ a.mention,
a.mention-group {
// linked
@include mention;
.user-status-message {
user-select: none;
}
}
.mention .emoji {

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