mirror of
https://github.com/flarum/core.git
synced 2025-08-13 20:04:24 +02:00
Compare commits
121 Commits
im/approva
...
v1.6.3
Author | SHA1 | Date | |
---|---|---|---|
|
243bc139b0 | ||
|
adf78bbd95 | ||
|
c8d9f1111e | ||
|
e5f05166a0 | ||
|
02556c6ca6 | ||
|
666223fa8c | ||
|
12dfcc5c79 | ||
|
248a71d9b5 | ||
|
a131e87911 | ||
|
be63b28437 | ||
|
132fdea659 | ||
|
8a65ad980d | ||
|
9a0668effd | ||
|
224b122303 | ||
|
ed0cee97f5 | ||
|
b5f324a7b3 | ||
|
8ef0df94b2 | ||
|
c50c924242 | ||
|
18bdd48835 | ||
|
f49cf887dc | ||
|
19793d5617 | ||
|
4a2f48ad04 | ||
|
2b413b06c5 | ||
|
2b89dedc08 | ||
|
00a880c467 | ||
|
92d2adc5fd | ||
|
bc59b8d9ab | ||
|
bb9f01372f | ||
|
069a29d22a | ||
|
105170b5bc | ||
|
b8261ef055 | ||
|
d14770188b | ||
|
e9bb646dbf | ||
|
46adf40675 | ||
|
6938a13223 | ||
|
ab56aefeaa | ||
|
259db92b9a | ||
|
094ec77980 | ||
|
bb712693d4 | ||
|
1e00e3fdbb | ||
|
47d7a6e155 | ||
|
45d91212f6 | ||
|
1a81c98d43 | ||
|
c98e6ba5a7 | ||
|
eeb00cc56b | ||
|
87cdb5b4d8 | ||
|
bc4b0b864c | ||
|
53ab1503e4 | ||
|
62a396e434 | ||
|
2096fa2807 | ||
|
06963df407 | ||
|
8fe09815f5 | ||
|
fccc3e2188 | ||
|
f0a867b20f | ||
|
69311ae689 | ||
|
f005b9e031 | ||
|
31ced98e0d | ||
|
9964ddd731 | ||
|
32ac0a8d8f | ||
|
827e905f8e | ||
|
cdc76567d4 | ||
|
5898a50463 | ||
|
72d9ee2010 | ||
|
4e5e4e5c6e | ||
|
201d7430fe | ||
|
52f6148876 | ||
|
dc215aba59 | ||
|
90a68506b8 | ||
|
bd0577f435 | ||
|
d33f1abffc | ||
|
b0b47a0888 | ||
|
8c0a14aff2 | ||
|
76788efaba | ||
|
973ec32e13 | ||
|
cf818aae9e | ||
|
6da0bc63b7 | ||
|
6e1bc2daed | ||
|
7ce9d63ed6 | ||
|
267f6759f8 | ||
|
b5874a08e4 | ||
|
368e08bb9b | ||
|
5f2d7fb7b6 | ||
|
68d6e30143 | ||
|
c5c312db0d | ||
|
05f80b7b83 | ||
|
31c3cfc4ea | ||
|
fd196454a5 | ||
|
f6761843b2 | ||
|
84c31165e5 | ||
|
fc4d5e3d43 | ||
|
b2fa28e4b5 | ||
|
fc743ba888 | ||
|
a35df8c139 | ||
|
8aec252452 | ||
|
32961d480c | ||
|
27e3d322ec | ||
|
9eab1085da | ||
|
c305f9a105 | ||
|
3b773e2677 | ||
|
6e48a0303e | ||
|
31f1ffd6a5 | ||
|
9897f682a0 | ||
|
f3156c65c9 | ||
|
2719042c71 | ||
|
ffd0b90a83 | ||
|
87aaaf6971 | ||
|
6ffa9e3736 | ||
|
434c459246 | ||
|
7f2f3e34f5 | ||
|
f7dd609b26 | ||
|
ec97ee41f9 | ||
|
335c602cea | ||
|
082117d8bc | ||
|
11a9b73610 | ||
|
974f003fe3 | ||
|
352a50e3ad | ||
|
5637fe8041 | ||
|
3d167749cb | ||
|
1a189f4923 | ||
|
6de8113720 | ||
|
f74f7f58cd |
@@ -20,3 +20,6 @@ indent_size = 4
|
||||
|
||||
[tsconfig.json]
|
||||
indent_size = 2
|
||||
|
||||
[*.neon]
|
||||
indent_style = tab
|
||||
|
1
.github/CODEOWNERS
vendored
Normal file
1
.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1 @@
|
||||
* @flarum/core
|
91
.github/workflows/REUSABLE_backend.yml
vendored
91
.github/workflows/REUSABLE_backend.yml
vendored
@@ -9,6 +9,12 @@ on:
|
||||
default: true
|
||||
required: false
|
||||
|
||||
enable_phpstan:
|
||||
description: "Enable PHPStan Static Analysis?"
|
||||
type: boolean
|
||||
default: false
|
||||
required: false
|
||||
|
||||
backend_directory:
|
||||
description: The directory of the project where backend code is located. This should contain a `composer.json` file, and is generally the root directory of the repo.
|
||||
type: string
|
||||
@@ -19,12 +25,19 @@ on:
|
||||
description: Versions of PHP to test with. Should be array of strings encoded as JSON array
|
||||
type: string
|
||||
required: false
|
||||
default: '["7.4", "8.0", "8.1"]'
|
||||
default: '["7.3", "7.4", "8.0", "8.1"]'
|
||||
|
||||
php_extensions:
|
||||
description: PHP extensions to install.
|
||||
type: string
|
||||
required: false
|
||||
default: 'curl, dom, gd, json, mbstring, openssl, pdo_mysql, tokenizer, zip'
|
||||
|
||||
db_versions:
|
||||
description: Versions of databases to test with. Should be array of strings encoded as JSON array
|
||||
type: string
|
||||
required: false
|
||||
default: '["mysql:5.7", "mariadb"]'
|
||||
default: '["mysql:5.7", "mysql:8.0.30", "mariadb"]'
|
||||
|
||||
php_ini_values:
|
||||
description: PHP ini values
|
||||
@@ -44,23 +57,41 @@ jobs:
|
||||
matrix:
|
||||
php: ${{ fromJSON(inputs.php_versions) }}
|
||||
service: ${{ fromJSON(inputs.db_versions) }}
|
||||
prefix: ['', flarum_]
|
||||
prefix: ['']
|
||||
|
||||
# https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstrategymatrixinclude
|
||||
include:
|
||||
# Expands the matrix by naming DBs.
|
||||
- service: 'mysql:5.7'
|
||||
db: MySQL
|
||||
db: MySQL 5.7
|
||||
- service: 'mysql:8.0.30'
|
||||
db: MySQL 8.0
|
||||
- service: mariadb
|
||||
db: MariaDB
|
||||
- prefix: flarum_
|
||||
|
||||
# Include Database prefix tests with only one PHP version.
|
||||
- php: ${{ fromJSON(inputs.php_versions)[0] }}
|
||||
service: 'mysql:5.7'
|
||||
db: MySQL 5.7
|
||||
prefix: flarum_
|
||||
prefixStr: (prefix)
|
||||
- php: ${{ fromJSON(inputs.php_versions)[0] }}
|
||||
service: 'mysql:8.0.30'
|
||||
db: MySQL 8.0
|
||||
prefix: flarum_
|
||||
prefixStr: (prefix)
|
||||
- php: ${{ fromJSON(inputs.php_versions)[0] }}
|
||||
service: mariadb
|
||||
db: MariaDB
|
||||
prefix: flarum_
|
||||
prefixStr: (prefix)
|
||||
|
||||
# To reduce number of actions, we exclude some PHP versions from running with some DB versions.
|
||||
exclude:
|
||||
- php: 8.0
|
||||
service: 'mysql:5.7'
|
||||
prefix: flarum_
|
||||
- php: 8.0
|
||||
service: mariadb
|
||||
prefix: flarum_
|
||||
- php: ${{ fromJSON(inputs.php_versions)[1] }}
|
||||
service: 'mysql:8.0.30'
|
||||
- php: ${{ fromJSON(inputs.php_versions)[2] }}
|
||||
service: 'mysql:8.0.30'
|
||||
|
||||
services:
|
||||
mysql:
|
||||
@@ -70,7 +101,9 @@ jobs:
|
||||
|
||||
name: 'PHP ${{ matrix.php }} / ${{ matrix.db }} ${{ matrix.prefixStr }}'
|
||||
|
||||
if: inputs.enable_backend_testing
|
||||
if: >-
|
||||
inputs.enable_backend_testing &&
|
||||
((github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) || github.event_name != 'pull_request')
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@master
|
||||
@@ -80,7 +113,7 @@ jobs:
|
||||
with:
|
||||
php-version: ${{ matrix.php }}
|
||||
coverage: xdebug
|
||||
extensions: curl, dom, gd, json, mbstring, openssl, pdo_mysql, tokenizer, zip
|
||||
extensions: ${{ inputs.php_extensions }}
|
||||
tools: phpunit, composer:v2
|
||||
ini-values: ${{ inputs.php_ini_values }}
|
||||
|
||||
@@ -110,3 +143,35 @@ jobs:
|
||||
working-directory: ${{ inputs.backend_directory }}
|
||||
env:
|
||||
COMPOSER_PROCESS_TIMEOUT: 600
|
||||
|
||||
phpstan:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
php: ${{ fromJSON(inputs.php_versions) }}
|
||||
|
||||
name: 'PHPStan PHP ${{ matrix.php }}'
|
||||
|
||||
if: >-
|
||||
inputs.enable_phpstan &&
|
||||
((github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) || github.event_name != 'pull_request')
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@master
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: ${{ matrix.php }}
|
||||
coverage: xdebug
|
||||
extensions: ${{ inputs.php_extensions }}
|
||||
tools: phpunit, composer:v2
|
||||
ini-values: ${{ inputs.php_ini_values }}
|
||||
|
||||
- name: Install Composer dependencies
|
||||
run: composer install
|
||||
working-directory: ${{ inputs.backend_directory }}
|
||||
|
||||
- name: Run PHPStan
|
||||
run: composer analyse:phpstan
|
||||
|
3
.github/workflows/REUSABLE_frontend.yml
vendored
3
.github/workflows/REUSABLE_frontend.yml
vendored
@@ -91,6 +91,9 @@ jobs:
|
||||
name: Checks & Build
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
if: >-
|
||||
((github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) || github.event_name != 'pull_request')
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v2
|
||||
|
12
.github/workflows/phpstan.yml
vendored
Normal file
12
.github/workflows/phpstan.yml
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
name: Framework PHP
|
||||
|
||||
on: [workflow_dispatch, push, pull_request]
|
||||
|
||||
jobs:
|
||||
run:
|
||||
uses: ./.github/workflows/REUSABLE_backend.yml
|
||||
with:
|
||||
enable_backend_testing: false
|
||||
enable_phpstan: true
|
||||
|
||||
backend_directory: .
|
133
CHANGELOG.md
133
CHANGELOG.md
@@ -1,5 +1,138 @@
|
||||
# Changelog
|
||||
|
||||
# [v1.6.3](https://github.com/flarum/framework/compare/v1.6.2...v1.6.3)
|
||||
### Fixed
|
||||
* Post mentions can be used to read any post on the forum without access control (ab1c868b978e8b0d09a5d682c54665dae17d0985).
|
||||
* Notifications can leak restricted content (d0a2b95dca57d3dae9a0d77b610b1cb1d0b1766a).
|
||||
* Any user including unactivated can reply in public discussions whose first post was permanently deleted (12f14112a0ecd1484d97330b82beb2a145919015).
|
||||
* (subscriptions) Post notifications not getting access checked (https://github.com/flarum/framework/commit/e5f05166a062a9a6eb7c12e28728bfd5db7270e3).
|
||||
|
||||
## [v1.6.2](https://github.com/flarum/framework/compare/v1.6.1...v1.6.2)
|
||||
### Fixed
|
||||
* XSS Vulnerability in core (https://github.com/flarum/framework/pull/3684).
|
||||
|
||||
## [v1.6.1](https://github.com/flarum/framework/compare/v1.6.0...v1.6.1)
|
||||
### Fixed
|
||||
* JS dependencies update breaks utilities.
|
||||
|
||||
## [v1.6.0](https://github.com/flarum/framework/compare/v1.5.0...v1.6.0)
|
||||
### Fixed
|
||||
- (approval) posts approved for deleted users error ([b5874a0](b5874a08e482196f50af50aa78e43c93c29fb647))
|
||||
- (regression) bad import ([5f2d7fb](5f2d7fb7b6e430d40cf2bb05eca7c73f6ca5a2cc))
|
||||
- akismet fails when the extension is not on a version ([45d9121](45d91212f6bfa777cae9fc06c55c85d01ffd174d))
|
||||
- apply flex for AppearancePage colors input [#3651]
|
||||
- groupmentions have poor contrast on some backgrounds [#3672]
|
||||
- larastan v1 incompatible with phpstan v1.9.0 [#3665]
|
||||
- package manager failures not showing alerts [#3647]
|
||||
- password reset leaks user existence [#3616]
|
||||
- statistics previous period chart is unclear [#3654]
|
||||
### Changed
|
||||
- (package-manager) config composer to use web php version ([fd19645](fd196454a5641776784fa80886cc7577c840f8ed))
|
||||
- (package-manager) set min core version and add warning ([31c3cfc](31c3cfc4eab4c314260b9b0d11e53ac2d4be158d))
|
||||
- (statistics) prepare v1.5.1 ([dc215ab](dc215aba59145dfd7b0d6efad4388444f30e47fb))
|
||||
- Apply fixes from StyleCI ([267f675](267f6759f80bd06f468337245ea6045635e827d9))
|
||||
- Fix tag discussion count decreased by 2 when hiding before deleting [#3660]
|
||||
- Log migration path when up/down keys are missing [#3664]
|
||||
- Make it possible to extend SetupScript [#3643]
|
||||
- Setup PHPStan Level 5 [#3553]
|
||||
- `yarn format` ([c5c312d](c5c312db0d800e3b84b94a4abb9691e348dea742))
|
||||
- add missing last period to custom date ranges [#3661]
|
||||
- add priorities to profile settings page [#3657]
|
||||
- allow specifying php extensions in workflow ([b0b47a0](b0b47a0888f513a459b67e9f89e72a61de38f1ce))
|
||||
- format js ([06963df](06963df4079373fc8fc51b7479e9576f02beb098))
|
||||
- group mentions [#3658]
|
||||
- remove styleci from changelog ([b2fa28e](b2fa28e4b57094e46dbdb3d79fab74f290a17d17))
|
||||
- set flarum version to dev for 1.6.0 ([fc743ba](fc743ba88872031db13597d7365a063b8004c78f))
|
||||
- throw an exception when no serializer is provided to the controller [#3614]
|
||||
### Added
|
||||
- (statistics) support for custom date ranges [#3622]
|
||||
- Allow additional login params, Introduce `LogInValidator` [#3670]
|
||||
- Allow additional reset password params, introduce `ForgotPasswordValidator` [#3671]
|
||||
- add statistics chart export button [#3662]
|
||||
- allow specifying extensions when installing an instance [#3655]
|
||||
- contrast util with yiq calculator [#3652]
|
||||
- customizable session driver [#3610]
|
||||
- replace `ColorPreviewInput` for GroupModal color input [#3650]
|
||||
- send notifications of a new reply when post is approved [#3656]
|
||||
|
||||
## [v1.5.0](https://github.com/flarum/framework/compare/v1.4.0...v1.5.0)
|
||||
### Fixed
|
||||
- (a11y) add accessible labels to notification grid options [#3520]
|
||||
- (a11y) present post streams as feeds [#3522]
|
||||
- (a11y) set `aria-busy` when editing a post stream item [#3521]
|
||||
- (compilation) versioner not inject into compilers [#3589]
|
||||
- (mentions) accessing `id` of null `user` relation [#3618]
|
||||
- (subscriptions) add missing table prefix for filter gambit [#3599]
|
||||
- (tags) use default index sortmap [#3615]
|
||||
- Move guzzle requirement to core [#3544]
|
||||
- MyISAM tables for extensions during installation ([75aaef7](75aaef7d76317bc8578eac1439fed8091c87213b), [f926c58](f926c58e0143fe75a4a4c2e93810970c5910afc8))
|
||||
- Set the translator locale to user preference for email notifications [#3525]
|
||||
- `$events` property declared dynamically [#3598]
|
||||
- core settings header has no priority ([33bf228](33bf2284c77863a1bb18d71d87b8516483056a74))
|
||||
- html entities shown raw in page title [#3542]
|
||||
- incorrect centring of deleted user avatars in notification list [#3569]
|
||||
- intellisense imports defaulting to absolute path from `src` folder [#3549]
|
||||
- minor backward compatible fix for php 8.1 in st_replace ([07b2f86](07b2f86dcc90a3ef17c8ee19a1a07e99a4b17360))
|
||||
- post query wildcard selection causes ambiguity [#3621]
|
||||
- potential static caching memory exhaustion [#3548]
|
||||
- prepare release workflow has invalid layout ([70e483d](70e483d1b185332910be9513fd06cc6342830d49))
|
||||
- remove deprecation warning for decoding null values ([590639f](590639f5f3e1fe883f28c41e1f175c2826b4b5f4))
|
||||
- replace `.fa()` mixin usage with `.fas()` [#3537]
|
||||
- return type hint static is php 8+ ([b01b75e](b01b75e36790d8026dd27ce59051d9581ad47940))
|
||||
- sticky nav content displays below post stream [#3575]
|
||||
- titles positioned wrongly with custom header height [#3550]
|
||||
- typo in error message ([1a189f4](1a189f492320071365286a8835bc49d5a9571753))
|
||||
- unread notifications are globally cached between users. [#3543]
|
||||
- update workflow name ([628c281](628c281c39855f01069ddc40b698d80d29fec870))
|
||||
- user has wrong discussion read status [#3591]
|
||||
### Changed
|
||||
- (approval, likes) use subscribers [#3577]
|
||||
- (package-manager) last tweaks before beta tag ([335c602](335c602cea3fbaee9ad7c32ceecaaf222e5d89a7))
|
||||
- (statistics) add release notes for 1.4.1 ([f4ace73](f4ace73a3c59434b8717efb2d83f50084f470fe4))
|
||||
- (statistics) rewrite for performance on very large communities [#3531]
|
||||
- (statistics) split timed data into per-model XHR requests [#3601]
|
||||
- (tags) Replace event helper with event dispatcher [#3570]
|
||||
- Add `loading="lazy"` attribute for avatars [#3578]
|
||||
- Create CODEOWNERS ([6e48a03](6e48a0303e45bcf210e550ba3e0772bc8443a207))
|
||||
- MyISAM tables for extensions during installation" ([f128190](f128190f143398dd1262fd1379e634794daee4c1))
|
||||
- convert `AlertManager` `IndexPage` and `UserPage` components to TS [#3536]
|
||||
- convert `Badge` `Checkbox` and `Navigation` components to TS [#3532]
|
||||
- convert core modals to TypeScript [#3515]
|
||||
- convert page components to TypeScript [#3538]
|
||||
- debug line slipped in while rebasing a PR [#3580]
|
||||
- don't pass password field between auth modals [#3626]
|
||||
- fix github issue templates ([d3e456a](d3e456a1bf42d13b7cd2542c371f392712247c09))
|
||||
- format code ([4954621](495462183bfb3b33046b293e6b1088ab225968df))
|
||||
- getting the release workflow in ([5530400](5530400b093b5fd07d670e5c92d8a7da96634cfe))
|
||||
- link logo at the top with the official website [#3552]
|
||||
- prevent running both `push` and `pull_request` actions at the same time [#3597]
|
||||
- refactor prefix matrix and add `MySQL 8.0` & `PHP 7.3` to workflows [#3595]
|
||||
- relying on a third-party for avatar URL tests is unreliable [#3586]
|
||||
- require guzzle 6 or 7 ([46b3b7a](46b3b7a9527b935c3c52269aaad2010c75dcb6d8))
|
||||
- split FA imports into separate Less file for easy overriding [#3535]
|
||||
- unify JS actions into one (rewritten `flarum/action-build`) [#3573]
|
||||
- update version constant during cycle 22 ([d864405](d86440506dd37101e60adec591d4b017e7765ec6))
|
||||
- use `isCollapsed` instead of `rangeCount` [#3581]
|
||||
- use github issue template forms [#3526]
|
||||
### Added
|
||||
- (likes) Add likes tab to user profile [#3528]
|
||||
- (likes) Option to prevent users liking their own posts [#3534]
|
||||
- (modals) support stacking modals, remove bootstrap modals dependency [#3456]
|
||||
- (subscriptions) add option to send notifications when not caught up [#3503]
|
||||
- Add custom class for email confirmation alert [#3584]
|
||||
- Admin debug mode warning [#3590]
|
||||
- Delete all notifications [#3529]
|
||||
- Queue package manager commands [#3418]
|
||||
- Restart the queue worker after cache clearing, ext enable/disable, save settings [#3565]
|
||||
- add createTableIfNotExists migration helper [#3576]
|
||||
- add new workflow for generating release meta ([0901e59](0901e59a58a3e1f017762583a2adf419f7f34257))
|
||||
- clear password & email tokens when appropriate [#3567]
|
||||
- discussion UTF-8 slug driver [#3606]
|
||||
- expose assets base url to frontend forum model [#3566]
|
||||
- extender to add custom less variables [#3530]
|
||||
- publish assets on admin dashboard cache clear [#3564]
|
||||
- throttle email change, email confirmation, and password reset endpoints. [#3555]
|
||||
|
||||
## [1.4.0](https://github.com/flarum/framework/compare/v1.3.1...v1.4.0)
|
||||
|
||||
### Added
|
||||
|
@@ -140,8 +140,8 @@
|
||||
"require-dev": {
|
||||
"mockery/mockery": "^1.4",
|
||||
"phpunit/phpunit": "^9.0",
|
||||
"phpstan/phpstan-php-parser": "^1.0",
|
||||
"phpstan/phpstan": "^1.2"
|
||||
"phpstan/phpstan": ">=1.8.11 < 1.9.0",
|
||||
"nunomaduro/larastan": "^1.0"
|
||||
},
|
||||
"config": {
|
||||
"sort-packages": true
|
||||
@@ -178,5 +178,11 @@
|
||||
"extension.neon"
|
||||
]
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"analyse:phpstan": "phpstan analyse"
|
||||
},
|
||||
"scripts-descriptions": {
|
||||
"analyse:phpstan": "Run static analysis"
|
||||
}
|
||||
}
|
||||
|
@@ -19,7 +19,7 @@
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"flarum/core": "^1.4",
|
||||
"flarum/core": "^1.6",
|
||||
"flarum/approval": "^1.2"
|
||||
},
|
||||
"autoload": {
|
||||
|
@@ -38,7 +38,7 @@ class AkismetProvider extends AbstractServiceProvider
|
||||
$settings->get('flarum-akismet.api_key'),
|
||||
$url->to('forum')->base(),
|
||||
$app::VERSION,
|
||||
$extensions->getExtension('flarum-akismet')->getVersion(),
|
||||
$extensions->getExtension('flarum-akismet')->getVersion() ?? 'unknown',
|
||||
$config->inDebugMode()
|
||||
);
|
||||
});
|
||||
|
@@ -19,7 +19,7 @@
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"flarum/core": "^1.4",
|
||||
"flarum/core": "^1.6",
|
||||
"flarum/flags": "^1.2"
|
||||
},
|
||||
"autoload": {
|
||||
|
@@ -10,6 +10,7 @@
|
||||
use Flarum\Api\Serializer\BasicDiscussionSerializer;
|
||||
use Flarum\Api\Serializer\PostSerializer;
|
||||
use Flarum\Approval\Access;
|
||||
use Flarum\Approval\Event\PostWasApproved;
|
||||
use Flarum\Approval\Listener;
|
||||
use Flarum\Discussion\Discussion;
|
||||
use Flarum\Extend;
|
||||
@@ -48,6 +49,7 @@ return [
|
||||
new Extend\Locales(__DIR__.'/locale'),
|
||||
|
||||
(new Extend\Event())
|
||||
->listen(PostWasApproved::class, Listener\UpdateDiscussionAfterPostApproval::class)
|
||||
->subscribe(Listener\ApproveContent::class)
|
||||
->subscribe(Listener\UnapproveNewContent::class),
|
||||
|
||||
|
@@ -21,12 +21,8 @@ class ApproveContent
|
||||
public function subscribe(Dispatcher $events)
|
||||
{
|
||||
$events->listen(Saving::class, [$this, 'approvePost']);
|
||||
$events->listen(PostWasApproved::class, [$this, 'approveDiscussion']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Saving $event
|
||||
*/
|
||||
public function approvePost(Saving $event)
|
||||
{
|
||||
$attributes = $event->data['attributes'];
|
||||
@@ -46,30 +42,4 @@ class ApproveContent
|
||||
$post->raise(new PostWasApproved($post, $event->actor));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param PostWasApproved $event
|
||||
*/
|
||||
public function approveDiscussion(PostWasApproved $event)
|
||||
{
|
||||
$post = $event->post;
|
||||
$discussion = $post->discussion;
|
||||
$user = $discussion->user;
|
||||
|
||||
$discussion->refreshCommentCount();
|
||||
$discussion->refreshLastPost();
|
||||
|
||||
if ($post->number == 1) {
|
||||
$discussion->is_approved = true;
|
||||
|
||||
$discussion->afterSave(function () use ($user) {
|
||||
$user->refreshDiscussionCount();
|
||||
});
|
||||
}
|
||||
|
||||
$discussion->save();
|
||||
|
||||
$user->refreshCommentCount();
|
||||
$user->save();
|
||||
}
|
||||
}
|
||||
|
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Approval\Listener;
|
||||
|
||||
use Flarum\Approval\Event\PostWasApproved;
|
||||
|
||||
class UpdateDiscussionAfterPostApproval
|
||||
{
|
||||
public function handle(PostWasApproved $event)
|
||||
{
|
||||
$post = $event->post;
|
||||
$discussion = $post->discussion;
|
||||
$user = $discussion->user;
|
||||
|
||||
$discussion->refreshCommentCount();
|
||||
$discussion->refreshLastPost();
|
||||
|
||||
if ($post->number == 1) {
|
||||
$discussion->is_approved = true;
|
||||
|
||||
$discussion->afterSave(function () use ($user) {
|
||||
$user->refreshDiscussionCount();
|
||||
});
|
||||
}
|
||||
|
||||
$discussion->save();
|
||||
|
||||
if ($discussion->user) {
|
||||
$user->refreshCommentCount();
|
||||
$user->save();
|
||||
}
|
||||
}
|
||||
}
|
@@ -28,11 +28,8 @@ trait InteractsWithUnapprovedContent
|
||||
['id' => 3, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 4, 'first_post_id' => 3, 'comment_count' => 1, 'is_approved' => 0, 'is_private' => 1],
|
||||
['id' => 4, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 4, 'first_post_id' => 4, 'comment_count' => 1, 'is_approved' => 1, 'is_private' => 0],
|
||||
['id' => 5, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 4, 'first_post_id' => 5, 'comment_count' => 1, 'is_approved' => 1, 'is_private' => 0],
|
||||
['id' => 6, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 2, 'first_post_id' => 6, 'comment_count' => 1, 'is_approved' => 0, 'is_private' => 1],
|
||||
['id' => 6, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 4, 'first_post_id' => 6, 'comment_count' => 1, 'is_approved' => 0, 'is_private' => 1],
|
||||
['id' => 7, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 4, 'first_post_id' => 7, 'comment_count' => 1, 'is_approved' => 1, 'is_private' => 0],
|
||||
|
||||
// Normal discussion with first post being private (also means comment_count = 0).
|
||||
['id' => 8, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 4, 'first_post_id' => 12, 'comment_count' => 0, 'is_approved' => 1, 'is_private' => 0],
|
||||
],
|
||||
'posts' => [
|
||||
['id' => 1, 'discussion_id' => 1, 'user_id' => 4, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'is_approved' => 1, 'number' => 1],
|
||||
@@ -40,26 +37,39 @@ trait InteractsWithUnapprovedContent
|
||||
['id' => 3, 'discussion_id' => 3, 'user_id' => 4, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'is_approved' => 1, 'number' => 1],
|
||||
['id' => 4, 'discussion_id' => 4, 'user_id' => 4, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'is_approved' => 1, 'number' => 1],
|
||||
['id' => 5, 'discussion_id' => 5, 'user_id' => 4, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'is_approved' => 1, 'number' => 1],
|
||||
['id' => 6, 'discussion_id' => 6, 'user_id' => 2, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'is_approved' => 1, 'number' => 1],
|
||||
['id' => 6, 'discussion_id' => 6, 'user_id' => 4, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'is_approved' => 1, 'number' => 1],
|
||||
['id' => 7, 'discussion_id' => 7, 'user_id' => 4, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'is_approved' => 1, 'number' => 1],
|
||||
|
||||
['id' => 8, 'discussion_id' => 7, 'user_id' => 4, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'is_approved' => 1, 'number' => 2],
|
||||
['id' => 9, 'discussion_id' => 7, 'user_id' => 2, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 1, 'is_approved' => 0, 'number' => 3],
|
||||
['id' => 9, 'discussion_id' => 7, 'user_id' => 4, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 1, 'is_approved' => 0, 'number' => 3],
|
||||
['id' => 10, 'discussion_id' => 7, 'user_id' => 4, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'is_approved' => 1, 'number' => 4],
|
||||
['id' => 11, 'discussion_id' => 7, 'user_id' => 4, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 1, 'is_approved' => 0, 'number' => 5],
|
||||
|
||||
// First post of a normal discussion being private.
|
||||
['id' => 12, 'discussion_id' => 8, 'user_id' => 4, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 1, 'is_approved' => 0, 'number' => 1],
|
||||
],
|
||||
'groups' => [
|
||||
['id' => 100, 'name_singular' => 'Acme', 'name_plural' => 'Acme', 'is_hidden' => 0]
|
||||
['id' => 4, 'name_singular' => 'Acme', 'name_plural' => 'Acme', 'is_hidden' => 0]
|
||||
],
|
||||
'group_user' => [
|
||||
['user_id' => 3, 'group_id' => 100]
|
||||
['user_id' => 3, 'group_id' => 4]
|
||||
],
|
||||
'group_permission' => [
|
||||
['permission' => 'discussion.approvePosts', 'group_id' => 100]
|
||||
['permission' => 'discussion.approvePosts', 'group_id' => 4]
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* null: Guest, 2: Normal User.
|
||||
*/
|
||||
public function unallowedUsers(): array
|
||||
{
|
||||
return [[null], [2]];
|
||||
}
|
||||
|
||||
/**
|
||||
* 1: Admin, 3: Permission Given, 4: Discussions Author.
|
||||
*/
|
||||
public function allowedUsers(): array
|
||||
{
|
||||
return [[1], [3], [4]];
|
||||
}
|
||||
}
|
||||
|
@@ -29,10 +29,10 @@ class ListDiscussionsTest extends TestCase
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider userVisibleDiscussionsDataProvider
|
||||
* @dataProvider unallowedUsers
|
||||
* @test
|
||||
*/
|
||||
public function can_only_see_approved_if_allowed(?int $authenticatedAs, array $visibleDiscussionIds)
|
||||
public function can_only_see_approved_if_not_allowed_to_approve(?int $authenticatedAs)
|
||||
{
|
||||
$response = $this->send(
|
||||
$this->request('GET', '/api/discussions', compact('authenticatedAs'))
|
||||
@@ -41,17 +41,22 @@ class ListDiscussionsTest extends TestCase
|
||||
$body = json_decode($response->getBody()->getContents(), true);
|
||||
|
||||
$this->assertEquals(200, $response->getStatusCode());
|
||||
$this->assertEqualsCanonicalizing($visibleDiscussionIds, Arr::pluck($body['data'], 'id'));
|
||||
$this->assertEqualsCanonicalizing([1, 4, 5, 7], Arr::pluck($body['data'], 'id'));
|
||||
}
|
||||
|
||||
public function userVisibleDiscussionsDataProvider(): array
|
||||
/**
|
||||
* @dataProvider allowedUsers
|
||||
* @test
|
||||
*/
|
||||
public function can_see_unapproved_if_allowed_to_approve(int $authenticatedAs)
|
||||
{
|
||||
return [
|
||||
'admin can view unapproved discussions' => [1, [1, 2, 3, 4, 5, 6, 7, 8]],
|
||||
'user with perms can view unapproved discussions' => [3, [1, 2, 3, 4, 5, 6, 7, 8]],
|
||||
'guests cannot view unapproved discussions' => [null, [1, 4, 5, 7]],
|
||||
'normal users cannot view unapproved discussions unless being an author 1' => [2, [1, 4, 5, 6, 7]],
|
||||
'normal users cannot view unapproved discussions unless being an author 2' => [4, [1, 2, 3, 4, 5, 7, 8]],
|
||||
];
|
||||
$response = $this->send(
|
||||
$this->request('GET', '/api/discussions', compact('authenticatedAs'))
|
||||
);
|
||||
|
||||
$body = json_decode($response->getBody()->getContents(), true);
|
||||
|
||||
$this->assertEquals(200, $response->getStatusCode());
|
||||
$this->assertEqualsCanonicalizing([1, 2, 3, 4, 5, 6, 7], Arr::pluck($body['data'], 'id'));
|
||||
}
|
||||
}
|
||||
|
@@ -29,10 +29,10 @@ class ListPostsTest extends TestCase
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider userVisiblePostsDataProvider
|
||||
* @dataProvider unallowedUsers
|
||||
* @test
|
||||
*/
|
||||
public function can_only_see_approved_if_allowed(?int $authenticatedAs, array $visiblePostIds)
|
||||
public function can_only_see_approved_if_not_allowed_to_approve(?int $authenticatedAs)
|
||||
{
|
||||
$response = $this->send(
|
||||
$this
|
||||
@@ -47,22 +47,28 @@ class ListPostsTest extends TestCase
|
||||
$body = json_decode($response->getBody()->getContents(), true);
|
||||
|
||||
$this->assertEquals(200, $response->getStatusCode());
|
||||
$this->assertEqualsCanonicalizing($visiblePostIds, Arr::pluck($body['data'], 'id'));
|
||||
$this->assertEqualsCanonicalizing([7, 8, 10], Arr::pluck($body['data'], 'id'));
|
||||
}
|
||||
|
||||
public function userVisiblePostsDataProvider(): array
|
||||
/**
|
||||
* @dataProvider allowedUsers
|
||||
* @test
|
||||
*/
|
||||
public function can_see_unapproved_if_allowed_to_approve(int $authenticatedAs)
|
||||
{
|
||||
return [
|
||||
// Admin can view unapproved posts.
|
||||
[1, [7, 8, 9, 10, 11, 12]],
|
||||
$response = $this->send(
|
||||
$this
|
||||
->request('GET', '/api/posts', compact('authenticatedAs'))
|
||||
->withQueryParams([
|
||||
'filter' => [
|
||||
'discussion' => 7
|
||||
]
|
||||
])
|
||||
);
|
||||
|
||||
// User with approval perms can view unapproved posts.
|
||||
[3, [7, 8, 9, 10, 11, 12]],
|
||||
$body = json_decode($response->getBody()->getContents(), true);
|
||||
|
||||
// Normal users cannot view unapproved posts unless being an author.
|
||||
[null, [7, 8, 10]],
|
||||
[2, [7, 8, 9, 10]],
|
||||
[4, [7, 8, 10, 11, 12]],
|
||||
];
|
||||
$this->assertEquals(200, $response->getStatusCode());
|
||||
$this->assertEqualsCanonicalizing([7, 8, 9, 10, 11], Arr::pluck($body['data'], 'id'));
|
||||
}
|
||||
}
|
||||
|
@@ -19,7 +19,7 @@
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"flarum/core": "^1.4"
|
||||
"flarum/core": "^1.6"
|
||||
},
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
|
@@ -19,7 +19,7 @@
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"flarum/core": "^1.4"
|
||||
"flarum/core": "^1.6"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
@@ -19,7 +19,7 @@
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"flarum/core": "^1.4"
|
||||
"flarum/core": "^1.6"
|
||||
},
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
|
@@ -19,7 +19,7 @@
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"flarum/core": "^1.4"
|
||||
"flarum/core": "^1.6"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
@@ -7,7 +7,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"flarum/core": "^1.4"
|
||||
"flarum/core": "^1.6"
|
||||
},
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
|
@@ -19,7 +19,7 @@
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"flarum/core": "^1.4"
|
||||
"flarum/core": "^1.6"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
@@ -19,7 +19,7 @@
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"flarum/core": "^1.4"
|
||||
"flarum/core": "^1.6"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
@@ -19,7 +19,7 @@
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"flarum/core": "^1.4"
|
||||
"flarum/core": "^1.6"
|
||||
},
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
|
@@ -19,7 +19,7 @@
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"flarum/core": "^1.4"
|
||||
"flarum/core": "^1.6.3"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
@@ -12,10 +12,11 @@ namespace Flarum\Mentions;
|
||||
use Flarum\Api\Controller;
|
||||
use Flarum\Api\Serializer\BasicPostSerializer;
|
||||
use Flarum\Api\Serializer\BasicUserSerializer;
|
||||
use Flarum\Api\Serializer\CurrentUserSerializer;
|
||||
use Flarum\Api\Serializer\GroupSerializer;
|
||||
use Flarum\Api\Serializer\PostSerializer;
|
||||
use Flarum\Extend;
|
||||
use Flarum\Mentions\Notification\PostMentionedBlueprint;
|
||||
use Flarum\Mentions\Notification\UserMentionedBlueprint;
|
||||
use Flarum\Group\Group;
|
||||
use Flarum\Post\Event\Deleted;
|
||||
use Flarum\Post\Event\Hidden;
|
||||
use Flarum\Post\Event\Posted;
|
||||
@@ -37,13 +38,16 @@ return [
|
||||
->configure(ConfigureMentions::class)
|
||||
->render(Formatter\FormatPostMentions::class)
|
||||
->render(Formatter\FormatUserMentions::class)
|
||||
->render(Formatter\FormatGroupMentions::class)
|
||||
->unparse(Formatter\UnparsePostMentions::class)
|
||||
->unparse(Formatter\UnparseUserMentions::class),
|
||||
->unparse(Formatter\UnparseUserMentions::class)
|
||||
->parse(Formatter\CheckPermissions::class),
|
||||
|
||||
(new Extend\Model(Post::class))
|
||||
->belongsToMany('mentionedBy', Post::class, 'post_mentions_post', 'mentions_post_id', 'post_id')
|
||||
->belongsToMany('mentionsPosts', Post::class, 'post_mentions_post', 'post_id', 'mentions_post_id')
|
||||
->belongsToMany('mentionsUsers', User::class, 'post_mentions_user', 'post_id', 'mentions_user_id'),
|
||||
->belongsToMany('mentionsUsers', User::class, 'post_mentions_user', 'post_id', 'mentions_user_id')
|
||||
->belongsToMany('mentionsGroups', Group::class, 'post_mentions_group', 'post_id', 'mentions_group_id'),
|
||||
|
||||
new Extend\Locales(__DIR__.'/locale'),
|
||||
|
||||
@@ -51,25 +55,28 @@ return [
|
||||
->namespace('flarum-mentions', __DIR__.'/views'),
|
||||
|
||||
(new Extend\Notification())
|
||||
->type(PostMentionedBlueprint::class, PostSerializer::class, ['alert'])
|
||||
->type(UserMentionedBlueprint::class, PostSerializer::class, ['alert']),
|
||||
->type(Notification\PostMentionedBlueprint::class, PostSerializer::class, ['alert'])
|
||||
->type(Notification\UserMentionedBlueprint::class, PostSerializer::class, ['alert'])
|
||||
->type(Notification\GroupMentionedBlueprint::class, PostSerializer::class, ['alert']),
|
||||
|
||||
(new Extend\ApiSerializer(BasicPostSerializer::class))
|
||||
->hasMany('mentionedBy', BasicPostSerializer::class)
|
||||
->hasMany('mentionsPosts', BasicPostSerializer::class)
|
||||
->hasMany('mentionsUsers', BasicUserSerializer::class),
|
||||
->hasMany('mentionsUsers', BasicUserSerializer::class)
|
||||
->hasMany('mentionsGroups', GroupSerializer::class),
|
||||
|
||||
(new Extend\ApiController(Controller\ShowDiscussionController::class))
|
||||
->addInclude(['posts.mentionedBy', 'posts.mentionedBy.user', 'posts.mentionedBy.discussion'])
|
||||
->load([
|
||||
'posts.mentionsUsers', 'posts.mentionsPosts', 'posts.mentionsPosts.user', 'posts.mentionedBy',
|
||||
'posts.mentionedBy.mentionsPosts', 'posts.mentionedBy.mentionsPosts.user', 'posts.mentionedBy.mentionsUsers',
|
||||
'posts.mentionsGroups'
|
||||
]),
|
||||
|
||||
(new Extend\ApiController(Controller\ListDiscussionsController::class))
|
||||
->load([
|
||||
'firstPost.mentionsUsers', 'firstPost.mentionsPosts', 'firstPost.mentionsPosts.user',
|
||||
'lastPost.mentionsUsers', 'lastPost.mentionsPosts', 'lastPost.mentionsPosts.user'
|
||||
'firstPost.mentionsUsers', 'firstPost.mentionsPosts', 'firstPost.mentionsPosts.user', 'firstPost.mentionsGroups',
|
||||
'lastPost.mentionsUsers', 'lastPost.mentionsPosts', 'lastPost.mentionsPosts.user', 'lastPost.mentionsGroups'
|
||||
]),
|
||||
|
||||
(new Extend\ApiController(Controller\ShowPostController::class))
|
||||
@@ -80,13 +87,14 @@ return [
|
||||
->load([
|
||||
'mentionsUsers', 'mentionsPosts', 'mentionsPosts.user', 'mentionedBy',
|
||||
'mentionedBy.mentionsPosts', 'mentionedBy.mentionsPosts.user', 'mentionedBy.mentionsUsers',
|
||||
'mentionsGroups'
|
||||
]),
|
||||
|
||||
(new Extend\ApiController(Controller\CreatePostController::class))
|
||||
->addInclude(['mentionsPosts', 'mentionsPosts.mentionedBy']),
|
||||
->addOptionalInclude('mentionsGroups'),
|
||||
|
||||
(new Extend\ApiController(Controller\UpdatePostController::class))
|
||||
->addInclude(['mentionsPosts', 'mentionsPosts.mentionedBy']),
|
||||
->addOptionalInclude('mentionsGroups'),
|
||||
|
||||
(new Extend\ApiController(Controller\AbstractSerializeController::class))
|
||||
->prepareDataForSerialization(FilterVisiblePosts::class),
|
||||
@@ -103,4 +111,9 @@ return [
|
||||
|
||||
(new Extend\Filter(PostFilterer::class))
|
||||
->addFilter(Filter\MentionedFilter::class),
|
||||
|
||||
(new Extend\ApiSerializer(CurrentUserSerializer::class))
|
||||
->attribute('canMentionGroups', function (CurrentUserSerializer $serializer, User $user, array $attributes): bool {
|
||||
return $user->can('mentionGroups');
|
||||
})
|
||||
];
|
||||
|
2
extensions/mentions/js/dist/admin.js
generated
vendored
2
extensions/mentions/js/dist/admin.js
generated
vendored
@@ -1,2 +1,2 @@
|
||||
(()=>{var e={n:t=>{var a=t&&t.__esModule?()=>t.default:()=>t;return e.d(a,{a}),a},d:(t,a)=>{for(var r in a)e.o(a,r)&&!e.o(t,r)&&Object.defineProperty(t,r,{enumerable:!0,get:a[r]})},o:(e,t)=>Object.prototype.hasOwnProperty.call(e,t),r:e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})}},t={};(()=>{"use strict";e.r(t);const a=flarum.core.compat["admin/app"];var r=e.n(a);r().initializers.add("flarum-mentions",(function(){r().extensionData.for("flarum-mentions").registerSetting({setting:"flarum-mentions.allow_username_format",type:"boolean",label:r().translator.trans("flarum-mentions.admin.settings.allow_username_format_label"),help:r().translator.trans("flarum-mentions.admin.settings.allow_username_format_text")})}))})(),module.exports=t})();
|
||||
(()=>{var e={n:t=>{var r=t&&t.__esModule?()=>t.default:()=>t;return e.d(r,{a:r}),r},d:(t,r)=>{for(var a in r)e.o(r,a)&&!e.o(t,a)&&Object.defineProperty(t,a,{enumerable:!0,get:r[a]})},o:(e,t)=>Object.prototype.hasOwnProperty.call(e,t),r:e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})}},t={};(()=>{"use strict";e.r(t);const r=flarum.core.compat["admin/app"];var a=e.n(r);a().initializers.add("flarum-mentions",(function(){a().extensionData.for("flarum-mentions").registerSetting({setting:"flarum-mentions.allow_username_format",type:"boolean",label:a().translator.trans("flarum-mentions.admin.settings.allow_username_format_label"),help:a().translator.trans("flarum-mentions.admin.settings.allow_username_format_text")}).registerPermission({permission:"mentionGroups",label:a().translator.trans("flarum-mentions.admin.permissions.mention_groups_label"),icon:"fas fa-at"},"start")}))})(),module.exports=t})();
|
||||
//# sourceMappingURL=admin.js.map
|
2
extensions/mentions/js/dist/admin.js.map
generated
vendored
2
extensions/mentions/js/dist/admin.js.map
generated
vendored
@@ -1 +1 @@
|
||||
{"version":3,"file":"admin.js","mappings":"MACA,IAAIA,EAAsB,CCA1BA,EAAyBC,IACxB,IAAIC,EAASD,GAAUA,EAAOE,WAC7B,IAAOF,EAAiB,QACxB,IAAM,EAEP,OADAD,EAAoBI,EAAEF,EAAQ,CAAEG,IACzBH,CAAM,ECLdF,EAAwB,CAACM,EAASC,KACjC,IAAI,IAAIC,KAAOD,EACXP,EAAoBS,EAAEF,EAAYC,KAASR,EAAoBS,EAAEH,EAASE,IAC5EE,OAAOC,eAAeL,EAASE,EAAK,CAAEI,YAAY,EAAMC,IAAKN,EAAWC,IAE1E,ECNDR,EAAwB,CAACc,EAAKC,IAAUL,OAAOM,UAAUC,eAAeC,KAAKJ,EAAKC,GCClFf,EAAyBM,IACH,oBAAXa,QAA0BA,OAAOC,aAC1CV,OAAOC,eAAeL,EAASa,OAAOC,YAAa,CAAEC,MAAO,WAE7DX,OAAOC,eAAeL,EAAS,aAAc,CAAEe,OAAO,GAAO,G,+BCL9D,MAAM,EAA+BC,OAAOC,KAAKC,OAAO,a,aCExDC,IAAAA,aAAAA,IAAqB,mBAAmB,WACtCA,IAAAA,cAAAA,IAAsB,mBAAmBC,gBAAgB,CACvDC,QAAS,wCACTC,KAAM,UACNC,MAAOJ,IAAAA,WAAAA,MAAqB,8DAC5BK,KAAML,IAAAA,WAAAA,MAAqB,8DAE9B,G","sources":["webpack://@flarum/mentions/webpack/bootstrap","webpack://@flarum/mentions/webpack/runtime/compat get default export","webpack://@flarum/mentions/webpack/runtime/define property getters","webpack://@flarum/mentions/webpack/runtime/hasOwnProperty shorthand","webpack://@flarum/mentions/webpack/runtime/make namespace object","webpack://@flarum/mentions/external root \"flarum.core.compat['admin/app']\"","webpack://@flarum/mentions/./src/admin/index.js"],"sourcesContent":["// The require scope\nvar __webpack_require__ = {};\n\n","// getDefaultExport function for compatibility with non-harmony modules\n__webpack_require__.n = (module) => {\n\tvar getter = module && module.__esModule ?\n\t\t() => (module['default']) :\n\t\t() => (module);\n\t__webpack_require__.d(getter, { a: getter });\n\treturn getter;\n};","// define getter functions for harmony exports\n__webpack_require__.d = (exports, definition) => {\n\tfor(var key in definition) {\n\t\tif(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {\n\t\t\tObject.defineProperty(exports, key, { enumerable: true, get: definition[key] });\n\t\t}\n\t}\n};","__webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop))","// define __esModule on exports\n__webpack_require__.r = (exports) => {\n\tif(typeof Symbol !== 'undefined' && Symbol.toStringTag) {\n\t\tObject.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });\n\t}\n\tObject.defineProperty(exports, '__esModule', { value: true });\n};","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.core.compat['admin/app'];","import app from 'flarum/admin/app';\n\napp.initializers.add('flarum-mentions', function () {\n app.extensionData.for('flarum-mentions').registerSetting({\n setting: 'flarum-mentions.allow_username_format',\n type: 'boolean',\n label: app.translator.trans('flarum-mentions.admin.settings.allow_username_format_label'),\n help: app.translator.trans('flarum-mentions.admin.settings.allow_username_format_text'),\n });\n});\n"],"names":["__webpack_require__","module","getter","__esModule","d","a","exports","definition","key","o","Object","defineProperty","enumerable","get","obj","prop","prototype","hasOwnProperty","call","Symbol","toStringTag","value","flarum","core","compat","app","registerSetting","setting","type","label","help"],"sourceRoot":""}
|
||||
{"version":3,"file":"admin.js","mappings":"MACA,IAAIA,EAAsB,CCA1BA,EAAyBC,IACxB,IAAIC,EAASD,GAAUA,EAAOE,WAC7B,IAAOF,EAAiB,QACxB,IAAM,EAEP,OADAD,EAAoBI,EAAEF,EAAQ,CAAEG,EAAGH,IAC5BA,CAAM,ECLdF,EAAwB,CAACM,EAASC,KACjC,IAAI,IAAIC,KAAOD,EACXP,EAAoBS,EAAEF,EAAYC,KAASR,EAAoBS,EAAEH,EAASE,IAC5EE,OAAOC,eAAeL,EAASE,EAAK,CAAEI,YAAY,EAAMC,IAAKN,EAAWC,IAE1E,ECNDR,EAAwB,CAACc,EAAKC,IAAUL,OAAOM,UAAUC,eAAeC,KAAKJ,EAAKC,GCClFf,EAAyBM,IACH,oBAAXa,QAA0BA,OAAOC,aAC1CV,OAAOC,eAAeL,EAASa,OAAOC,YAAa,CAAEC,MAAO,WAE7DX,OAAOC,eAAeL,EAAS,aAAc,CAAEe,OAAO,GAAO,G,+BCL9D,MAAM,EAA+BC,OAAOC,KAAKC,OAAO,a,aCExDC,IAAAA,aAAAA,IAAqB,mBAAmB,WACtCA,IAAAA,cAAAA,IACO,mBACJC,gBAAgB,CACfC,QAAS,wCACTC,KAAM,UACNC,MAAOJ,IAAAA,WAAAA,MAAqB,8DAC5BK,KAAML,IAAAA,WAAAA,MAAqB,+DAE5BM,mBACC,CACEC,WAAY,gBACZH,MAAOJ,IAAAA,WAAAA,MAAqB,0DAC5BQ,KAAM,aAER,QAEL,G","sources":["webpack://@flarum/mentions/webpack/bootstrap","webpack://@flarum/mentions/webpack/runtime/compat get default export","webpack://@flarum/mentions/webpack/runtime/define property getters","webpack://@flarum/mentions/webpack/runtime/hasOwnProperty shorthand","webpack://@flarum/mentions/webpack/runtime/make namespace object","webpack://@flarum/mentions/external root \"flarum.core.compat['admin/app']\"","webpack://@flarum/mentions/./src/admin/index.js"],"sourcesContent":["// The require scope\nvar __webpack_require__ = {};\n\n","// getDefaultExport function for compatibility with non-harmony modules\n__webpack_require__.n = (module) => {\n\tvar getter = module && module.__esModule ?\n\t\t() => (module['default']) :\n\t\t() => (module);\n\t__webpack_require__.d(getter, { a: getter });\n\treturn getter;\n};","// define getter functions for harmony exports\n__webpack_require__.d = (exports, definition) => {\n\tfor(var key in definition) {\n\t\tif(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {\n\t\t\tObject.defineProperty(exports, key, { enumerable: true, get: definition[key] });\n\t\t}\n\t}\n};","__webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop))","// define __esModule on exports\n__webpack_require__.r = (exports) => {\n\tif(typeof Symbol !== 'undefined' && Symbol.toStringTag) {\n\t\tObject.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });\n\t}\n\tObject.defineProperty(exports, '__esModule', { value: true });\n};","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.core.compat['admin/app'];","import app from 'flarum/admin/app';\n\napp.initializers.add('flarum-mentions', function () {\n app.extensionData\n .for('flarum-mentions')\n .registerSetting({\n setting: 'flarum-mentions.allow_username_format',\n type: 'boolean',\n label: app.translator.trans('flarum-mentions.admin.settings.allow_username_format_label'),\n help: app.translator.trans('flarum-mentions.admin.settings.allow_username_format_text'),\n })\n .registerPermission(\n {\n permission: 'mentionGroups',\n label: app.translator.trans('flarum-mentions.admin.permissions.mention_groups_label'),\n icon: 'fas fa-at',\n },\n 'start'\n );\n});\n"],"names":["__webpack_require__","module","getter","__esModule","d","a","exports","definition","key","o","Object","defineProperty","enumerable","get","obj","prop","prototype","hasOwnProperty","call","Symbol","toStringTag","value","flarum","core","compat","app","registerSetting","setting","type","label","help","registerPermission","permission","icon"],"sourceRoot":""}
|
2
extensions/mentions/js/dist/forum.js
generated
vendored
2
extensions/mentions/js/dist/forum.js
generated
vendored
File diff suppressed because one or more lines are too long
2
extensions/mentions/js/dist/forum.js.map
generated
vendored
2
extensions/mentions/js/dist/forum.js.map
generated
vendored
File diff suppressed because one or more lines are too long
@@ -1,10 +1,20 @@
|
||||
import app from 'flarum/admin/app';
|
||||
|
||||
app.initializers.add('flarum-mentions', function () {
|
||||
app.extensionData.for('flarum-mentions').registerSetting({
|
||||
setting: 'flarum-mentions.allow_username_format',
|
||||
type: 'boolean',
|
||||
label: app.translator.trans('flarum-mentions.admin.settings.allow_username_format_label'),
|
||||
help: app.translator.trans('flarum-mentions.admin.settings.allow_username_format_text'),
|
||||
});
|
||||
app.extensionData
|
||||
.for('flarum-mentions')
|
||||
.registerSetting({
|
||||
setting: 'flarum-mentions.allow_username_format',
|
||||
type: 'boolean',
|
||||
label: app.translator.trans('flarum-mentions.admin.settings.allow_username_format_label'),
|
||||
help: app.translator.trans('flarum-mentions.admin.settings.allow_username_format_text'),
|
||||
})
|
||||
.registerPermission(
|
||||
{
|
||||
permission: 'mentionGroups',
|
||||
label: app.translator.trans('flarum-mentions.admin.permissions.mention_groups_label'),
|
||||
icon: 'fas fa-at',
|
||||
},
|
||||
'start'
|
||||
);
|
||||
});
|
||||
|
@@ -10,6 +10,8 @@ import highlight from 'flarum/common/helpers/highlight';
|
||||
import KeyboardNavigatable from 'flarum/forum/utils/KeyboardNavigatable';
|
||||
import { truncate } from 'flarum/common/utils/string';
|
||||
import { throttle } from 'flarum/common/utils/throttleDebounce';
|
||||
import Badge from 'flarum/common/components/Badge';
|
||||
import Group from 'flarum/common/models/Group';
|
||||
|
||||
import AutocompleteDropdown from './fragments/AutocompleteDropdown';
|
||||
import getMentionText from './utils/getMentionText';
|
||||
@@ -29,6 +31,7 @@ const throttledSearch = throttle(
|
||||
|
||||
buildSuggestions();
|
||||
});
|
||||
|
||||
searched.push(typedLower);
|
||||
}
|
||||
}
|
||||
@@ -66,6 +69,13 @@ export default function addComposerAutocomplete() {
|
||||
const returnedUsers = Array.from(app.store.all('users'));
|
||||
const returnedUserIds = new Set(returnedUsers.map((u) => u.id()));
|
||||
|
||||
// Store groups, but exclude the two virtual groups - 'Guest' and 'Member'.
|
||||
const returnedGroups = Array.from(
|
||||
app.store.all('groups').filter((group) => {
|
||||
return group.id() != Group.GUEST_ID && group.id() != Group.MEMBER_ID;
|
||||
})
|
||||
);
|
||||
|
||||
const applySuggestion = (replacement) => {
|
||||
this.attrs.composer.editor.replaceBeforeCursor(absMentionStart - 1, replacement + ' ');
|
||||
|
||||
@@ -124,12 +134,41 @@ export default function addComposerAutocomplete() {
|
||||
);
|
||||
};
|
||||
|
||||
const makeGroupSuggestion = function (group, replacement, content, className = '') {
|
||||
let groupName = group.namePlural().toLowerCase();
|
||||
|
||||
if (typed) {
|
||||
groupName = highlight(groupName, typed);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className={'PostPreview ' + className}
|
||||
onclick={() => applySuggestion(replacement)}
|
||||
onmouseenter={function () {
|
||||
dropdown.setIndex($(this).parent().index());
|
||||
}}
|
||||
>
|
||||
<span className="PostPreview-content">
|
||||
<Badge class={`Avatar Badge Badge--group--${group.id()} Badge-icon `} color={group.color()} type="group" icon={group.icon()} />
|
||||
<span className="username">{groupName}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const userMatches = function (user) {
|
||||
const names = [user.username(), user.displayName()];
|
||||
|
||||
return names.some((name) => name.toLowerCase().substr(0, typed.length) === typed);
|
||||
};
|
||||
|
||||
const groupMatches = function (group) {
|
||||
const names = [group.nameSingular(), group.namePlural()];
|
||||
|
||||
return names.some((name) => name.toLowerCase().substr(0, typed.length) === typed);
|
||||
};
|
||||
|
||||
const buildSuggestions = () => {
|
||||
const suggestions = [];
|
||||
|
||||
@@ -141,6 +180,15 @@ export default function addComposerAutocomplete() {
|
||||
|
||||
suggestions.push(makeSuggestion(user, getMentionText(user), '', 'MentionsDropdown-user'));
|
||||
});
|
||||
|
||||
// ... or groups.
|
||||
if (app.session?.user?.canMentionGroups()) {
|
||||
returnedGroups.forEach((group) => {
|
||||
if (!groupMatches(group)) return;
|
||||
|
||||
suggestions.push(makeGroupSuggestion(group, getMentionText(undefined, undefined, group), '', 'MentionsDropdown-group'));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// If the user is replying to a discussion, or if they are editing a
|
||||
|
@@ -1,3 +1,4 @@
|
||||
import GroupMentionedNotification from './components/GroupMentionedNotification';
|
||||
import MentionsUserPage from './components/MentionsUserPage';
|
||||
import PostMentionedNotification from './components/PostMentionedNotification';
|
||||
import UserMentionedNotification from './components/UserMentionedNotification';
|
||||
@@ -13,6 +14,7 @@ export default {
|
||||
'mentions/components/MentionsUserPage': MentionsUserPage,
|
||||
'mentions/components/PostMentionedNotification': PostMentionedNotification,
|
||||
'mentions/components/UserMentionedNotification': UserMentionedNotification,
|
||||
'mentions/components/GroupMentionedNotification': GroupMentionedNotification,
|
||||
'mentions/fragments/AutocompleteDropdown': AutocompleteDropdown,
|
||||
'mentions/fragments/PostQuoteButton': PostQuoteButton,
|
||||
'mentions/utils/getCleanDisplayName': getCleanDisplayName,
|
||||
|
@@ -0,0 +1,25 @@
|
||||
import app from 'flarum/forum/app';
|
||||
import Notification from 'flarum/forum/components/Notification';
|
||||
import { truncate } from 'flarum/common/utils/string';
|
||||
|
||||
export default class GroupMentionedNotification extends Notification {
|
||||
icon() {
|
||||
return 'fas fa-at';
|
||||
}
|
||||
|
||||
href() {
|
||||
const post = this.attrs.notification.subject();
|
||||
|
||||
return app.route.discussion(post.discussion(), post.number());
|
||||
}
|
||||
|
||||
content() {
|
||||
const user = this.attrs.notification.fromUser();
|
||||
|
||||
return app.translator.trans('flarum-mentions.forum.notifications.group_mentioned_text', { user });
|
||||
}
|
||||
|
||||
excerpt() {
|
||||
return truncate(this.attrs.notification.subject().contentPlain(), 200);
|
||||
}
|
||||
}
|
@@ -10,11 +10,16 @@ import addPostQuoteButton from './addPostQuoteButton';
|
||||
import addComposerAutocomplete from './addComposerAutocomplete';
|
||||
import PostMentionedNotification from './components/PostMentionedNotification';
|
||||
import UserMentionedNotification from './components/UserMentionedNotification';
|
||||
import GroupMentionedNotification from './components/GroupMentionedNotification';
|
||||
import UserPage from 'flarum/forum/components/UserPage';
|
||||
import LinkButton from 'flarum/common/components/LinkButton';
|
||||
import MentionsUserPage from './components/MentionsUserPage';
|
||||
import User from 'flarum/common/models/User';
|
||||
import Model from 'flarum/common/Model';
|
||||
|
||||
app.initializers.add('flarum-mentions', function () {
|
||||
User.prototype.canMentionGroups = Model.attribute('canMentionGroups');
|
||||
|
||||
// For every mention of a post inside a post's content, set up a hover handler
|
||||
// that shows a preview of the mentioned post.
|
||||
addPostMentionPreviews();
|
||||
@@ -36,6 +41,7 @@ app.initializers.add('flarum-mentions', function () {
|
||||
|
||||
app.notificationComponents.postMentioned = PostMentionedNotification;
|
||||
app.notificationComponents.userMentioned = UserMentionedNotification;
|
||||
app.notificationComponents.groupMentioned = GroupMentionedNotification;
|
||||
|
||||
// Add notification preferences.
|
||||
extend(NotificationGrid.prototype, 'notificationTypes', function (items) {
|
||||
@@ -50,6 +56,12 @@ app.initializers.add('flarum-mentions', function () {
|
||||
icon: 'fas fa-at',
|
||||
label: app.translator.trans('flarum-mentions.forum.settings.notify_user_mentioned_label'),
|
||||
});
|
||||
|
||||
items.add('groupMentioned', {
|
||||
name: 'groupMentioned',
|
||||
icon: 'fas fa-at',
|
||||
label: app.translator.trans('flarum-mentions.forum.settings.notify_group_mentioned_label'),
|
||||
});
|
||||
});
|
||||
|
||||
// Add mentions tab in user profile
|
||||
|
@@ -1,7 +1,7 @@
|
||||
import getCleanDisplayName, { shouldUseOldFormat } from './getCleanDisplayName';
|
||||
|
||||
/**
|
||||
* Fetches the mention text for a specified user (and optionally a post ID for replies).
|
||||
* Fetches the mention text for a specified user (and optionally a post ID for replies, or group).
|
||||
*
|
||||
* Automatically determines which mention syntax to be used based on the option in the
|
||||
* admin dashboard. Also performs display name clean-up automatically.
|
||||
@@ -17,9 +17,13 @@ import getCleanDisplayName, { shouldUseOldFormat } from './getCleanDisplayName';
|
||||
* @example <caption>Using old syntax</caption>
|
||||
* // '@username'
|
||||
* getMentionText(User) // User's username is 'username'
|
||||
*
|
||||
* @example <caption>Group mention</caption>
|
||||
* // '@"Mods"#g4'
|
||||
* getMentionText(undefined, undefined, group) // Group display name is 'Mods', group ID is 4
|
||||
*/
|
||||
export default function getMentionText(user, postId) {
|
||||
if (postId === undefined) {
|
||||
export default function getMentionText(user, postId, group) {
|
||||
if (user !== undefined && postId === undefined) {
|
||||
if (shouldUseOldFormat()) {
|
||||
// Plain @username
|
||||
const cleanText = getCleanDisplayName(user, false);
|
||||
@@ -28,9 +32,14 @@ export default function getMentionText(user, postId) {
|
||||
// @"Display name"#UserID
|
||||
const cleanText = getCleanDisplayName(user);
|
||||
return `@"${cleanText}"#${user.id()}`;
|
||||
} else {
|
||||
} else if (user !== undefined && postId !== undefined) {
|
||||
// @"Display name"#pPostID
|
||||
const cleanText = getCleanDisplayName(user);
|
||||
return `@"${cleanText}"#p${postId}`;
|
||||
} else if (group !== undefined) {
|
||||
// @"Name Plural"#gGroupID
|
||||
return `@"${group.namePlural()}"#g${group.id()}`;
|
||||
} else {
|
||||
throw 'No parameters were passed';
|
||||
}
|
||||
}
|
||||
|
@@ -4,7 +4,7 @@
|
||||
export default function selectedText(body) {
|
||||
const selection = window.getSelection();
|
||||
|
||||
if (selection?.rangeCount) {
|
||||
if (!selection.isCollapsed) {
|
||||
const range = selection.getRangeAt(0);
|
||||
const parent = range.commonAncestorContainer;
|
||||
|
||||
|
@@ -1,6 +1,7 @@
|
||||
import app from 'flarum/forum/app';
|
||||
import username from 'flarum/common/helpers/username';
|
||||
import extractText from 'flarum/common/utils/extractText';
|
||||
import isDark from 'flarum/common/utils/isDark';
|
||||
|
||||
export function filterUserMentions(tag) {
|
||||
let user;
|
||||
@@ -31,3 +32,20 @@ export function filterPostMentions(tag) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function filterGroupMentions(tag) {
|
||||
if (app.session?.user?.canMentionGroups()) {
|
||||
const group = app.store.getById('groups', tag.getAttribute('id'));
|
||||
|
||||
if (group) {
|
||||
tag.setAttribute('groupname', extractText(group.namePlural()));
|
||||
tag.setAttribute('icon', group.icon());
|
||||
tag.setAttribute('color', group.color());
|
||||
tag.setAttribute('class', isDark(group.color()) ? 'GroupMention--light' : 'GroupMention--dark');
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
tag.invalidate();
|
||||
}
|
||||
|
@@ -1,4 +1,4 @@
|
||||
.PostMention, .UserMention {
|
||||
.PostMention, .UserMention, .GroupMention {
|
||||
background: @control-bg;
|
||||
color: @control-color;
|
||||
border-radius: @border-radius;
|
||||
@@ -14,7 +14,7 @@
|
||||
color: @link-color;
|
||||
}
|
||||
}
|
||||
.UserMention, .PostMention {
|
||||
.UserMention, .PostMention, .GroupMention {
|
||||
&--deleted {
|
||||
opacity: 0.8;
|
||||
filter: grayscale(1);
|
||||
@@ -97,6 +97,45 @@
|
||||
position: absolute;
|
||||
.Button--color(@tooltip-color, @tooltip-bg);
|
||||
}
|
||||
.GroupMention {
|
||||
& when (@config-dark-mode = false) {
|
||||
&,
|
||||
&:hover,
|
||||
&:active {
|
||||
color: @text-on-light;
|
||||
}
|
||||
}
|
||||
& when (@config-dark-mode = true) {
|
||||
&,
|
||||
&:hover,
|
||||
&:active {
|
||||
color: @text-on-dark;
|
||||
}
|
||||
}
|
||||
|
||||
&--light {
|
||||
&,
|
||||
&:hover,
|
||||
&:active {
|
||||
color: @text-on-light;
|
||||
}
|
||||
}
|
||||
|
||||
&--dark {
|
||||
&,
|
||||
&:hover,
|
||||
&:active {
|
||||
color: @text-on-dark;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-left: 5px;
|
||||
}
|
||||
}
|
||||
.MentionsDropdown .Badge {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
@media @phone {
|
||||
.MentionsDropdown {
|
||||
|
@@ -7,6 +7,9 @@ flarum-mentions:
|
||||
# Translations in this namespace are used by the admin interface.
|
||||
admin:
|
||||
|
||||
# These translations are used in the mentions permissions
|
||||
permissions:
|
||||
mention_groups_label: Mention groups
|
||||
# These translations are used in the mentions Settings page.
|
||||
settings:
|
||||
allow_username_format_label: Allow username mention format (@Username)
|
||||
@@ -19,7 +22,7 @@ flarum-mentions:
|
||||
|
||||
# These translations are used by the composer (reply autocompletion function).
|
||||
composer:
|
||||
mention_tooltip: Mention a user or post
|
||||
mention_tooltip: Mention a user, group or post
|
||||
reply_to_post_text: "Reply to #{number}"
|
||||
|
||||
# These translations are used by the Notifications dropdown, a.k.a. "the bell".
|
||||
@@ -27,6 +30,7 @@ flarum-mentions:
|
||||
others_text: => core.ref.some_others
|
||||
post_mentioned_text: "{username} replied to your post" # Can be pluralized to agree with the number of users!
|
||||
user_mentioned_text: "{username} mentioned you"
|
||||
group_mentioned_text: "{username} mentioned a group you're a member of"
|
||||
|
||||
# These translations are displayed beneath individual posts.
|
||||
post:
|
||||
@@ -41,6 +45,7 @@ flarum-mentions:
|
||||
settings:
|
||||
notify_post_mentioned_label: Someone replies to one of my posts
|
||||
notify_user_mentioned_label: Someone mentions me in a post
|
||||
notify_group_mentioned_label: Someone mentions a group I'm a member of in a post
|
||||
|
||||
# These translations are used in the user profile page and profile popup.
|
||||
user:
|
||||
@@ -50,6 +55,9 @@ flarum-mentions:
|
||||
post_mention:
|
||||
deleted_text: "[unknown]"
|
||||
|
||||
group_mention:
|
||||
deleted_text: "[unknown group]"
|
||||
|
||||
# Translations in this namespace are used in emails sent by the forum.
|
||||
email:
|
||||
|
||||
@@ -80,4 +88,16 @@ flarum-mentions:
|
||||
---
|
||||
|
||||
{content}
|
||||
# These translations are used in emails sent when a group is mentioned
|
||||
group_mentioned:
|
||||
subject: "{mentioner_display_name} mentioned a group you're a member of in {title}"
|
||||
body: |
|
||||
Hey {recipient_display_name}!
|
||||
|
||||
{mentioner_display_name} mentioned a group you're a member of in {title}.
|
||||
|
||||
{url}
|
||||
|
||||
---
|
||||
|
||||
{content}
|
||||
|
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Database\Schema\Builder;
|
||||
|
||||
return [
|
||||
'up' => function (Builder $schema) {
|
||||
$schema->create('post_mentions_group', function (Blueprint $table) {
|
||||
$table->integer('post_id')->unsigned();
|
||||
$table->integer('mentions_group_id')->unsigned();
|
||||
$table->dateTime('created_at')->useCurrent()->nullable();
|
||||
$table->primary(['post_id', 'mentions_group_id']);
|
||||
|
||||
$table->foreign('post_id')->references('id')->on('posts')->onDelete('cascade');
|
||||
$table->foreign('mentions_group_id')->references('id')->on('groups')->onDelete('cascade');
|
||||
});
|
||||
},
|
||||
|
||||
'down' => function (Builder $schema) {
|
||||
$schema->drop('post_mentions_group');
|
||||
}
|
||||
];
|
@@ -9,10 +9,12 @@
|
||||
|
||||
namespace Flarum\Mentions;
|
||||
|
||||
use Flarum\Group\Group;
|
||||
use Flarum\Http\UrlGenerator;
|
||||
use Flarum\Post\CommentPost;
|
||||
use Flarum\Post\PostRepository;
|
||||
use Flarum\Settings\SettingsRepositoryInterface;
|
||||
use Flarum\User\User;
|
||||
use Illuminate\Support\Str;
|
||||
use s9e\TextFormatter\Configurator;
|
||||
|
||||
class ConfigureMentions
|
||||
@@ -34,6 +36,7 @@ class ConfigureMentions
|
||||
{
|
||||
$this->configureUserMentions($config);
|
||||
$this->configurePostMentions($config);
|
||||
$this->configureGroupMentions($config);
|
||||
}
|
||||
|
||||
private function configureUserMentions(Configurator $config)
|
||||
@@ -112,7 +115,8 @@ class ConfigureMentions
|
||||
|
||||
$tag->filterChain
|
||||
->prepend([static::class, 'addPostId'])
|
||||
->setJS('function(tag) { return flarum.extensions["flarum-mentions"].filterPostMentions(tag); }');
|
||||
->setJS('function(tag) { return flarum.extensions["flarum-mentions"].filterPostMentions(tag); }')
|
||||
->addParameterByName('actor');
|
||||
|
||||
$config->Preg->match('/\B@["|“](?<displayname>((?!"#[a-z]{0,3}[0-9]+).)+)["|”]#p(?<id>[0-9]+)\b/', $tagName);
|
||||
}
|
||||
@@ -121,9 +125,11 @@ class ConfigureMentions
|
||||
* @param $tag
|
||||
* @return bool
|
||||
*/
|
||||
public static function addPostId($tag)
|
||||
public static function addPostId($tag, User $actor)
|
||||
{
|
||||
$post = CommentPost::find($tag->getAttribute('id'));
|
||||
$post = resolve(PostRepository::class)
|
||||
->queryVisibleTo($actor)
|
||||
->find($tag->getAttribute('id'));
|
||||
|
||||
if ($post) {
|
||||
$tag->setAttribute('discussionid', (int) $post->discussion_id);
|
||||
@@ -136,4 +142,80 @@ class ConfigureMentions
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private function configureGroupMentions(Configurator $config)
|
||||
{
|
||||
$tagName = 'GROUPMENTION';
|
||||
|
||||
$tag = $config->tags->add($tagName);
|
||||
$tag->attributes->add('groupname');
|
||||
$tag->attributes->add('icon');
|
||||
$tag->attributes->add('color');
|
||||
$tag->attributes->add('class');
|
||||
$tag->attributes->add('id')->filterChain->append('#uint');
|
||||
|
||||
$tag->template = '
|
||||
<xsl:choose>
|
||||
<xsl:when test="@deleted != 1">
|
||||
<span class="GroupMention {@class}" style="background: {@color}">@<xsl:value-of select="@groupname"/><i class="icon {@icon}"></i></span>
|
||||
</xsl:when>
|
||||
<xsl:otherwise>
|
||||
<span class="GroupMention GroupMention--deleted" style="background: {@color}">@<xsl:value-of select="@groupname"/><i class="icon {@icon}"></i></span>
|
||||
</xsl:otherwise>
|
||||
</xsl:choose>';
|
||||
$tag->filterChain->prepend([static::class, 'addGroupId'])
|
||||
->setJS('function(tag) { return flarum.extensions["flarum-mentions"].filterGroupMentions(tag); }');
|
||||
|
||||
$config->Preg->match('/\B@["|“](?<groupname>((?!"#[a-z]{0,3}[0-9]+).)+)["|”]#g(?<id>[0-9]+)\b/', $tagName);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param $tag
|
||||
* @return bool
|
||||
*/
|
||||
public static function addGroupId($tag)
|
||||
{
|
||||
$group = Group::find($tag->getAttribute('id'));
|
||||
|
||||
if (isset($group) && ! in_array($group->id, [Group::GUEST_ID, Group::MEMBER_ID])) {
|
||||
$tag->setAttribute('id', $group->id);
|
||||
$tag->setAttribute('groupname', $group->name_plural);
|
||||
$tag->setAttribute('icon', $group->icon ?? 'fas fa-at');
|
||||
$tag->setAttribute('color', $group->color);
|
||||
if (! empty($group->color)) {
|
||||
$tag->setAttribute('class', self::isDark($group->color) ? 'GroupMention--light' : 'GroupMention--dark');
|
||||
} else {
|
||||
$tag->setAttribute('class', '');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
$tag->invalidate();
|
||||
}
|
||||
|
||||
/**
|
||||
* The `isDark` utility converts a hex color to rgb, and then calcul a YIQ
|
||||
* value in order to get the appropriate brightness value (is it dark or is it
|
||||
* light?) See https://www.w3.org/TR/AERT/#color-contrast for references. A YIQ
|
||||
* value >= 128 is a light color.
|
||||
*/
|
||||
public static function isDark(?string $hexColor): bool
|
||||
{
|
||||
if (! $hexColor) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$hexNumbers = Str::replace('#', '', $hexColor);
|
||||
if (Str::length($hexNumbers) === 3) {
|
||||
$hexNumbers += $hexNumbers;
|
||||
}
|
||||
|
||||
$r = hexdec(Str::substr($hexNumbers, 0, 2));
|
||||
$g = hexdec(Str::subStr($hexNumbers, 2, 2));
|
||||
$b = hexdec(Str::subStr($hexNumbers, 4, 2));
|
||||
$yiq = ($r * 299 + $g * 587 + $b * 114) / 1000;
|
||||
|
||||
return $yiq >= 128 ? false : true;
|
||||
}
|
||||
}
|
||||
|
@@ -54,8 +54,8 @@ class FilterVisiblePosts
|
||||
|| $controller instanceof Controller\CreatePostController
|
||||
|| $controller instanceof Controller\UpdatePostController) {
|
||||
$relations = [
|
||||
'mentionsUsers', 'mentionsPosts', 'mentionsPosts.user', 'mentionedBy',
|
||||
'mentionedBy.mentionsPosts', 'mentionedBy.mentionsPosts.user', 'mentionedBy.mentionsUsers'
|
||||
'mentionsUsers', 'mentionsPosts', 'mentionsPosts.user', 'mentionedBy', 'mentionsGroups',
|
||||
'mentionedBy.mentionsPosts', 'mentionedBy.mentionsPosts.user', 'mentionedBy.mentionsUsers', 'mentionedBy.mentionsGroups.group'
|
||||
];
|
||||
|
||||
$posts = [$data];
|
||||
|
26
extensions/mentions/src/Formatter/CheckPermissions.php
Normal file
26
extensions/mentions/src/Formatter/CheckPermissions.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Mentions\Formatter;
|
||||
|
||||
use Flarum\User\User;
|
||||
use s9e\TextFormatter\Parser;
|
||||
|
||||
class CheckPermissions
|
||||
{
|
||||
public function __invoke(Parser $parser, $content, string $text, ?User $actor): string
|
||||
{
|
||||
// Check user has `mentionGroups` permission, if not, remove the `GROUPMENTION` tag from the parser.
|
||||
if ($actor && $actor->cannot('mentionGroups')) {
|
||||
$parser->disableTag('GROUPMENTION');
|
||||
}
|
||||
|
||||
return $text;
|
||||
}
|
||||
}
|
59
extensions/mentions/src/Formatter/FormatGroupMentions.php
Normal file
59
extensions/mentions/src/Formatter/FormatGroupMentions.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Mentions\Formatter;
|
||||
|
||||
use Flarum\Group\Group;
|
||||
use Flarum\Post\Post;
|
||||
use s9e\TextFormatter\Renderer;
|
||||
use s9e\TextFormatter\Utils;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
class FormatGroupMentions
|
||||
{
|
||||
/**
|
||||
* @var TranslatorInterface
|
||||
*/
|
||||
private $translator;
|
||||
|
||||
public function __construct(TranslatorInterface $translator)
|
||||
{
|
||||
$this->translator = $translator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure rendering for group mentions.
|
||||
*
|
||||
* @param \s9e\TextFormatter\Renderer $renderer
|
||||
* @param mixed $context
|
||||
* @param string $xml
|
||||
* @return string
|
||||
*/
|
||||
public function __invoke(Renderer $renderer, $context, string $xml): string
|
||||
{
|
||||
return Utils::replaceAttributes($xml, 'GROUPMENTION', function ($attributes) use ($context) {
|
||||
$group = (($context && isset($context->getRelations()['mentionsGroups'])) || $context instanceof Post)
|
||||
? $context->mentionsGroups->find($attributes['id'])
|
||||
: Group::find($attributes['id']);
|
||||
|
||||
if ($group) {
|
||||
$attributes['groupname'] = $group->name_plural;
|
||||
$attributes['icon'] = $group->icon ?? 'fas fa-at';
|
||||
$attributes['color'] = $group->color;
|
||||
$attributes['deleted'] = false;
|
||||
} else {
|
||||
$attributes['groupname'] = $this->translator->trans('flarum-mentions.forum.group_mention.deleted_text');
|
||||
$attributes['icon'] = '';
|
||||
$attributes['deleted'] = true;
|
||||
}
|
||||
|
||||
return $attributes;
|
||||
});
|
||||
}
|
||||
}
|
@@ -40,5 +40,8 @@ class UpdateMentionsMetadataWhenInvisible
|
||||
|
||||
// Remove post mentions
|
||||
$event->post->mentionsPosts()->sync([]);
|
||||
|
||||
// Remove group mentions
|
||||
$event->post->mentionsGroups()->sync([]);
|
||||
}
|
||||
}
|
||||
|
@@ -9,6 +9,7 @@
|
||||
|
||||
namespace Flarum\Mentions\Listener;
|
||||
|
||||
use Flarum\Mentions\Notification\GroupMentionedBlueprint;
|
||||
use Flarum\Mentions\Notification\PostMentionedBlueprint;
|
||||
use Flarum\Mentions\Notification\UserMentionedBlueprint;
|
||||
use Flarum\Notification\NotificationSyncer;
|
||||
@@ -50,6 +51,11 @@ class UpdateMentionsMetadataWhenVisible
|
||||
$event->post,
|
||||
Utils::getAttributeValues($content, 'POSTMENTION', 'id')
|
||||
);
|
||||
|
||||
$this->syncGroupMentions(
|
||||
$event->post,
|
||||
Utils::getAttributeValues($content, 'GROUPMENTION', 'id')
|
||||
);
|
||||
}
|
||||
|
||||
protected function syncUserMentions(Post $post, array $mentioned)
|
||||
@@ -60,7 +66,7 @@ class UpdateMentionsMetadataWhenVisible
|
||||
$users = User::whereIn('id', $mentioned)
|
||||
->get()
|
||||
->filter(function ($user) use ($post) {
|
||||
return $post->isVisibleTo($user) && $user->id !== $post->user->id;
|
||||
return $post->isVisibleTo($user) && $user->id !== $post->user_id;
|
||||
})
|
||||
->all();
|
||||
|
||||
@@ -75,8 +81,8 @@ class UpdateMentionsMetadataWhenVisible
|
||||
$posts = Post::with('user')
|
||||
->whereIn('id', $mentioned)
|
||||
->get()
|
||||
->filter(function ($post) use ($reply) {
|
||||
return $post->user && $post->user->id !== $reply->user_id && $reply->isVisibleTo($post->user);
|
||||
->filter(function (Post $post) use ($reply) {
|
||||
return $post->user && $post->user_id !== $reply->user_id && $reply->isVisibleTo($post->user);
|
||||
})
|
||||
->all();
|
||||
|
||||
@@ -84,4 +90,21 @@ class UpdateMentionsMetadataWhenVisible
|
||||
$this->notifications->sync(new PostMentionedBlueprint($post, $reply), [$post->user]);
|
||||
}
|
||||
}
|
||||
|
||||
protected function syncGroupMentions(Post $post, array $mentioned)
|
||||
{
|
||||
$post->mentionsGroups()->sync($mentioned);
|
||||
$post->unsetRelation('mentionsGroups');
|
||||
|
||||
$users = User::whereHas('groups', function ($query) use ($mentioned) {
|
||||
$query->whereIn('id', $mentioned);
|
||||
})
|
||||
->get()
|
||||
->filter(function (User $user) use ($post) {
|
||||
return $post->isVisibleTo($user) && $user->id !== $post->user_id;
|
||||
})
|
||||
->all();
|
||||
|
||||
$this->notifications->sync(new GroupMentionedBlueprint($post), $users);
|
||||
}
|
||||
}
|
||||
|
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Mentions\Notification;
|
||||
|
||||
use Flarum\Notification\Blueprint\BlueprintInterface;
|
||||
use Flarum\Notification\MailableInterface;
|
||||
use Flarum\Post\Post;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
class GroupMentionedBlueprint implements BlueprintInterface, MailableInterface
|
||||
{
|
||||
/**
|
||||
* @var Post
|
||||
*/
|
||||
public $post;
|
||||
|
||||
/**
|
||||
* @param Post $post
|
||||
*/
|
||||
public function __construct(Post $post)
|
||||
{
|
||||
$this->post = $post;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getSubject()
|
||||
{
|
||||
return $this->post;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getFromUser()
|
||||
{
|
||||
return $this->post->user;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getData()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getEmailView()
|
||||
{
|
||||
return ['text' => 'flarum-mentions::emails.groupMentioned'];
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getEmailSubject(TranslatorInterface $translator)
|
||||
{
|
||||
return $translator->trans('flarum-mentions.email.group_mentioned.subject', [
|
||||
'{mentioner_display_name}' => $this->post->user->display_name,
|
||||
'{title}' => $this->post->discussion->title
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public static function getType()
|
||||
{
|
||||
return 'groupMentioned';
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public static function getSubjectModel()
|
||||
{
|
||||
return Post::class;
|
||||
}
|
||||
}
|
420
extensions/mentions/tests/integration/api/GroupMentionsTest.php
Normal file
420
extensions/mentions/tests/integration/api/GroupMentionsTest.php
Normal file
@@ -0,0 +1,420 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Mentions\Tests\integration\api;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Flarum\Group\Group;
|
||||
use Flarum\Post\CommentPost;
|
||||
use Flarum\Testing\integration\RetrievesAuthorizedUsers;
|
||||
use Flarum\Testing\integration\TestCase;
|
||||
use Flarum\User\User;
|
||||
|
||||
class GroupMentionsTest extends TestCase
|
||||
{
|
||||
use RetrievesAuthorizedUsers;
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->extension('flarum-mentions');
|
||||
|
||||
$this->prepareDatabase([
|
||||
'users' => [
|
||||
['id' => 3, 'username' => 'potato', 'email' => 'potato@machine.local', 'is_email_confirmed' => 1],
|
||||
['id' => 4, 'username' => 'toby', 'email' => 'toby@machine.local', 'is_email_confirmed' => 1],
|
||||
['id' => 5, 'username' => 'bad_user', 'email' => 'bad_user@machine.local', 'is_email_confirmed' => 1],
|
||||
],
|
||||
'discussions' => [
|
||||
['id' => 2, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 3, 'first_post_id' => 4, 'comment_count' => 2],
|
||||
],
|
||||
'posts' => [
|
||||
['id' => 4, 'number' => 2, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 3, 'type' => 'comment', 'content' => '<r><p>One of the <GROUPMENTION color="#80349E" groupname="Mods" icon="fas fa-bolt" id="4">@"Mods"#g4</GROUPMENTION> will look at this</p></r>'],
|
||||
['id' => 6, 'number' => 3, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 3, 'type' => 'comment', 'content' => '<r><p><GROUPMENTION color="#80349E" groupname="OldGroupName" icon="fas fa-circle" id="100">@"OldGroupName"#g100</GROUPMENTION></p></r>'],
|
||||
['id' => 7, 'number' => 4, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 3, 'type' => 'comment', 'content' => '<r><p><GROUPMENTION color="#000" groupname="OldGroupName" icon="fas fa-circle" id="11">@"OldGroupName"#g11</GROUPMENTION></p></r>'],
|
||||
],
|
||||
'post_mentions_group' => [
|
||||
['post_id' => 4, 'mentions_group_id' => 4],
|
||||
['post_id' => 7, 'mentions_group_id' => 11],
|
||||
],
|
||||
'group_permission' => [
|
||||
['group_id' => Group::MEMBER_ID, 'permission' => 'postWithoutThrottle'],
|
||||
],
|
||||
'groups' => [
|
||||
[
|
||||
'id' => 10,
|
||||
'name_singular' => 'Hidden',
|
||||
'name_plural' => 'Ninjas',
|
||||
'color' => null,
|
||||
'icon' => 'fas fa-wrench',
|
||||
'is_hidden' => 1
|
||||
],
|
||||
[
|
||||
'id' => 11,
|
||||
'name_singular' => 'Fresh Name',
|
||||
'name_plural' => 'Fresh Name',
|
||||
'color' => '#ccc',
|
||||
'icon' => 'fas fa-users',
|
||||
'is_hidden' => 0
|
||||
]
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function rendering_a_valid_group_mention_works()
|
||||
{
|
||||
$response = $this->send(
|
||||
$this->request('GET', '/api/posts/4')
|
||||
);
|
||||
|
||||
$this->assertEquals(200, $response->getStatusCode());
|
||||
|
||||
$response = json_decode($response->getBody(), true);
|
||||
|
||||
$this->assertStringContainsString('<p>One of the <span style="background:#80349E" class="GroupMention ">@Mods<i class="icon fas fa-bolt"></i></span> will look at this</p>', $response['data']['attributes']['contentHtml']);
|
||||
$this->assertNotNull(CommentPost::find($response['data']['id'])->mentionsGroups->find(4));
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function mentioning_an_invalid_group_doesnt_work()
|
||||
{
|
||||
$response = $this->send(
|
||||
$this->request('POST', '/api/posts', [
|
||||
'authenticatedAs' => 1,
|
||||
'json' => [
|
||||
'data' => [
|
||||
'attributes' => [
|
||||
'content' => '@"InvalidGroup"#g99',
|
||||
],
|
||||
'relationships' => [
|
||||
'discussion' => ['data' => ['id' => 2]],
|
||||
]
|
||||
],
|
||||
],
|
||||
])
|
||||
);
|
||||
|
||||
$this->assertEquals(201, $response->getStatusCode());
|
||||
|
||||
$response = json_decode($response->getBody(), true);
|
||||
|
||||
$this->assertStringContainsString('@"InvalidGroup"#g99', $response['data']['attributes']['content']);
|
||||
$this->assertStringNotContainsString('GroupMention', $response['data']['attributes']['contentHtml']);
|
||||
$this->assertCount(0, CommentPost::find($response['data']['id'])->mentionsGroups);
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function deleted_group_mentions_render_with_deleted_label()
|
||||
{
|
||||
$deleted_text = $this->app()->getContainer()->make('translator')->trans('flarum-mentions.forum.group_mention.deleted_text');
|
||||
|
||||
$response = $this->send(
|
||||
$this->request('GET', '/api/posts/6', [
|
||||
'authenticatedAs' => 1,
|
||||
])
|
||||
);
|
||||
|
||||
$this->assertEquals(200, $response->getStatusCode());
|
||||
|
||||
$response = json_decode($response->getBody(), true);
|
||||
|
||||
$this->assertStringContainsString("@$deleted_text", $response['data']['attributes']['contentHtml']);
|
||||
$this->assertStringContainsString('GroupMention', $response['data']['attributes']['contentHtml']);
|
||||
$this->assertStringContainsString('GroupMention--deleted', $response['data']['attributes']['contentHtml']);
|
||||
$this->assertStringNotContainsString('@OldGroupName', $response['data']['attributes']['contentHtml']);
|
||||
$this->assertCount(0, CommentPost::find($response['data']['id'])->mentionsGroups);
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function group_mentions_render_with_fresh_data()
|
||||
{
|
||||
$response = $this->send(
|
||||
$this->request('GET', '/api/posts/7', [
|
||||
'authenticatedAs' => 1,
|
||||
])
|
||||
);
|
||||
|
||||
$this->assertEquals(200, $response->getStatusCode());
|
||||
|
||||
$response = json_decode($response->getBody(), true);
|
||||
|
||||
$this->assertStringContainsString('@Fresh Name', $response['data']['attributes']['contentHtml']);
|
||||
$this->assertStringContainsString('GroupMention', $response['data']['attributes']['contentHtml']);
|
||||
$this->assertStringNotContainsString('@OldGroupName', $response['data']['attributes']['contentHtml']);
|
||||
$this->assertNotNull(CommentPost::find($response['data']['id'])->mentionsGroups->find(11));
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function mentioning_a_group_as_an_admin_user_works()
|
||||
{
|
||||
$response = $this->send(
|
||||
$this->request('POST', '/api/posts', [
|
||||
'authenticatedAs' => 1,
|
||||
'json' => [
|
||||
'data' => [
|
||||
'attributes' => [
|
||||
'content' => '@"Mods"#g4',
|
||||
],
|
||||
'relationships' => [
|
||||
'discussion' => ['data' => ['id' => 2]],
|
||||
]
|
||||
]
|
||||
]
|
||||
])
|
||||
);
|
||||
|
||||
$this->assertEquals(201, $response->getStatusCode());
|
||||
|
||||
$response = json_decode($response->getBody(), true);
|
||||
|
||||
$this->assertStringContainsString('@Mods', $response['data']['attributes']['contentHtml']);
|
||||
$this->assertStringContainsString('fas fa-bolt', $response['data']['attributes']['contentHtml']);
|
||||
$this->assertEquals('@"Mods"#g4', $response['data']['attributes']['content']);
|
||||
$this->assertStringContainsString('GroupMention', $response['data']['attributes']['contentHtml']);
|
||||
$this->assertCount(1, CommentPost::find($response['data']['id'])->mentionsGroups);
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function mentioning_multiple_groups_as_an_admin_user_works()
|
||||
{
|
||||
$response = $this->send(
|
||||
$this->request('POST', '/api/posts', [
|
||||
'authenticatedAs' => 1,
|
||||
'json' => [
|
||||
'data' => [
|
||||
'attributes' => [
|
||||
'content' => '@"Admins"#g1 @"Mods"#g4',
|
||||
],
|
||||
'relationships' => [
|
||||
'discussion' => ['data' => ['id' => 2]],
|
||||
]
|
||||
]
|
||||
]
|
||||
])
|
||||
);
|
||||
|
||||
$this->assertEquals(201, $response->getStatusCode());
|
||||
|
||||
$response = json_decode($response->getBody(), true);
|
||||
|
||||
$this->assertStringContainsString('@Admins', $response['data']['attributes']['contentHtml']);
|
||||
$this->assertStringContainsString('@Mods', $response['data']['attributes']['contentHtml']);
|
||||
$this->assertStringContainsString('fas fa-wrench', $response['data']['attributes']['contentHtml']);
|
||||
$this->assertStringContainsString('fas fa-bolt', $response['data']['attributes']['contentHtml']);
|
||||
$this->assertEquals('@"Admins"#g1 @"Mods"#g4', $response['data']['attributes']['content']);
|
||||
$this->assertStringContainsString('GroupMention', $response['data']['attributes']['contentHtml']);
|
||||
$this->assertCount(2, CommentPost::find($response['data']['id'])->mentionsGroups);
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function mentioning_a_virtual_group_as_an_admin_user_does_not_work()
|
||||
{
|
||||
$response = $this->send(
|
||||
$this->request('POST', '/api/posts', [
|
||||
'authenticatedAs' => 1,
|
||||
'json' => [
|
||||
'data' => [
|
||||
'attributes' => [
|
||||
'content' => '@"Members"#g3 @"Guests"#g2',
|
||||
],
|
||||
'relationships' => [
|
||||
'discussion' => ['data' => ['id' => 2]],
|
||||
]
|
||||
]
|
||||
]
|
||||
])
|
||||
);
|
||||
|
||||
$this->assertEquals(201, $response->getStatusCode());
|
||||
|
||||
$response = json_decode($response->getBody(), true);
|
||||
|
||||
$this->assertStringNotContainsString('@Members', $response['data']['attributes']['contentHtml']);
|
||||
$this->assertStringNotContainsString('@Guests', $response['data']['attributes']['contentHtml']);
|
||||
$this->assertEquals('@"Members"#g3 @"Guests"#g2', $response['data']['attributes']['content']);
|
||||
$this->assertStringNotContainsString('GroupMention', $response['data']['attributes']['contentHtml']);
|
||||
$this->assertCount(0, CommentPost::find($response['data']['id'])->mentionsGroups);
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function regular_user_does_not_have_group_mention_permission_by_default()
|
||||
{
|
||||
$this->database();
|
||||
$this->assertFalse(User::find(3)->can('mentionGroups'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function regular_user_does_have_group_mention_permission_when_added()
|
||||
{
|
||||
$this->prepareDatabase([
|
||||
'group_permission' => [
|
||||
['group_id' => Group::MEMBER_ID, 'permission' => 'mentionGroups'],
|
||||
]
|
||||
]);
|
||||
|
||||
$this->database();
|
||||
$this->assertTrue(User::find(3)->can('mentionGroups'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function user_without_permission_cannot_mention_groups()
|
||||
{
|
||||
$response = $this->send(
|
||||
$this->request('POST', '/api/posts', [
|
||||
'authenticatedAs' => 3,
|
||||
'json' => [
|
||||
'data' => [
|
||||
'attributes' => [
|
||||
'content' => '@"Mods"#g4',
|
||||
],
|
||||
'relationships' => [
|
||||
'discussion' => ['data' => ['id' => 2]],
|
||||
],
|
||||
],
|
||||
],
|
||||
])
|
||||
);
|
||||
|
||||
$this->assertEquals(201, $response->getStatusCode());
|
||||
|
||||
$response = json_decode($response->getBody(), true);
|
||||
|
||||
$this->assertStringNotContainsString('@Mods', $response['data']['attributes']['contentHtml']);
|
||||
$this->assertStringContainsString('@"Mods"#g4', $response['data']['attributes']['content']);
|
||||
$this->assertStringNotContainsString('GroupMention', $response['data']['attributes']['contentHtml']);
|
||||
$this->assertCount(0, CommentPost::find($response['data']['id'])->mentionsGroups);
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function user_with_permission_can_mention_groups()
|
||||
{
|
||||
$this->prepareDatabase([
|
||||
'group_permission' => [
|
||||
['group_id' => Group::MEMBER_ID, 'permission' => 'mentionGroups'],
|
||||
]
|
||||
]);
|
||||
|
||||
$response = $this->send(
|
||||
$this->request('POST', '/api/posts', [
|
||||
'authenticatedAs' => 3,
|
||||
'json' => [
|
||||
'data' => [
|
||||
'attributes' => [
|
||||
'content' => '@"Mods"#g4',
|
||||
],
|
||||
'relationships' => [
|
||||
'discussion' => ['data' => ['id' => 2]],
|
||||
],
|
||||
],
|
||||
],
|
||||
])
|
||||
);
|
||||
|
||||
$this->assertEquals(201, $response->getStatusCode());
|
||||
|
||||
$response = json_decode($response->getBody(), true);
|
||||
|
||||
$this->assertStringContainsString('@Mods', $response['data']['attributes']['contentHtml']);
|
||||
$this->assertStringContainsString('@"Mods"#g4', $response['data']['attributes']['content']);
|
||||
$this->assertStringContainsString('GroupMention', $response['data']['attributes']['contentHtml']);
|
||||
$this->assertCount(1, CommentPost::find($response['data']['id'])->mentionsGroups);
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function user_with_permission_cannot_mention_hidden_groups()
|
||||
{
|
||||
$this->prepareDatabase([
|
||||
'group_permission' => [
|
||||
['group_id' => Group::MEMBER_ID, 'permission' => 'mentionGroups'],
|
||||
]
|
||||
]);
|
||||
|
||||
$response = $this->send(
|
||||
$this->request('POST', '/api/posts', [
|
||||
'authenticatedAs' => 3,
|
||||
'json' => [
|
||||
'data' => [
|
||||
'attributes' => [
|
||||
'content' => '@"Ninjas"#g10',
|
||||
],
|
||||
'relationships' => [
|
||||
'discussion' => ['data' => ['id' => 2]],
|
||||
],
|
||||
],
|
||||
],
|
||||
])
|
||||
);
|
||||
|
||||
$this->assertEquals(201, $response->getStatusCode());
|
||||
|
||||
$response = json_decode($response->getBody(), true);
|
||||
|
||||
$this->assertStringNotContainsString('@Ninjas', $response['data']['attributes']['contentHtml']);
|
||||
$this->assertStringContainsString('@"Ninjas"#g10', $response['data']['attributes']['content']);
|
||||
$this->assertStringNotContainsString('GroupMention', $response['data']['attributes']['contentHtml']);
|
||||
$this->assertCount(0, CommentPost::find($response['data']['id'])->mentionsGroups);
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function editing_a_post_that_has_a_mention_works()
|
||||
{
|
||||
$response = $this->send(
|
||||
$this->request('PATCH', '/api/posts/4', [
|
||||
'authenticatedAs' => 1,
|
||||
'json' => [
|
||||
'data' => [
|
||||
'attributes' => [
|
||||
'content' => 'New content with @"Mods"#g4 mention',
|
||||
],
|
||||
],
|
||||
],
|
||||
])
|
||||
);
|
||||
|
||||
$this->assertEquals(200, $response->getStatusCode());
|
||||
|
||||
$response = json_decode($response->getBody(), true);
|
||||
|
||||
$this->assertStringContainsString('@Mods', $response['data']['attributes']['contentHtml']);
|
||||
$this->assertEquals('New content with @"Mods"#g4 mention', $response['data']['attributes']['content']);
|
||||
$this->assertStringContainsString('GroupMention', $response['data']['attributes']['contentHtml']);
|
||||
$this->assertNotNull(CommentPost::find($response['data']['id'])->mentionsGroups->find(4));
|
||||
}
|
||||
}
|
@@ -38,6 +38,7 @@ class PostMentionsTest extends TestCase
|
||||
],
|
||||
'discussions' => [
|
||||
['id' => 2, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 3, 'first_post_id' => 4, 'comment_count' => 2],
|
||||
['id' => 50, 'title' => __CLASS__, 'is_private' => true, 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 3, 'first_post_id' => 4, 'comment_count' => 1],
|
||||
],
|
||||
'posts' => [
|
||||
['id' => 4, 'number' => 2, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 3, 'type' => 'comment', 'content' => '<r><POSTMENTION displayname="TobyFlarum___" id="5" number="2" discussionid="2" username="toby">@tobyuuu#5</POSTMENTION></r>'],
|
||||
@@ -47,12 +48,17 @@ class PostMentionsTest extends TestCase
|
||||
['id' => 8, 'number' => 6, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 4, 'type' => 'comment', 'content' => '<r><POSTMENTION displayname="i_am_a_deleted_user" id="2020" number="8" discussionid="2" username="i_am_a_deleted_user">@"i_am_a_deleted_user"#p2020</POSTMENTION></r>'],
|
||||
['id' => 9, 'number' => 10, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 5, 'type' => 'comment', 'content' => '<r><p>I am bad</p></r>'],
|
||||
['id' => 10, 'number' => 11, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 4, 'type' => 'comment', 'content' => '<r><POSTMENTION displayname="Bad "#p6 User" id="9" number="10" discussionid="2">@"Bad "#p6 User"#p9</POSTMENTION></r>'],
|
||||
['id' => 11, 'number' => 12, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 40, 'type' => 'comment', 'content' => '<r><POSTMENTION displayname="Bad "#p6 User" id="9" number="10" discussionid="2">@"Bad "#p6 User"#p9</POSTMENTION></r>'],
|
||||
['id' => 12, 'number' => 13, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 4, 'type' => 'comment', 'content' => '<r><POSTMENTION displayname="deleted_user" id="11" number="12" discussionid="2">@"acme"#p11</POSTMENTION></r>'],
|
||||
|
||||
// Restricted access
|
||||
['id' => 50, 'number' => 1, 'discussion_id' => 50, 'created_at' => Carbon::now(), 'user_id' => 3, 'type' => 'comment', 'content' => '<r>no</r>'],
|
||||
],
|
||||
'post_mentions_post' => [
|
||||
['post_id' => 4, 'mentions_post_id' => 5],
|
||||
['post_id' => 5, 'mentions_post_id' => 4],
|
||||
['post_id' => 6, 'mentions_post_id' => 7],
|
||||
['post_id' => 10, 'mentions_post_id' => 9]
|
||||
['post_id' => 10, 'mentions_post_id' => 9],
|
||||
],
|
||||
]);
|
||||
|
||||
@@ -126,6 +132,37 @@ class PostMentionsTest extends TestCase
|
||||
$this->assertNotNull(CommentPost::find($response['data']['id'])->mentionsPosts->find(4));
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function cannot_mention_a_post_without_access()
|
||||
{
|
||||
$response = $this->send(
|
||||
$this->request('POST', '/api/posts', [
|
||||
'authenticatedAs' => 1,
|
||||
'json' => [
|
||||
'data' => [
|
||||
'attributes' => [
|
||||
'content' => '@"potato"#p50',
|
||||
],
|
||||
'relationships' => [
|
||||
'discussion' => ['data' => ['id' => 2]],
|
||||
],
|
||||
],
|
||||
],
|
||||
])
|
||||
);
|
||||
|
||||
$this->assertEquals(201, $response->getStatusCode());
|
||||
|
||||
$response = json_decode($response->getBody(), true);
|
||||
|
||||
$this->assertStringContainsString('potato', $response['data']['attributes']['contentHtml']);
|
||||
$this->assertEquals('@"potato"#p50', $response['data']['attributes']['content']);
|
||||
$this->assertStringNotContainsString('PostMention', $response['data']['attributes']['contentHtml']);
|
||||
$this->assertNull(CommentPost::find($response['data']['id'])->mentionsPosts->find(50));
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
@@ -417,6 +454,90 @@ class PostMentionsTest extends TestCase
|
||||
$this->assertStringContainsString('PostMention', $response['data']['attributes']['contentHtml']);
|
||||
$this->assertNotNull(CommentPost::find($response['data']['id'])->mentionsPosts->find(9));
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function editing_a_post_that_has_a_mention_works()
|
||||
{
|
||||
$response = $this->send(
|
||||
$this->request('PATCH', '/api/posts/10', [
|
||||
'authenticatedAs' => 1,
|
||||
'json' => [
|
||||
'data' => [
|
||||
'attributes' => [
|
||||
'content' => '@"Bad _ User"#p9',
|
||||
],
|
||||
],
|
||||
],
|
||||
])
|
||||
);
|
||||
|
||||
$this->assertEquals(200, $response->getStatusCode());
|
||||
|
||||
$response = json_decode($response->getBody(), true);
|
||||
|
||||
$this->assertStringContainsString('Bad "#p6 User', $response['data']['attributes']['contentHtml']);
|
||||
$this->assertEquals('@"Bad _ User"#p9', $response['data']['attributes']['content']);
|
||||
$this->assertStringContainsString('PostMention', $response['data']['attributes']['contentHtml']);
|
||||
$this->assertNotNull(CommentPost::find($response['data']['id'])->mentionsPosts->find(9));
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function editing_a_post_with_deleted_author_that_has_a_mention_works()
|
||||
{
|
||||
$response = $this->send(
|
||||
$this->request('PATCH', '/api/posts/11', [
|
||||
'authenticatedAs' => 1,
|
||||
'json' => [
|
||||
'data' => [
|
||||
'attributes' => [
|
||||
'content' => '@"Bad _ User"#p9',
|
||||
],
|
||||
],
|
||||
],
|
||||
])
|
||||
);
|
||||
|
||||
$this->assertEquals(200, $response->getStatusCode());
|
||||
|
||||
$response = json_decode($response->getBody(), true);
|
||||
|
||||
$this->assertStringContainsString('Bad "#p6 User', $response['data']['attributes']['contentHtml']);
|
||||
$this->assertEquals('@"Bad _ User"#p9', $response['data']['attributes']['content']);
|
||||
$this->assertStringContainsString('PostMention', $response['data']['attributes']['contentHtml']);
|
||||
$this->assertNotNull(CommentPost::find($response['data']['id'])->mentionsPosts->find(9));
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function editing_a_post_with_a_mention_of_a_post_with_deleted_author_works()
|
||||
{
|
||||
$response = $this->send(
|
||||
$this->request('PATCH', '/api/posts/12', [
|
||||
'authenticatedAs' => 1,
|
||||
'json' => [
|
||||
'data' => [
|
||||
'attributes' => [
|
||||
'content' => '@"acme"#p11',
|
||||
],
|
||||
],
|
||||
],
|
||||
])
|
||||
);
|
||||
|
||||
$this->assertEquals(200, $response->getStatusCode());
|
||||
|
||||
$response = json_decode($response->getBody(), true);
|
||||
|
||||
$this->assertStringContainsString('[deleted]', $response['data']['attributes']['contentHtml']);
|
||||
$this->assertEquals('@"[deleted]"#p11', $response['data']['attributes']['content']);
|
||||
$this->assertStringContainsString('PostMention', $response['data']['attributes']['contentHtml']);
|
||||
$this->assertNotNull(CommentPost::find($response['data']['id'])->mentionsPosts->find(11));
|
||||
}
|
||||
}
|
||||
|
||||
class CustomOtherDisplayNameDriver implements DriverInterface
|
||||
|
@@ -44,10 +44,11 @@ class UserMentionsTest extends TestCase
|
||||
['id' => 4, 'number' => 2, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 3, 'type' => 'comment', 'content' => '<r><USERMENTION displayname="TobyFlarum___" id="4" username="toby">@tobyuuu</USERMENTION></r>'],
|
||||
['id' => 6, 'number' => 3, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 4, 'type' => 'comment', 'content' => '<r><USERMENTION displayname="i_am_a_deleted_user" id="2021" username="i_am_a_deleted_user">@"i_am_a_deleted_user"#2021</USERMENTION></r>'],
|
||||
['id' => 10, 'number' => 11, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 5, 'type' => 'comment', 'content' => '<r><USERMENTION displayname="Bad "#p6 User" id="5">@"Bad "#p6 User"#5</USERMENTION></r>'],
|
||||
['id' => 11, 'number' => 12, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 50, 'type' => 'comment', 'content' => '<r><USERMENTION displayname="Bad "#p6 User" id="5">@"Bad "#p6 User"#5</USERMENTION></r>'],
|
||||
],
|
||||
'post_mentions_user' => [
|
||||
['post_id' => 4, 'mentions_user_id' => 4],
|
||||
['post_id' => 10, 'mentions_user_id' => 5]
|
||||
['post_id' => 10, 'mentions_user_id' => 5],
|
||||
],
|
||||
]);
|
||||
|
||||
@@ -438,6 +439,62 @@ class UserMentionsTest extends TestCase
|
||||
$this->assertStringContainsString('UserMention', $response['data']['attributes']['contentHtml']);
|
||||
$this->assertNotNull(CommentPost::find($response['data']['id'])->mentionsUsers->find(5));
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function editing_a_post_that_has_a_mention_works()
|
||||
{
|
||||
$response = $this->send(
|
||||
$this->request('PATCH', '/api/posts/10', [
|
||||
'authenticatedAs' => 1,
|
||||
'json' => [
|
||||
'data' => [
|
||||
'attributes' => [
|
||||
'content' => '@"Bad _ User"#5',
|
||||
],
|
||||
],
|
||||
],
|
||||
])
|
||||
);
|
||||
|
||||
$this->assertEquals(200, $response->getStatusCode());
|
||||
|
||||
$response = json_decode($response->getBody(), true);
|
||||
|
||||
$this->assertStringContainsString('Bad "#p6 User', $response['data']['attributes']['contentHtml']);
|
||||
$this->assertEquals('@"Bad _ User"#5', $response['data']['attributes']['content']);
|
||||
$this->assertStringContainsString('UserMention', $response['data']['attributes']['contentHtml']);
|
||||
$this->assertNotNull(CommentPost::find($response['data']['id'])->mentionsUsers->find(5));
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function editing_a_post_with_deleted_author_that_has_a_mention_works()
|
||||
{
|
||||
$response = $this->send(
|
||||
$this->request('PATCH', '/api/posts/11', [
|
||||
'authenticatedAs' => 1,
|
||||
'json' => [
|
||||
'data' => [
|
||||
'attributes' => [
|
||||
'content' => '@"Bad _ User"#5',
|
||||
],
|
||||
],
|
||||
],
|
||||
])
|
||||
);
|
||||
|
||||
$this->assertEquals(200, $response->getStatusCode());
|
||||
|
||||
$response = json_decode($response->getBody(), true);
|
||||
|
||||
$this->assertStringContainsString('Bad "#p6 User', $response['data']['attributes']['contentHtml']);
|
||||
$this->assertEquals('@"Bad _ User"#5', $response['data']['attributes']['content']);
|
||||
$this->assertStringContainsString('UserMention', $response['data']['attributes']['contentHtml']);
|
||||
$this->assertNotNull(CommentPost::find($response['data']['id'])->mentionsUsers->find(5));
|
||||
}
|
||||
}
|
||||
|
||||
class CustomDisplayNameDriver implements DriverInterface
|
||||
|
@@ -0,0 +1,7 @@
|
||||
{!! $translator->trans('flarum-mentions.email.group_mentioned.body', [
|
||||
'{recipient_display_name}' => $user->display_name,
|
||||
'{mentioner_display_name}' => $blueprint->post->user->display_name,
|
||||
'{title}' => $blueprint->post->discussion->title,
|
||||
'{url}' => $url->to('forum')->route('discussion', ['id' => $blueprint->post->discussion_id, 'near' => $blueprint->post->number]),
|
||||
'{content}' => $blueprint->post->content
|
||||
]) !!}
|
@@ -19,7 +19,7 @@
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"flarum/core": "^1.4"
|
||||
"flarum/core": "^1.6"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
@@ -22,7 +22,7 @@
|
||||
"source": "https://github.com/flarum/package-manager"
|
||||
},
|
||||
"require": {
|
||||
"flarum/core": "^1.0.0",
|
||||
"flarum/core": "^1.5.0",
|
||||
"composer/composer": "^2.3"
|
||||
},
|
||||
"require-dev": {
|
||||
|
@@ -41,7 +41,8 @@ return [
|
||||
$paths = resolve(Paths::class);
|
||||
|
||||
$document->payload['flarum-package-manager.writable_dirs'] = is_writable($paths->vendor)
|
||||
&& is_writable($paths->storage.'/.composer')
|
||||
&& is_writable($paths->storage)
|
||||
&& (! file_exists($paths->storage.'/.composer') || is_writable($paths->storage.'/.composer'))
|
||||
&& is_writable($paths->base.'/composer.json')
|
||||
&& is_writable($paths->base.'/composer.lock');
|
||||
|
||||
|
6
extensions/package-manager/js/dist-typings/components/ControlSection.d.ts
generated
vendored
6
extensions/package-manager/js/dist-typings/components/ControlSection.d.ts
generated
vendored
@@ -1,5 +1,7 @@
|
||||
/// <reference types="mithril" />
|
||||
import Component from 'flarum/common/Component';
|
||||
export default class ControlSection extends Component {
|
||||
import { ComponentAttrs } from 'flarum/common/Component';
|
||||
import Mithril from 'mithril';
|
||||
export default class ControlSection extends Component<ComponentAttrs> {
|
||||
oninit(vnode: Mithril.Vnode<ComponentAttrs, this>): void;
|
||||
view(): JSX.Element;
|
||||
}
|
||||
|
2
extensions/package-manager/js/dist-typings/components/ExtensionItem.d.ts
generated
vendored
2
extensions/package-manager/js/dist-typings/components/ExtensionItem.d.ts
generated
vendored
@@ -1,7 +1,7 @@
|
||||
import type Mithril from 'mithril';
|
||||
import Component, { ComponentAttrs } from 'flarum/common/Component';
|
||||
import { Extension } from 'flarum/admin/AdminApplication';
|
||||
import { UpdatedPackage } from './Updater';
|
||||
import { UpdatedPackage } from '../states/ControlSectionState';
|
||||
export interface ExtensionItemAttrs extends ComponentAttrs {
|
||||
extension: Extension;
|
||||
updates: UpdatedPackage;
|
||||
|
5
extensions/package-manager/js/dist-typings/components/Installer.d.ts
generated
vendored
5
extensions/package-manager/js/dist-typings/components/Installer.d.ts
generated
vendored
@@ -1,14 +1,13 @@
|
||||
import type Mithril from 'mithril';
|
||||
import Component, { ComponentAttrs } from 'flarum/common/Component';
|
||||
import Stream from 'flarum/common/utils/Stream';
|
||||
interface InstallerAttrs extends ComponentAttrs {
|
||||
export interface InstallerAttrs extends ComponentAttrs {
|
||||
}
|
||||
export declare type InstallerLoadingTypes = 'extension-install' | null;
|
||||
export default class Installer extends Component<InstallerAttrs> {
|
||||
packageName: Stream<string>;
|
||||
isLoading: boolean;
|
||||
oninit(vnode: Mithril.Vnode<InstallerAttrs, this>): void;
|
||||
view(): Mithril.Children;
|
||||
data(): any;
|
||||
onsubmit(): void;
|
||||
}
|
||||
export {};
|
||||
|
9
extensions/package-manager/js/dist-typings/components/MajorUpdater.d.ts
generated
vendored
9
extensions/package-manager/js/dist-typings/components/MajorUpdater.d.ts
generated
vendored
@@ -1,15 +1,14 @@
|
||||
import type Mithril from 'mithril';
|
||||
import Component, { ComponentAttrs } from 'flarum/common/Component';
|
||||
import { UpdatedPackage, UpdateState } from './Updater';
|
||||
interface MajorUpdaterAttrs extends ComponentAttrs {
|
||||
import { UpdatedPackage, UpdateState } from '../states/ControlSectionState';
|
||||
export interface MajorUpdaterAttrs extends ComponentAttrs {
|
||||
coreUpdate: UpdatedPackage;
|
||||
updateState: UpdateState;
|
||||
}
|
||||
export declare type MajorUpdaterLoadingTypes = 'major-update' | 'major-update-dry-run';
|
||||
export default class MajorUpdater<T extends MajorUpdaterAttrs = MajorUpdaterAttrs> extends Component<T> {
|
||||
isLoading: string | null;
|
||||
updateState: UpdateState;
|
||||
oninit(vnode: Mithril.Vnode<T, this>): void;
|
||||
view(vnode: Mithril.Vnode<T, this>): Mithril.Children;
|
||||
view(): Mithril.Children;
|
||||
update(dryRun: boolean): void;
|
||||
}
|
||||
export {};
|
||||
|
2
extensions/package-manager/js/dist-typings/components/TaskOutputModal.d.ts
generated
vendored
2
extensions/package-manager/js/dist-typings/components/TaskOutputModal.d.ts
generated
vendored
@@ -1,5 +1,5 @@
|
||||
/// <reference types="mithril" />
|
||||
/// <reference types="flarum/@types/translator-icu-rich" />
|
||||
/// <reference types="@flarum/core/dist-typings/@types/translator-icu-rich" />
|
||||
import Modal, { IInternalModalAttrs } from 'flarum/common/components/Modal';
|
||||
import Task from '../models/Task';
|
||||
interface TaskOutputModalAttrs extends IInternalModalAttrs {
|
||||
|
53
extensions/package-manager/js/dist-typings/components/Updater.d.ts
generated
vendored
53
extensions/package-manager/js/dist-typings/components/Updater.d.ts
generated
vendored
@@ -1,49 +1,12 @@
|
||||
import Mithril from 'mithril';
|
||||
/// <reference types="mithril" />
|
||||
import Component, { ComponentAttrs } from 'flarum/common/Component';
|
||||
import { Extension } from 'flarum/admin/AdminApplication';
|
||||
export declare type UpdatedPackage = {
|
||||
name: string;
|
||||
version: string;
|
||||
latest: string;
|
||||
'latest-minor': string | null;
|
||||
'latest-major': string | null;
|
||||
'latest-status': string;
|
||||
description: string;
|
||||
};
|
||||
export declare type ComposerUpdates = {
|
||||
installed: UpdatedPackage[];
|
||||
};
|
||||
export declare type LastUpdateCheck = {
|
||||
checkedAt: Date | null;
|
||||
updates: ComposerUpdates;
|
||||
};
|
||||
declare type UpdateType = 'major' | 'minor' | 'global';
|
||||
declare type UpdateStatus = 'success' | 'failure' | null;
|
||||
export declare type UpdateState = {
|
||||
ranAt: Date | null;
|
||||
status: UpdateStatus;
|
||||
limitedPackages: string[];
|
||||
incompatibleExtensions: string[];
|
||||
};
|
||||
export declare type LastUpdateRun = {
|
||||
[key in UpdateType]: UpdateState;
|
||||
} & {
|
||||
limitedPackages: () => string[];
|
||||
};
|
||||
interface UpdaterAttrs extends ComponentAttrs {
|
||||
import ItemList from '@flarum/core/src/common/utils/ItemList';
|
||||
export interface IUpdaterAttrs extends ComponentAttrs {
|
||||
}
|
||||
export default class Updater extends Component<UpdaterAttrs> {
|
||||
isLoading: string | null;
|
||||
packageUpdates: Record<string, UpdatedPackage>;
|
||||
lastUpdateCheck: LastUpdateCheck;
|
||||
get lastUpdateRun(): LastUpdateRun;
|
||||
oninit(vnode: Mithril.Vnode<UpdaterAttrs, this>): void;
|
||||
export declare type UpdaterLoadingTypes = 'check' | 'minor-update' | 'global-update' | 'extension-update' | null;
|
||||
export default class Updater extends Component<IUpdaterAttrs> {
|
||||
view(): (JSX.Element | null)[];
|
||||
getExtensionUpdates(): Extension[];
|
||||
getCoreUpdate(): UpdatedPackage | undefined;
|
||||
checkForUpdates(): void;
|
||||
updateCoreMinor(): void;
|
||||
updateExtension(extension: any): void;
|
||||
updateGlobally(): void;
|
||||
lastUpdateCheckView(): JSX.Element | null;
|
||||
availableUpdatesView(): JSX.Element;
|
||||
controlItems(): ItemList<unknown>;
|
||||
}
|
||||
export {};
|
||||
|
2
extensions/package-manager/js/dist-typings/components/WhyNotModal.d.ts
generated
vendored
2
extensions/package-manager/js/dist-typings/components/WhyNotModal.d.ts
generated
vendored
@@ -1,4 +1,4 @@
|
||||
/// <reference types="flarum/@types/translator-icu-rich" />
|
||||
/// <reference types="@flarum/core/dist-typings/@types/translator-icu-rich" />
|
||||
import type Mithril from 'mithril';
|
||||
import Modal, { IInternalModalAttrs } from 'flarum/common/components/Modal';
|
||||
export interface WhyNotModalAttrs extends IInternalModalAttrs {
|
||||
|
57
extensions/package-manager/js/dist-typings/states/ControlSectionState.d.ts
generated
vendored
Normal file
57
extensions/package-manager/js/dist-typings/states/ControlSectionState.d.ts
generated
vendored
Normal file
@@ -0,0 +1,57 @@
|
||||
import { UpdaterLoadingTypes } from '../components/Updater';
|
||||
import { InstallerLoadingTypes } from '../components/Installer';
|
||||
import { MajorUpdaterLoadingTypes } from '../components/MajorUpdater';
|
||||
import { Extension } from 'flarum/admin/AdminApplication';
|
||||
export declare type UpdatedPackage = {
|
||||
name: string;
|
||||
version: string;
|
||||
latest: string;
|
||||
'latest-minor': string | null;
|
||||
'latest-major': string | null;
|
||||
'latest-status': string;
|
||||
description: string;
|
||||
};
|
||||
export declare type ComposerUpdates = {
|
||||
installed: UpdatedPackage[];
|
||||
};
|
||||
export declare type LastUpdateCheck = {
|
||||
checkedAt: Date | null;
|
||||
updates: ComposerUpdates;
|
||||
};
|
||||
declare type UpdateType = 'major' | 'minor' | 'global';
|
||||
declare type UpdateStatus = 'success' | 'failure' | null;
|
||||
export declare type UpdateState = {
|
||||
ranAt: Date | null;
|
||||
status: UpdateStatus;
|
||||
limitedPackages: string[];
|
||||
incompatibleExtensions: string[];
|
||||
};
|
||||
export declare type LastUpdateRun = {
|
||||
[key in UpdateType]: UpdateState;
|
||||
} & {
|
||||
limitedPackages: () => string[];
|
||||
};
|
||||
export declare type LoadingTypes = UpdaterLoadingTypes | InstallerLoadingTypes | MajorUpdaterLoadingTypes;
|
||||
export declare type CoreUpdate = {
|
||||
package: UpdatedPackage;
|
||||
extension: Extension;
|
||||
};
|
||||
export default class ControlSectionState {
|
||||
loading: LoadingTypes;
|
||||
packageUpdates: Record<string, UpdatedPackage>;
|
||||
lastUpdateCheck: LastUpdateCheck;
|
||||
extensionUpdates: Extension[];
|
||||
coreUpdate: CoreUpdate | null;
|
||||
get lastUpdateRun(): LastUpdateRun;
|
||||
constructor();
|
||||
isLoading(name?: LoadingTypes): boolean;
|
||||
isLoadingOtherThan(name: LoadingTypes): boolean;
|
||||
setLoading(name: LoadingTypes): void;
|
||||
checkForUpdates(): void;
|
||||
updateCoreMinor(): void;
|
||||
updateExtension(extension: Extension): void;
|
||||
updateGlobally(): void;
|
||||
formatExtensionUpdates(lastUpdateCheck: LastUpdateCheck): Extension[];
|
||||
formatCoreUpdate(lastUpdateCheck: LastUpdateCheck): CoreUpdate | null;
|
||||
}
|
||||
export {};
|
6
extensions/package-manager/js/dist-typings/states/PackageManagerState.d.ts
generated
vendored
Normal file
6
extensions/package-manager/js/dist-typings/states/PackageManagerState.d.ts
generated
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
import QueueState from './QueueState';
|
||||
import ControlSectionState from './ControlSectionState';
|
||||
export default class PackageManagerState {
|
||||
queue: QueueState;
|
||||
control: ControlSectionState;
|
||||
}
|
2
extensions/package-manager/js/dist/admin.js
generated
vendored
2
extensions/package-manager/js/dist/admin.js
generated
vendored
File diff suppressed because one or more lines are too long
2
extensions/package-manager/js/dist/admin.js.map
generated
vendored
2
extensions/package-manager/js/dist/admin.js.map
generated
vendored
File diff suppressed because one or more lines are too long
@@ -1,11 +1,17 @@
|
||||
import app from 'flarum/admin/app';
|
||||
import Component from 'flarum/common/Component';
|
||||
import Alert from 'flarum/common/components/Alert';
|
||||
import { ComponentAttrs } from 'flarum/common/Component';
|
||||
|
||||
import Installer from './Installer';
|
||||
import Updater from './Updater';
|
||||
import Mithril from 'mithril';
|
||||
|
||||
export default class ControlSection extends Component<ComponentAttrs> {
|
||||
oninit(vnode: Mithril.Vnode<ComponentAttrs, this>) {
|
||||
super.oninit(vnode);
|
||||
}
|
||||
|
||||
export default class ControlSection extends Component {
|
||||
view() {
|
||||
return (
|
||||
<div className="ExtensionPage-permissions PackageManager-controlSection">
|
||||
|
@@ -7,7 +7,7 @@ import Tooltip from 'flarum/common/components/Tooltip';
|
||||
import Button from 'flarum/common/components/Button';
|
||||
import { Extension } from 'flarum/admin/AdminApplication';
|
||||
|
||||
import { UpdatedPackage } from './Updater';
|
||||
import { UpdatedPackage } from '../states/ControlSectionState';
|
||||
import WhyNotModal from './WhyNotModal';
|
||||
import Label from './Label';
|
||||
|
||||
@@ -40,7 +40,7 @@ export default class ExtensionItem<Attrs extends ExtensionItemAttrs = ExtensionI
|
||||
<div className="PackageManager-extension-info">
|
||||
<div className="PackageManager-extension-name">{extension.extra['flarum-extension'].title}</div>
|
||||
<div className="PackageManager-extension-version">
|
||||
<span className="PackageManager-extension-version-current">{this.version(extension.version)}</span>
|
||||
<span className="PackageManager-extension-version-current">{this.version(updates['version'])}</span>
|
||||
{latestVersion ? (
|
||||
<Label className="PackageManager-extension-version-latest" type={updates['latest-minor'] ? 'success' : 'warning'}>
|
||||
{this.version(latestVersion)}
|
||||
|
@@ -9,11 +9,12 @@ import errorHandler from '../utils/errorHandler';
|
||||
import jumpToQueue from '../utils/jumpToQueue';
|
||||
import { AsyncBackendResponse } from '../shims';
|
||||
|
||||
interface InstallerAttrs extends ComponentAttrs {}
|
||||
export interface InstallerAttrs extends ComponentAttrs {}
|
||||
|
||||
export type InstallerLoadingTypes = 'extension-install' | null;
|
||||
|
||||
export default class Installer extends Component<InstallerAttrs> {
|
||||
packageName!: Stream<string>;
|
||||
isLoading: boolean = false;
|
||||
|
||||
oninit(vnode: Mithril.Vnode<InstallerAttrs, this>): void {
|
||||
super.oninit(vnode);
|
||||
@@ -32,7 +33,13 @@ export default class Installer extends Component<InstallerAttrs> {
|
||||
</p>
|
||||
<div className="FormControl-container">
|
||||
<input className="FormControl" id="install-extension" placeholder="vendor/package-name" bidi={this.packageName} />
|
||||
<Button className="Button" icon="fas fa-download" onclick={this.onsubmit.bind(this)} loading={this.isLoading}>
|
||||
<Button
|
||||
className="Button"
|
||||
icon="fas fa-download"
|
||||
onclick={this.onsubmit.bind(this)}
|
||||
loading={app.packageManager.control.isLoading('extension-install')}
|
||||
disabled={app.packageManager.control.isLoadingOtherThan('extension-install')}
|
||||
>
|
||||
{app.translator.trans('flarum-package-manager.admin.extensions.proceed')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -47,7 +54,7 @@ export default class Installer extends Component<InstallerAttrs> {
|
||||
}
|
||||
|
||||
onsubmit(): void {
|
||||
this.isLoading = true;
|
||||
app.packageManager.control.setLoading('extension-install');
|
||||
app.modal.show(LoadingModal);
|
||||
|
||||
app
|
||||
@@ -57,7 +64,6 @@ export default class Installer extends Component<InstallerAttrs> {
|
||||
body: {
|
||||
data: this.data(),
|
||||
},
|
||||
errorHandler,
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.processing) {
|
||||
@@ -72,8 +78,10 @@ export default class Installer extends Component<InstallerAttrs> {
|
||||
window.location.reload();
|
||||
}
|
||||
})
|
||||
.catch(errorHandler)
|
||||
.finally(() => {
|
||||
this.isLoading = false;
|
||||
app.packageManager.control.setLoading(null);
|
||||
app.modal.close();
|
||||
m.redraw();
|
||||
});
|
||||
}
|
||||
|
@@ -7,20 +7,21 @@ import LoadingModal from 'flarum/admin/components/LoadingModal';
|
||||
import Alert from 'flarum/common/components/Alert';
|
||||
import RequestError from 'flarum/common/utils/RequestError';
|
||||
|
||||
import { UpdatedPackage, UpdateState } from './Updater';
|
||||
import { UpdatedPackage, UpdateState } from '../states/ControlSectionState';
|
||||
import errorHandler from '../utils/errorHandler';
|
||||
import WhyNotModal from './WhyNotModal';
|
||||
import ExtensionItem from './ExtensionItem';
|
||||
import { AsyncBackendResponse } from '../shims';
|
||||
import jumpToQueue from '../utils/jumpToQueue';
|
||||
|
||||
interface MajorUpdaterAttrs extends ComponentAttrs {
|
||||
export interface MajorUpdaterAttrs extends ComponentAttrs {
|
||||
coreUpdate: UpdatedPackage;
|
||||
updateState: UpdateState;
|
||||
}
|
||||
|
||||
export type MajorUpdaterLoadingTypes = 'major-update' | 'major-update-dry-run';
|
||||
|
||||
export default class MajorUpdater<T extends MajorUpdaterAttrs = MajorUpdaterAttrs> extends Component<T> {
|
||||
isLoading: string | null = null;
|
||||
updateState!: UpdateState;
|
||||
|
||||
oninit(vnode: Mithril.Vnode<T, this>) {
|
||||
@@ -29,7 +30,7 @@ export default class MajorUpdater<T extends MajorUpdaterAttrs = MajorUpdaterAttr
|
||||
this.updateState = this.attrs.updateState;
|
||||
}
|
||||
|
||||
view(vnode: Mithril.Vnode<T, this>): Mithril.Children {
|
||||
view(): Mithril.Children {
|
||||
// @todo move Form-group--danger class to core for reuse
|
||||
return (
|
||||
<div className="Form-group Form-group--danger PackageManager-majorUpdate">
|
||||
@@ -38,11 +39,21 @@ export default class MajorUpdater<T extends MajorUpdaterAttrs = MajorUpdaterAttr
|
||||
<p className="helpText">{app.translator.trans('flarum-package-manager.admin.major_updater.description')}</p>
|
||||
<div className="PackageManager-updaterControls">
|
||||
<Tooltip text={app.translator.trans('flarum-package-manager.admin.major_updater.dry_run_help')}>
|
||||
<Button className="Button" icon="fas fa-vial" onclick={this.update.bind(this, true)}>
|
||||
<Button
|
||||
className="Button"
|
||||
icon="fas fa-vial"
|
||||
onclick={this.update.bind(this, true)}
|
||||
disabled={app.packageManager.control.isLoadingOtherThan('major-update-dry-run')}
|
||||
>
|
||||
{app.translator.trans('flarum-package-manager.admin.major_updater.dry_run')}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Button className="Button Button--danger" icon="fas fa-play" onclick={this.update.bind(this, false)}>
|
||||
<Button
|
||||
className="Button Button--danger"
|
||||
icon="fas fa-play"
|
||||
onclick={this.update.bind(this, false)}
|
||||
disabled={app.packageManager.control.isLoadingOtherThan('major-update')}
|
||||
>
|
||||
{app.translator.trans('flarum-package-manager.admin.major_updater.update')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -83,7 +94,7 @@ export default class MajorUpdater<T extends MajorUpdaterAttrs = MajorUpdaterAttr
|
||||
}
|
||||
|
||||
update(dryRun: boolean) {
|
||||
this.isLoading = `update-${dryRun ? 'dry-run' : 'run'}`;
|
||||
app.packageManager.control.setLoading(dryRun ? 'major-update-dry-run' : 'major-update');
|
||||
app.modal.show(LoadingModal);
|
||||
|
||||
app
|
||||
@@ -93,7 +104,6 @@ export default class MajorUpdater<T extends MajorUpdaterAttrs = MajorUpdaterAttr
|
||||
body: {
|
||||
data: { dryRun },
|
||||
},
|
||||
errorHandler,
|
||||
})
|
||||
.then((response) => {
|
||||
if (response?.processing) {
|
||||
@@ -103,13 +113,14 @@ export default class MajorUpdater<T extends MajorUpdaterAttrs = MajorUpdaterAttr
|
||||
window.location.reload();
|
||||
}
|
||||
})
|
||||
.catch(errorHandler)
|
||||
.catch((e: RequestError) => {
|
||||
app.modal.close();
|
||||
this.updateState.status = 'failure';
|
||||
this.updateState.incompatibleExtensions = e.response?.errors?.pop()?.incompatible_extensions as string[];
|
||||
})
|
||||
.finally(() => {
|
||||
this.isLoading = null;
|
||||
app.packageManager.control.setLoading(null);
|
||||
m.redraw();
|
||||
});
|
||||
}
|
||||
|
@@ -24,7 +24,7 @@ export default class QueueSection extends Component<{}> {
|
||||
oninit(vnode: Mithril.Vnode<{}, this>) {
|
||||
super.oninit(vnode);
|
||||
|
||||
app.packageManagerQueue.load();
|
||||
app.packageManager.queue.load();
|
||||
}
|
||||
|
||||
view() {
|
||||
@@ -36,7 +36,7 @@ export default class QueueSection extends Component<{}> {
|
||||
<Button
|
||||
className="Button Button--icon"
|
||||
icon="fas fa-sync-alt"
|
||||
onclick={() => app.packageManagerQueue.load()}
|
||||
onclick={() => app.packageManager.queue.load()}
|
||||
aria-label={app.translator.trans('flarum-package-manager.admin.sections.queue.refresh')}
|
||||
/>
|
||||
</div>
|
||||
@@ -154,7 +154,7 @@ export default class QueueSection extends Component<{}> {
|
||||
}
|
||||
|
||||
queueTable() {
|
||||
const tasks = app.packageManagerQueue.getItems();
|
||||
const tasks = app.packageManager.queue.getItems();
|
||||
|
||||
if (!tasks) {
|
||||
return <LoadingIndicator />;
|
||||
@@ -193,7 +193,7 @@ export default class QueueSection extends Component<{}> {
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<Pagination list={app.packageManagerQueue} />
|
||||
<Pagination list={app.packageManager.queue} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@@ -8,16 +8,16 @@ import ControlSection from './ControlSection';
|
||||
|
||||
export default class SettingsPage extends ExtensionPage {
|
||||
sections(vnode: Mithril.VnodeDOM<ExtensionPageAttrs, this>): ItemList<unknown> {
|
||||
// @todo add core feature to register sections
|
||||
const items = super.sections(vnode);
|
||||
|
||||
if (app.data.settings['flarum-package-manager.queue_jobs']) {
|
||||
items.add('queue', <QueueSection />, 5);
|
||||
}
|
||||
items.setPriority('content', 10);
|
||||
|
||||
items.add('control', <ControlSection />, 8);
|
||||
|
||||
items.setPriority('content', 10);
|
||||
if (parseInt(app.data.settings['flarum-package-manager.queue_jobs'])) {
|
||||
items.add('queue', <QueueSection />, 5);
|
||||
}
|
||||
|
||||
items.setPriority('permissions', 0);
|
||||
|
||||
return items;
|
||||
|
@@ -1,278 +1,126 @@
|
||||
import Mithril from 'mithril';
|
||||
import app from 'flarum/admin/app';
|
||||
import Component, { ComponentAttrs } from 'flarum/common/Component';
|
||||
import Button from 'flarum/common/components/Button';
|
||||
import humanTime from 'flarum/common/helpers/humanTime';
|
||||
import LoadingModal from 'flarum/admin/components/LoadingModal';
|
||||
import errorHandler from '../utils/errorHandler';
|
||||
import LoadingIndicator from 'flarum/common/components/LoadingIndicator';
|
||||
import MajorUpdater from './MajorUpdater';
|
||||
import ExtensionItem from './ExtensionItem';
|
||||
import extractText from 'flarum/common/utils/extractText';
|
||||
import jumpToQueue from '../utils/jumpToQueue';
|
||||
import { AsyncBackendResponse } from '../shims';
|
||||
import { Extension } from 'flarum/admin/AdminApplication';
|
||||
import Alert from 'flarum/common/components/Alert';
|
||||
import ItemList from '@flarum/core/src/common/utils/ItemList';
|
||||
|
||||
export type UpdatedPackage = {
|
||||
name: string;
|
||||
version: string;
|
||||
latest: string;
|
||||
'latest-minor': string | null;
|
||||
'latest-major': string | null;
|
||||
'latest-status': string;
|
||||
description: string;
|
||||
};
|
||||
export interface IUpdaterAttrs extends ComponentAttrs {}
|
||||
|
||||
export type ComposerUpdates = {
|
||||
installed: UpdatedPackage[];
|
||||
};
|
||||
|
||||
export type LastUpdateCheck = {
|
||||
checkedAt: Date | null;
|
||||
updates: ComposerUpdates;
|
||||
};
|
||||
|
||||
type UpdateType = 'major' | 'minor' | 'global';
|
||||
type UpdateStatus = 'success' | 'failure' | null;
|
||||
export type UpdateState = {
|
||||
ranAt: Date | null;
|
||||
status: UpdateStatus;
|
||||
limitedPackages: string[];
|
||||
incompatibleExtensions: string[];
|
||||
};
|
||||
|
||||
export type LastUpdateRun = {
|
||||
[key in UpdateType]: UpdateState;
|
||||
} & {
|
||||
limitedPackages: () => string[];
|
||||
};
|
||||
|
||||
interface UpdaterAttrs extends ComponentAttrs {}
|
||||
|
||||
export default class Updater extends Component<UpdaterAttrs> {
|
||||
isLoading: string | null = null;
|
||||
packageUpdates: Record<string, UpdatedPackage> = {};
|
||||
lastUpdateCheck: LastUpdateCheck = JSON.parse(app.data.settings['flarum-package-manager.last_update_check']) as LastUpdateCheck;
|
||||
get lastUpdateRun(): LastUpdateRun {
|
||||
const lastUpdateRun = JSON.parse(app.data.settings['flarum-package-manager.last_update_run']) as LastUpdateRun;
|
||||
|
||||
lastUpdateRun.limitedPackages = () => [
|
||||
...lastUpdateRun.major.limitedPackages,
|
||||
...lastUpdateRun.minor.limitedPackages,
|
||||
...lastUpdateRun.global.limitedPackages,
|
||||
];
|
||||
|
||||
return lastUpdateRun;
|
||||
}
|
||||
|
||||
oninit(vnode: Mithril.Vnode<UpdaterAttrs, this>) {
|
||||
super.oninit(vnode);
|
||||
}
|
||||
export type UpdaterLoadingTypes = 'check' | 'minor-update' | 'global-update' | 'extension-update' | null;
|
||||
|
||||
export default class Updater extends Component<IUpdaterAttrs> {
|
||||
view() {
|
||||
const extensions = this.getExtensionUpdates();
|
||||
let coreUpdate: UpdatedPackage | undefined = this.getCoreUpdate();
|
||||
let core: any;
|
||||
|
||||
if (coreUpdate) {
|
||||
core = {
|
||||
id: 'flarum-core',
|
||||
name: 'flarum/core',
|
||||
version: app.data.settings.version,
|
||||
icon: {
|
||||
backgroundImage: `url(${app.forum.attribute('baseUrl')}/assets/extensions/flarum-package-manager/flarum.svg`,
|
||||
},
|
||||
extra: {
|
||||
'flarum-extension': {
|
||||
title: app.translator.trans('flarum-package-manager.admin.updater.flarum'),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
const core = app.packageManager.control.coreUpdate;
|
||||
|
||||
return [
|
||||
<div className="Form-group">
|
||||
<label>{app.translator.trans('flarum-package-manager.admin.updater.updater_title')}</label>
|
||||
<p className="helpText">{app.translator.trans('flarum-package-manager.admin.updater.updater_help')}</p>
|
||||
{this.lastUpdateCheck?.checkedAt && (
|
||||
<p className="PackageManager-lastUpdatedAt">
|
||||
<span className="PackageManager-lastUpdatedAt-label">
|
||||
{app.translator.trans('flarum-package-manager.admin.updater.last_update_checked_at')}
|
||||
</span>
|
||||
<span className="PackageManager-lastUpdatedAt-value">{humanTime(this.lastUpdateCheck.checkedAt)}</span>
|
||||
</p>
|
||||
)}
|
||||
<div className="PackageManager-updaterControls">
|
||||
<Button
|
||||
className="Button"
|
||||
icon="fas fa-sync-alt"
|
||||
onclick={this.checkForUpdates.bind(this)}
|
||||
loading={this.isLoading === 'check'}
|
||||
disabled={this.isLoading !== null && this.isLoading !== 'check'}
|
||||
>
|
||||
{app.translator.trans('flarum-package-manager.admin.updater.check_for_updates')}
|
||||
</Button>
|
||||
<Button
|
||||
className="Button"
|
||||
icon="fas fa-play"
|
||||
onclick={this.updateGlobally.bind(this)}
|
||||
loading={this.isLoading === 'global-update'}
|
||||
disabled={this.isLoading !== null && this.isLoading !== 'global-update'}
|
||||
>
|
||||
{app.translator.trans('flarum-package-manager.admin.updater.run_global_update')}
|
||||
</Button>
|
||||
</div>
|
||||
{this.isLoading !== null ? (
|
||||
<div className="PackageManager-extensions">
|
||||
<LoadingIndicator />
|
||||
</div>
|
||||
) : extensions.length || core ? (
|
||||
<div className="PackageManager-extensions">
|
||||
<div className="PackageManager-extensions-grid">
|
||||
{core ? (
|
||||
<ExtensionItem
|
||||
extension={core}
|
||||
updates={coreUpdate}
|
||||
isCore={true}
|
||||
onClickUpdate={this.updateCoreMinor.bind(this)}
|
||||
whyNotWarning={this.lastUpdateRun.limitedPackages().includes('flarum/core')}
|
||||
/>
|
||||
) : null}
|
||||
{extensions.map((extension: Extension) => (
|
||||
<ExtensionItem
|
||||
extension={extension}
|
||||
updates={this.packageUpdates[extension.id]}
|
||||
onClickUpdate={this.updateExtension.bind(this, extension)}
|
||||
whyNotWarning={this.lastUpdateRun.limitedPackages().includes(extension.name)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{this.lastUpdateCheckView()}
|
||||
<div className="PackageManager-updaterControls">{this.controlItems().toArray()}</div>
|
||||
{this.availableUpdatesView()}
|
||||
</div>,
|
||||
coreUpdate && coreUpdate['latest-major'] ? <MajorUpdater coreUpdate={coreUpdate} updateState={this.lastUpdateRun.major} /> : null,
|
||||
core && core.package['latest-major'] ? (
|
||||
<MajorUpdater coreUpdate={core.package} updateState={app.packageManager.control.lastUpdateRun.major} />
|
||||
) : null,
|
||||
];
|
||||
}
|
||||
|
||||
getExtensionUpdates(): Extension[] {
|
||||
this.lastUpdateCheck?.updates?.installed?.filter((composerPackage: UpdatedPackage) => {
|
||||
const id = composerPackage.name.replace('/', '-').replace(/(flarum-ext-)|(flarum-)/, '');
|
||||
|
||||
const extension = app.data.extensions[id];
|
||||
const safeToUpdate = ['semver-safe-update', 'update-possible'].includes(composerPackage['latest-status']);
|
||||
|
||||
if (extension && safeToUpdate) {
|
||||
this.packageUpdates[extension.id] = composerPackage;
|
||||
}
|
||||
|
||||
return extension && safeToUpdate;
|
||||
});
|
||||
|
||||
return (Object.values(app.data.extensions) as Extension[]).filter((extension: Extension) => this.packageUpdates[extension.id]);
|
||||
lastUpdateCheckView() {
|
||||
return (
|
||||
(app.packageManager.control.lastUpdateCheck?.checkedAt && (
|
||||
<p className="PackageManager-lastUpdatedAt">
|
||||
<span className="PackageManager-lastUpdatedAt-label">
|
||||
{app.translator.trans('flarum-package-manager.admin.updater.last_update_checked_at')}
|
||||
</span>
|
||||
<span className="PackageManager-lastUpdatedAt-value">{humanTime(app.packageManager.control.lastUpdateCheck.checkedAt)}</span>
|
||||
</p>
|
||||
)) ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
getCoreUpdate(): UpdatedPackage | undefined {
|
||||
return this.lastUpdateCheck?.updates?.installed?.filter((composerPackage: UpdatedPackage) => composerPackage.name === 'flarum/core').pop();
|
||||
}
|
||||
availableUpdatesView() {
|
||||
const state = app.packageManager.control;
|
||||
|
||||
checkForUpdates() {
|
||||
this.isLoading = 'check';
|
||||
|
||||
app
|
||||
.request<AsyncBackendResponse | LastUpdateCheck>({
|
||||
method: 'POST',
|
||||
url: `${app.forum.attribute('apiUrl')}/package-manager/check-for-updates`,
|
||||
errorHandler,
|
||||
})
|
||||
.then((response) => {
|
||||
if ((response as AsyncBackendResponse).processing) {
|
||||
jumpToQueue();
|
||||
} else {
|
||||
this.lastUpdateCheck = response as LastUpdateCheck;
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
this.isLoading = null;
|
||||
m.redraw();
|
||||
});
|
||||
}
|
||||
|
||||
updateCoreMinor() {
|
||||
if (confirm(extractText(app.translator.trans('flarum-package-manager.admin.minor_update_confirmation.content')))) {
|
||||
app.modal.show(LoadingModal);
|
||||
this.isLoading = 'minor-update';
|
||||
|
||||
app
|
||||
.request<AsyncBackendResponse | null>({
|
||||
method: 'POST',
|
||||
url: `${app.forum.attribute('apiUrl')}/package-manager/minor-update`,
|
||||
errorHandler,
|
||||
})
|
||||
.then((response) => {
|
||||
if (response?.processing) {
|
||||
jumpToQueue();
|
||||
} else {
|
||||
app.alerts.show({ type: 'success' }, app.translator.trans('flarum-package-manager.admin.update_successful'));
|
||||
window.location.reload();
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
this.isLoading = null;
|
||||
m.redraw();
|
||||
});
|
||||
if (app.packageManager.control.isLoading()) {
|
||||
return (
|
||||
<div className="PackageManager-extensions">
|
||||
<LoadingIndicator />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!(state.extensionUpdates.length || state.coreUpdate)) {
|
||||
return (
|
||||
<div className="PackageManager-extensions">
|
||||
<Alert type="success" dismissible={false}>
|
||||
{app.translator.trans('flarum-package-manager.admin.updater.up_to_date')}
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="PackageManager-extensions">
|
||||
<div className="PackageManager-extensions-grid">
|
||||
{state.coreUpdate ? (
|
||||
<ExtensionItem
|
||||
extension={state.coreUpdate.extension}
|
||||
updates={state.coreUpdate.package}
|
||||
isCore={true}
|
||||
onClickUpdate={() => state.updateCoreMinor()}
|
||||
whyNotWarning={state.lastUpdateRun.limitedPackages().includes('flarum/core')}
|
||||
/>
|
||||
) : null}
|
||||
{state.extensionUpdates.map((extension: Extension) => (
|
||||
<ExtensionItem
|
||||
extension={extension}
|
||||
updates={state.packageUpdates[extension.id]}
|
||||
onClickUpdate={() => state.updateExtension(extension)}
|
||||
whyNotWarning={state.lastUpdateRun.limitedPackages().includes(extension.name)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
updateExtension(extension: any) {
|
||||
app.modal.show(LoadingModal);
|
||||
this.isLoading = 'extension-update';
|
||||
controlItems() {
|
||||
const items = new ItemList();
|
||||
|
||||
app
|
||||
.request<AsyncBackendResponse | null>({
|
||||
method: 'PATCH',
|
||||
url: `${app.forum.attribute('apiUrl')}/package-manager/extensions/${extension.id}`,
|
||||
errorHandler,
|
||||
})
|
||||
.then((response) => {
|
||||
if (response?.processing) {
|
||||
jumpToQueue();
|
||||
} else {
|
||||
app.alerts.show(
|
||||
{ type: 'success' },
|
||||
app.translator.trans('flarum-package-manager.admin.extensions.successful_update', {
|
||||
extension: extension.extra['flarum-extension'].title,
|
||||
})
|
||||
);
|
||||
window.location.reload();
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
this.isLoading = null;
|
||||
m.redraw();
|
||||
});
|
||||
}
|
||||
items.add(
|
||||
'updateCheck',
|
||||
<Button
|
||||
className="Button"
|
||||
icon="fas fa-sync-alt"
|
||||
onclick={() => app.packageManager.control.checkForUpdates()}
|
||||
loading={app.packageManager.control.isLoading('check')}
|
||||
disabled={app.packageManager.control.isLoadingOtherThan('check')}
|
||||
>
|
||||
{app.translator.trans('flarum-package-manager.admin.updater.check_for_updates')}
|
||||
</Button>,
|
||||
100
|
||||
);
|
||||
|
||||
updateGlobally() {
|
||||
app.modal.show(LoadingModal);
|
||||
this.isLoading = 'global-update';
|
||||
items.add(
|
||||
'globalUpdate',
|
||||
<Button
|
||||
className="Button"
|
||||
icon="fas fa-play"
|
||||
onclick={() => app.packageManager.control.updateGlobally()}
|
||||
loading={app.packageManager.control.isLoading('global-update')}
|
||||
disabled={app.packageManager.control.isLoadingOtherThan('global-update')}
|
||||
>
|
||||
{app.translator.trans('flarum-package-manager.admin.updater.run_global_update')}
|
||||
</Button>
|
||||
);
|
||||
|
||||
app
|
||||
.request<AsyncBackendResponse | null>({
|
||||
method: 'POST',
|
||||
url: `${app.forum.attribute('apiUrl')}/package-manager/global-update`,
|
||||
errorHandler,
|
||||
})
|
||||
.then((response) => {
|
||||
if (response?.processing) {
|
||||
jumpToQueue();
|
||||
} else {
|
||||
app.alerts.show({ type: 'success' }, app.translator.trans('flarum-package-manager.admin.updater.global_update_successful'));
|
||||
window.location.reload();
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
this.isLoading = null;
|
||||
m.redraw();
|
||||
});
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
@@ -47,12 +47,12 @@ export default class WhyNotModal<CustomAttrs extends WhyNotModalAttrs = WhyNotMo
|
||||
package: this.attrs.package,
|
||||
},
|
||||
},
|
||||
errorHandler,
|
||||
})
|
||||
.then((response) => {
|
||||
this.loading = false;
|
||||
this.whyNot = response.data.reason;
|
||||
m.redraw();
|
||||
});
|
||||
})
|
||||
.catch(errorHandler);
|
||||
}
|
||||
}
|
||||
|
@@ -4,21 +4,29 @@ import ExtensionPage from 'flarum/admin/components/ExtensionPage';
|
||||
import Button from 'flarum/common/components/Button';
|
||||
import LoadingModal from 'flarum/admin/components/LoadingModal';
|
||||
import isExtensionEnabled from 'flarum/admin/utils/isExtensionEnabled';
|
||||
import SettingsPage from './components/SettingsPage';
|
||||
import Alert from 'flarum/common/components/Alert';
|
||||
|
||||
import SettingsPage from './components/SettingsPage';
|
||||
import Task from './models/Task';
|
||||
import jumpToQueue from './utils/jumpToQueue';
|
||||
import QueueState from './states/QueueState';
|
||||
import extractText from 'flarum/common/utils/extractText';
|
||||
import { AsyncBackendResponse } from './shims';
|
||||
import PackageManagerState from './states/PackageManagerState';
|
||||
|
||||
app.initializers.add('flarum-package-manager', (app) => {
|
||||
app.store.models['package-manager-tasks'] = Task;
|
||||
|
||||
app.packageManagerQueue = new QueueState();
|
||||
app.packageManager = new PackageManagerState();
|
||||
|
||||
app.extensionData
|
||||
.for('flarum-package-manager')
|
||||
.registerSetting(() => (
|
||||
<div className="Form-group">
|
||||
<Alert type="warning" dismissible={false}>
|
||||
{app.translator.trans('flarum-package-manager.admin.settings.access_warning')}
|
||||
</Alert>
|
||||
</div>
|
||||
))
|
||||
.registerSetting({
|
||||
setting: 'flarum-package-manager.queue_jobs',
|
||||
label: app.translator.trans('flarum-package-manager.admin.settings.queue_jobs'),
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import QueueState from './states/QueueState';
|
||||
import PackageManagerState from './states/PackageManagerState';
|
||||
|
||||
export interface AsyncBackendResponse {
|
||||
processing: boolean;
|
||||
@@ -6,6 +6,6 @@ export interface AsyncBackendResponse {
|
||||
|
||||
declare module 'flarum/admin/AdminApplication' {
|
||||
export default interface AdminApplication {
|
||||
packageManagerQueue: QueueState;
|
||||
packageManager: PackageManagerState;
|
||||
}
|
||||
}
|
||||
|
@@ -0,0 +1,239 @@
|
||||
import app from 'flarum/admin/app';
|
||||
import LoadingModal from 'flarum/admin/components/LoadingModal';
|
||||
import { UpdaterLoadingTypes } from '../components/Updater';
|
||||
import { InstallerLoadingTypes } from '../components/Installer';
|
||||
import { MajorUpdaterLoadingTypes } from '../components/MajorUpdater';
|
||||
import { AsyncBackendResponse } from '../shims';
|
||||
import errorHandler from '../utils/errorHandler';
|
||||
import jumpToQueue from '../utils/jumpToQueue';
|
||||
import { Extension } from 'flarum/admin/AdminApplication';
|
||||
import extractText from 'flarum/common/utils/extractText';
|
||||
|
||||
export type UpdatedPackage = {
|
||||
name: string;
|
||||
version: string;
|
||||
latest: string;
|
||||
'latest-minor': string | null;
|
||||
'latest-major': string | null;
|
||||
'latest-status': string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type ComposerUpdates = {
|
||||
installed: UpdatedPackage[];
|
||||
};
|
||||
|
||||
export type LastUpdateCheck = {
|
||||
checkedAt: Date | null;
|
||||
updates: ComposerUpdates;
|
||||
};
|
||||
|
||||
type UpdateType = 'major' | 'minor' | 'global';
|
||||
type UpdateStatus = 'success' | 'failure' | null;
|
||||
export type UpdateState = {
|
||||
ranAt: Date | null;
|
||||
status: UpdateStatus;
|
||||
limitedPackages: string[];
|
||||
incompatibleExtensions: string[];
|
||||
};
|
||||
|
||||
export type LastUpdateRun = {
|
||||
[key in UpdateType]: UpdateState;
|
||||
} & {
|
||||
limitedPackages: () => string[];
|
||||
};
|
||||
|
||||
export type LoadingTypes = UpdaterLoadingTypes | InstallerLoadingTypes | MajorUpdaterLoadingTypes;
|
||||
|
||||
export type CoreUpdate = {
|
||||
package: UpdatedPackage;
|
||||
extension: Extension;
|
||||
};
|
||||
|
||||
export default class ControlSectionState {
|
||||
loading: LoadingTypes = null;
|
||||
|
||||
public packageUpdates: Record<string, UpdatedPackage> = {};
|
||||
public lastUpdateCheck!: LastUpdateCheck;
|
||||
public extensionUpdates!: Extension[];
|
||||
public coreUpdate: CoreUpdate | null = null;
|
||||
get lastUpdateRun(): LastUpdateRun {
|
||||
const lastUpdateRun = JSON.parse(app.data.settings['flarum-package-manager.last_update_run']) as LastUpdateRun;
|
||||
|
||||
lastUpdateRun.limitedPackages = () => [
|
||||
...lastUpdateRun.major.limitedPackages,
|
||||
...lastUpdateRun.minor.limitedPackages,
|
||||
...lastUpdateRun.global.limitedPackages,
|
||||
];
|
||||
|
||||
return lastUpdateRun;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this.lastUpdateCheck = JSON.parse(app.data.settings['flarum-package-manager.last_update_check']) as LastUpdateCheck;
|
||||
this.extensionUpdates = this.formatExtensionUpdates(this.lastUpdateCheck);
|
||||
this.coreUpdate = this.formatCoreUpdate(this.lastUpdateCheck);
|
||||
}
|
||||
|
||||
isLoading(name: LoadingTypes = null): boolean {
|
||||
return (name && this.loading === name) || (!name && this.loading !== null);
|
||||
}
|
||||
|
||||
isLoadingOtherThan(name: LoadingTypes): boolean {
|
||||
return this.loading !== null && this.loading !== name;
|
||||
}
|
||||
|
||||
setLoading(name: LoadingTypes): void {
|
||||
this.loading = name;
|
||||
}
|
||||
|
||||
checkForUpdates() {
|
||||
this.setLoading('check');
|
||||
|
||||
app
|
||||
.request<AsyncBackendResponse | LastUpdateCheck>({
|
||||
method: 'POST',
|
||||
url: `${app.forum.attribute('apiUrl')}/package-manager/check-for-updates`,
|
||||
})
|
||||
.then((response) => {
|
||||
if ((response as AsyncBackendResponse).processing) {
|
||||
jumpToQueue();
|
||||
} else {
|
||||
this.lastUpdateCheck = response as LastUpdateCheck;
|
||||
this.extensionUpdates = this.formatExtensionUpdates(response as LastUpdateCheck);
|
||||
this.coreUpdate = this.formatCoreUpdate(response as LastUpdateCheck);
|
||||
m.redraw();
|
||||
}
|
||||
})
|
||||
.catch(errorHandler)
|
||||
.finally(() => {
|
||||
this.setLoading(null);
|
||||
m.redraw();
|
||||
});
|
||||
}
|
||||
|
||||
updateCoreMinor() {
|
||||
if (confirm(extractText(app.translator.trans('flarum-package-manager.admin.minor_update_confirmation.content')))) {
|
||||
app.modal.show(LoadingModal);
|
||||
this.setLoading('minor-update');
|
||||
|
||||
app
|
||||
.request<AsyncBackendResponse | null>({
|
||||
method: 'POST',
|
||||
url: `${app.forum.attribute('apiUrl')}/package-manager/minor-update`,
|
||||
})
|
||||
.then((response) => {
|
||||
if (response?.processing) {
|
||||
jumpToQueue();
|
||||
} else {
|
||||
app.alerts.show({ type: 'success' }, app.translator.trans('flarum-package-manager.admin.update_successful'));
|
||||
window.location.reload();
|
||||
}
|
||||
})
|
||||
.catch(errorHandler)
|
||||
.finally(() => {
|
||||
this.setLoading(null);
|
||||
app.modal.close();
|
||||
m.redraw();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
updateExtension(extension: Extension) {
|
||||
app.modal.show(LoadingModal);
|
||||
this.setLoading('extension-update');
|
||||
|
||||
app
|
||||
.request<AsyncBackendResponse | null>({
|
||||
method: 'PATCH',
|
||||
url: `${app.forum.attribute('apiUrl')}/package-manager/extensions/${extension.id}`,
|
||||
})
|
||||
.then((response) => {
|
||||
if (response?.processing) {
|
||||
jumpToQueue();
|
||||
} else {
|
||||
app.alerts.show(
|
||||
{ type: 'success' },
|
||||
app.translator.trans('flarum-package-manager.admin.extensions.successful_update', {
|
||||
extension: extension.extra['flarum-extension'].title,
|
||||
})
|
||||
);
|
||||
window.location.reload();
|
||||
}
|
||||
})
|
||||
.catch(errorHandler)
|
||||
.finally(() => {
|
||||
this.setLoading(null);
|
||||
app.modal.close();
|
||||
m.redraw();
|
||||
});
|
||||
}
|
||||
|
||||
updateGlobally() {
|
||||
app.modal.show(LoadingModal);
|
||||
this.setLoading('global-update');
|
||||
|
||||
app
|
||||
.request<AsyncBackendResponse | null>({
|
||||
method: 'POST',
|
||||
url: `${app.forum.attribute('apiUrl')}/package-manager/global-update`,
|
||||
})
|
||||
.then((response) => {
|
||||
if (response?.processing) {
|
||||
jumpToQueue();
|
||||
} else {
|
||||
app.alerts.show({ type: 'success' }, app.translator.trans('flarum-package-manager.admin.updater.global_update_successful'));
|
||||
window.location.reload();
|
||||
}
|
||||
})
|
||||
.catch(errorHandler)
|
||||
.finally(() => {
|
||||
this.setLoading(null);
|
||||
app.modal.close();
|
||||
m.redraw();
|
||||
});
|
||||
}
|
||||
|
||||
formatExtensionUpdates(lastUpdateCheck: LastUpdateCheck): Extension[] {
|
||||
this.packageUpdates = {};
|
||||
|
||||
lastUpdateCheck?.updates?.installed?.filter((composerPackage: UpdatedPackage) => {
|
||||
const id = composerPackage.name.replace('/', '-').replace(/(flarum-ext-)|(flarum-)/, '');
|
||||
|
||||
const extension = app.data.extensions[id];
|
||||
const safeToUpdate = ['semver-safe-update', 'update-possible'].includes(composerPackage['latest-status']);
|
||||
|
||||
if (extension && safeToUpdate) {
|
||||
this.packageUpdates[extension.id] = composerPackage;
|
||||
}
|
||||
|
||||
return extension && safeToUpdate;
|
||||
});
|
||||
|
||||
return (Object.values(app.data.extensions) as Extension[]).filter((extension: Extension) => this.packageUpdates[extension.id]);
|
||||
}
|
||||
|
||||
formatCoreUpdate(lastUpdateCheck: LastUpdateCheck): CoreUpdate | null {
|
||||
const core = lastUpdateCheck?.updates?.installed?.filter((composerPackage: UpdatedPackage) => composerPackage.name === 'flarum/core').pop();
|
||||
|
||||
if (!core) return null;
|
||||
|
||||
return {
|
||||
package: core,
|
||||
extension: {
|
||||
id: 'flarum-core',
|
||||
name: 'flarum/core',
|
||||
version: app.data.settings.version,
|
||||
icon: {
|
||||
// @ts-ignore
|
||||
backgroundImage: `url(${app.forum.attribute('baseUrl')}/assets/extensions/flarum-package-manager/flarum.svg`,
|
||||
},
|
||||
extra: {
|
||||
'flarum-extension': {
|
||||
title: extractText(app.translator.trans('flarum-package-manager.admin.updater.flarum')),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
@@ -0,0 +1,7 @@
|
||||
import QueueState from './QueueState';
|
||||
import ControlSectionState from './ControlSectionState';
|
||||
|
||||
export default class PackageManagerState {
|
||||
public queue: QueueState = new QueueState();
|
||||
public control: ControlSectionState = new ControlSectionState();
|
||||
}
|
@@ -4,7 +4,7 @@ import { ApiQueryParamsPlural } from 'flarum/common/Store';
|
||||
|
||||
export default class QueueState {
|
||||
private tasks: Task[] | null = null;
|
||||
private limit = 5;
|
||||
private limit = 20;
|
||||
private offset = 0;
|
||||
private total = 0;
|
||||
|
||||
|
@@ -6,7 +6,7 @@ window.jumpToQueue = jumpToQueue;
|
||||
export default function jumpToQueue(): void {
|
||||
app.modal.close();
|
||||
m.route.set(app.route('extension', { id: 'flarum-package-manager' }));
|
||||
app.packageManagerQueue.load();
|
||||
app.packageManager.queue.load();
|
||||
setTimeout(() => {
|
||||
document.getElementById('PackageManager-queueSection')?.scrollIntoView({ block: 'nearest' });
|
||||
}, 200);
|
||||
|
@@ -11,6 +11,7 @@
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
grid-area: controls;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.PackageManager-extensions {
|
||||
@@ -19,7 +20,6 @@
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, calc(~"100% / 3 - var(--gap)"));
|
||||
gap: var(--gap);
|
||||
margin-top: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -22,7 +22,7 @@ flarum-package-manager:
|
||||
update: Update
|
||||
|
||||
file_permissions: >
|
||||
The package manager requires read and write permissions on the following files and directories: composer.json, composer.lock, vendor, storage/.composer
|
||||
The package manager requires read and write permissions on the following files and directories: composer.json, composer.lock, vendor, storage, storage/.composer
|
||||
|
||||
major_updater:
|
||||
description: Major Flarum updates are not backwards compatible, meaning that some of your currently installed extensions, and manually made modifications might not work with this new version.
|
||||
@@ -72,12 +72,14 @@ flarum-package-manager:
|
||||
title: Queue
|
||||
|
||||
settings:
|
||||
access_warning: Please be careful to who you give access to the admin area, the package manager could be misused by bad actors to install packages that can lead to security breaches.
|
||||
queue_jobs: Run operations in the background queue
|
||||
queue_jobs_help: >
|
||||
You can read about a <a href='{basic_impl_link}'>basic queue</a> implementation or a <a href='{adv_impl_link}'>more advanced</a> one.
|
||||
Make sure the PHP version used for the queue is {php_version}. Make sure <a href='{folder_perms_link}'>folder permissions</a> are correctly configured.
|
||||
|
||||
updater:
|
||||
up_to_date: Everything is up to date!
|
||||
check_for_updates: Check for updates
|
||||
flarum: Flarum Core
|
||||
global_update_successful: Successfully updated all packages.
|
||||
|
@@ -10,6 +10,8 @@
|
||||
namespace Flarum\PackageManager\Command;
|
||||
|
||||
use Flarum\Extension\ExtensionManager;
|
||||
use Flarum\Foundation\Paths;
|
||||
use Flarum\Foundation\ValidationException;
|
||||
use Flarum\PackageManager\Composer\ComposerAdapter;
|
||||
use Flarum\PackageManager\Exception\ComposerUpdateFailedException;
|
||||
use Flarum\PackageManager\Exception\ExtensionNotInstalledException;
|
||||
@@ -46,18 +48,25 @@ class UpdateExtensionHandler
|
||||
*/
|
||||
protected $events;
|
||||
|
||||
/**
|
||||
* @var Paths
|
||||
*/
|
||||
protected $paths;
|
||||
|
||||
public function __construct(
|
||||
ComposerAdapter $composer,
|
||||
ExtensionManager $extensions,
|
||||
UpdateExtensionValidator $validator,
|
||||
LastUpdateCheck $lastUpdateCheck,
|
||||
Dispatcher $events
|
||||
Dispatcher $events,
|
||||
Paths $paths
|
||||
) {
|
||||
$this->composer = $composer;
|
||||
$this->extensions = $extensions;
|
||||
$this->validator = $validator;
|
||||
$this->lastUpdateCheck = $lastUpdateCheck;
|
||||
$this->events = $events;
|
||||
$this->paths = $paths;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,6 +85,17 @@ class UpdateExtensionHandler
|
||||
throw new ExtensionNotInstalledException($command->extensionId);
|
||||
}
|
||||
|
||||
$rootComposer = json_decode(file_get_contents("{$this->paths->base}/composer.json"), true);
|
||||
|
||||
// If this was installed as a requirement for another extension,
|
||||
// don't update it directly.
|
||||
// @TODO communicate this in the UI.
|
||||
if (! isset($rootComposer['require'][$extension->name]) && ! empty($extension->getExtensionDependencyIds())) {
|
||||
throw new ValidationException([
|
||||
'message' => "Cannot update $extension->name. It was installed as a requirement for other extensions: ".implode(', ', $extension->getExtensionDependencyIds()).'. Update those extensions instead.'
|
||||
]);
|
||||
}
|
||||
|
||||
$output = $this->composer->run(
|
||||
new StringInput("require $extension->name:*"),
|
||||
$command->task ?? null
|
||||
|
@@ -9,6 +9,7 @@
|
||||
|
||||
namespace Flarum\PackageManager\Composer;
|
||||
|
||||
use Composer\Config;
|
||||
use Composer\Console\Application;
|
||||
use Flarum\Foundation\Paths;
|
||||
use Flarum\PackageManager\OutputLogger;
|
||||
@@ -70,4 +71,9 @@ class ComposerAdapter
|
||||
|
||||
return new ComposerOutput($exitCode, $output);
|
||||
}
|
||||
|
||||
public static function setPhpVersion(string $phpVersion)
|
||||
{
|
||||
Config::$defaultConfig['platform']['php'] = $phpVersion;
|
||||
}
|
||||
}
|
||||
|
@@ -11,7 +11,9 @@ namespace Flarum\PackageManager\Job;
|
||||
|
||||
use Flarum\Bus\Dispatcher;
|
||||
use Flarum\PackageManager\Command\BusinessCommandInterface;
|
||||
use Flarum\PackageManager\Composer\ComposerAdapter;
|
||||
use Flarum\Queue\AbstractJob;
|
||||
use Illuminate\Queue\Middleware\WithoutOverlapping;
|
||||
use Throwable;
|
||||
|
||||
class ComposerCommandJob extends AbstractJob
|
||||
@@ -22,11 +24,11 @@ class ComposerCommandJob extends AbstractJob
|
||||
protected $command;
|
||||
|
||||
/**
|
||||
* @var int[]
|
||||
* @var string
|
||||
*/
|
||||
protected $phpVersion;
|
||||
|
||||
public function __construct(BusinessCommandInterface $command, array $phpVersion)
|
||||
public function __construct(BusinessCommandInterface $command, string $phpVersion)
|
||||
{
|
||||
$this->command = $command;
|
||||
$this->phpVersion = $phpVersion;
|
||||
@@ -35,12 +37,7 @@ class ComposerCommandJob extends AbstractJob
|
||||
public function handle(Dispatcher $bus)
|
||||
{
|
||||
try {
|
||||
if ([PHP_MAJOR_VERSION, PHP_MINOR_VERSION] !== [$this->phpVersion[0], $this->phpVersion[1]]) {
|
||||
$webPhpVersion = implode('.', $this->phpVersion);
|
||||
$sshPhpVersion = implode('.', [PHP_MAJOR_VERSION, PHP_MINOR_VERSION]);
|
||||
|
||||
throw new \Exception("PHP version mismatch. SSH PHP version must match web server PHP version. Found SSH (PHP $sshPhpVersion) and Web Server (PHP $webPhpVersion).");
|
||||
}
|
||||
ComposerAdapter::setPhpVersion($this->phpVersion);
|
||||
|
||||
$this->command->task->start();
|
||||
|
||||
@@ -62,4 +59,11 @@ class ComposerCommandJob extends AbstractJob
|
||||
|
||||
$this->fail($exception);
|
||||
}
|
||||
|
||||
public function middleware()
|
||||
{
|
||||
return [
|
||||
new WithoutOverlapping(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
@@ -73,7 +73,7 @@ class Dispatcher
|
||||
$command->task = $task;
|
||||
|
||||
$this->queue->push(
|
||||
new ComposerCommandJob($command, [PHP_MAJOR_VERSION, PHP_MINOR_VERSION])
|
||||
new ComposerCommandJob($command, PHP_VERSION)
|
||||
);
|
||||
} else {
|
||||
$data = $this->bus->dispatch($command);
|
||||
|
@@ -19,7 +19,7 @@
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"flarum/core": "^1.4",
|
||||
"flarum/core": "^1.6",
|
||||
"pusher/pusher-php-server": "^2.2"
|
||||
},
|
||||
"require-dev": {
|
||||
|
1
extensions/pusher/js/dist/forum.js.LICENSE.txt
generated
vendored
Normal file
1
extensions/pusher/js/dist/forum.js.LICENSE.txt
generated
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/*! regenerator-runtime -- Copyright (c) 2014-present, Facebook, Inc. -- license (MIT): https://github.com/facebook/regenerator/blob/main/LICENSE */
|
@@ -1,5 +1,13 @@
|
||||
# Changelog
|
||||
|
||||
## [1.5.1](https://github.com/flarum/statistics/compare/v1.5.0...v1.5.1)
|
||||
|
||||
### Added
|
||||
- Support for custom date ranges (https://github.com/flarum/framework/pull/3622)
|
||||
|
||||
### Fixed
|
||||
- Previous period chart is unclear (https://github.com/flarum/framework/pull/3654)
|
||||
|
||||
## [1.4.1](https://github.com/flarum/statistics/compare/v1.4.0...v1.4.1)
|
||||
|
||||
### Changed
|
||||
|
@@ -19,7 +19,7 @@
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"flarum/core": "^1.4"
|
||||
"flarum/core": "^1.6"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
12
extensions/statistics/js/dist-typings/components/StatisticsWidget.d.ts
generated
vendored
12
extensions/statistics/js/dist-typings/components/StatisticsWidget.d.ts
generated
vendored
@@ -1,4 +1,5 @@
|
||||
import DashboardWidget, { IDashboardWidgetAttrs } from 'flarum/admin/components/DashboardWidget';
|
||||
import { IDateSelection } from './StatisticsWidgetDateSelectionModal';
|
||||
import type Mithril from 'mithril';
|
||||
interface IPeriodDeclaration {
|
||||
start: number;
|
||||
@@ -9,17 +10,22 @@ export default class StatisticsWidget extends DashboardWidget {
|
||||
entities: string[];
|
||||
periods: undefined | Record<string, IPeriodDeclaration>;
|
||||
chart: any;
|
||||
timedData: any;
|
||||
customPeriod: IDateSelection | null;
|
||||
timedData: Record<string, undefined | any>;
|
||||
lifetimeData: any;
|
||||
customPeriodData: Record<string, undefined | any>;
|
||||
noData: boolean;
|
||||
loadingLifetime: boolean;
|
||||
loadingTimed: boolean;
|
||||
loadingTimed: Record<string, 'unloaded' | 'loading' | 'loaded' | 'fail'>;
|
||||
loadingCustom: Record<string, 'unloaded' | 'loading' | 'loaded' | 'fail'>;
|
||||
selectedEntity: string;
|
||||
selectedPeriod: undefined | string;
|
||||
chartEntity?: string;
|
||||
chartPeriod?: string;
|
||||
oncreate(vnode: Mithril.VnodeDOM<IDashboardWidgetAttrs, this>): void;
|
||||
loadLifetimeData(): Promise<void>;
|
||||
loadTimedData(): Promise<void>;
|
||||
loadTimedData(model: string): Promise<void>;
|
||||
loadCustomRangeData(model: string): Promise<void>;
|
||||
className(): string;
|
||||
content(): JSX.Element;
|
||||
drawChart(vnode: Mithril.VnodeDOM<any, any>): void;
|
||||
|
39
extensions/statistics/js/dist-typings/components/StatisticsWidgetDateSelectionModal.d.ts
generated
vendored
Normal file
39
extensions/statistics/js/dist-typings/components/StatisticsWidgetDateSelectionModal.d.ts
generated
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
import ItemList from 'flarum/common/utils/ItemList';
|
||||
import Modal, { IInternalModalAttrs } from 'flarum/common/components/Modal';
|
||||
import Mithril from 'mithril';
|
||||
export interface IDateSelection {
|
||||
/**
|
||||
* Timestamp (seconds, not ms) for start date
|
||||
*/
|
||||
start: number;
|
||||
/**
|
||||
* Timestamp (seconds, not ms) for end date
|
||||
*/
|
||||
end: number;
|
||||
}
|
||||
export interface IStatisticsWidgetDateSelectionModalAttrs extends IInternalModalAttrs {
|
||||
onModalSubmit: (dates: IDateSelection) => void;
|
||||
value?: IDateSelection;
|
||||
}
|
||||
interface IStatisticsWidgetDateSelectionModalState {
|
||||
inputs: {
|
||||
startDateVal: string;
|
||||
endDateVal: string;
|
||||
};
|
||||
ids: {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
};
|
||||
}
|
||||
export default class StatisticsWidgetDateSelectionModal extends Modal<IStatisticsWidgetDateSelectionModalAttrs> {
|
||||
state: IStatisticsWidgetDateSelectionModalState;
|
||||
oninit(vnode: Mithril.Vnode<IStatisticsWidgetDateSelectionModalAttrs, this>): void;
|
||||
className(): string;
|
||||
title(): Mithril.Children;
|
||||
content(): Mithril.Children;
|
||||
items(): ItemList<Mithril.Children>;
|
||||
updateState(field: keyof IStatisticsWidgetDateSelectionModalState['inputs']): (e: InputEvent) => void;
|
||||
submitData(): IDateSelection;
|
||||
onsubmit(e: SubmitEvent): void;
|
||||
}
|
||||
export {};
|
2
extensions/statistics/js/dist/admin.js
generated
vendored
2
extensions/statistics/js/dist/admin.js
generated
vendored
File diff suppressed because one or more lines are too long
1
extensions/statistics/js/dist/admin.js.LICENSE.txt
generated
vendored
Normal file
1
extensions/statistics/js/dist/admin.js.LICENSE.txt
generated
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/*! regenerator-runtime -- Copyright (c) 2014-present, Facebook, Inc. -- license (MIT): https://github.com/facebook/regenerator/blob/main/LICENSE */
|
2
extensions/statistics/js/dist/admin.js.map
generated
vendored
2
extensions/statistics/js/dist/admin.js.map
generated
vendored
File diff suppressed because one or more lines are too long
@@ -7,15 +7,15 @@
|
||||
"frappe-charts": "^1.6.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/mithril": "^2.0.11",
|
||||
"prettier": "^2.7.1",
|
||||
"flarum-webpack-config": "^2.0.0",
|
||||
"webpack": "^5.73.0",
|
||||
"webpack-cli": "^4.10.0",
|
||||
"@flarum/prettier-config": "^1.0.0",
|
||||
"@types/mithril": "^2.0.11",
|
||||
"flarum-tsconfig": "^1.0.2",
|
||||
"flarum-webpack-config": "^2.0.0",
|
||||
"prettier": "^2.7.1",
|
||||
"typescript": "^4.7.4",
|
||||
"typescript-coverage-report": "^0.6.4"
|
||||
"typescript-coverage-report": "^0.6.4",
|
||||
"webpack": "^5.73.0",
|
||||
"webpack-cli": "^4.10.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "webpack --mode development --watch",
|
||||
|
@@ -3,16 +3,26 @@ import app from 'flarum/admin/app';
|
||||
import SelectDropdown from 'flarum/common/components/SelectDropdown';
|
||||
import Button from 'flarum/common/components/Button';
|
||||
import abbreviateNumber from 'flarum/common/utils/abbreviateNumber';
|
||||
import extractText from 'flarum/common/utils/extractText';
|
||||
import LoadingIndicator from 'flarum/common/components/LoadingIndicator';
|
||||
import Placeholder from 'flarum/common/components/Placeholder';
|
||||
import icon from 'flarum/common/helpers/icon';
|
||||
|
||||
import DashboardWidget, { IDashboardWidgetAttrs } from 'flarum/admin/components/DashboardWidget';
|
||||
|
||||
import StatisticsWidgetDateSelectionModal, { IDateSelection, IStatisticsWidgetDateSelectionModalAttrs } from './StatisticsWidgetDateSelectionModal';
|
||||
|
||||
import type Mithril from 'mithril';
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
import dayjsUtc from 'dayjs/plugin/utc';
|
||||
import dayjsLocalizedFormat from 'dayjs/plugin/localizedFormat';
|
||||
// @ts-expect-error No typings available
|
||||
import { Chart } from 'frappe-charts';
|
||||
|
||||
dayjs.extend(dayjsUtc);
|
||||
dayjs.extend(dayjsLocalizedFormat);
|
||||
|
||||
interface IPeriodDeclaration {
|
||||
start: number;
|
||||
end: number;
|
||||
@@ -25,11 +35,23 @@ export default class StatisticsWidget extends DashboardWidget {
|
||||
|
||||
chart: any;
|
||||
|
||||
timedData: any;
|
||||
customPeriod: IDateSelection | null = null;
|
||||
|
||||
timedData: Record<string, undefined | any> = {};
|
||||
lifetimeData: any;
|
||||
customPeriodData: Record<string, undefined | any> = {};
|
||||
|
||||
noData: boolean = false;
|
||||
|
||||
loadingLifetime = true;
|
||||
loadingTimed = true;
|
||||
loadingTimed: Record<string, 'unloaded' | 'loading' | 'loaded' | 'fail'> = this.entities.reduce((acc, curr) => {
|
||||
acc[curr] = 'unloaded';
|
||||
return acc;
|
||||
}, {} as Record<string, 'unloaded' | 'loading' | 'loaded' | 'fail'>);
|
||||
loadingCustom: Record<string, 'unloaded' | 'loading' | 'loaded' | 'fail'> = this.entities.reduce((acc, curr) => {
|
||||
acc[curr] = 'unloaded';
|
||||
return acc;
|
||||
}, {} as Record<string, 'unloaded' | 'loading' | 'loaded' | 'fail'>);
|
||||
|
||||
selectedEntity = 'users';
|
||||
selectedPeriod: undefined | string;
|
||||
@@ -41,7 +63,6 @@ export default class StatisticsWidget extends DashboardWidget {
|
||||
super.oncreate(vnode);
|
||||
|
||||
this.loadLifetimeData();
|
||||
this.loadTimedData();
|
||||
}
|
||||
|
||||
async loadLifetimeData() {
|
||||
@@ -62,49 +83,116 @@ export default class StatisticsWidget extends DashboardWidget {
|
||||
m.redraw();
|
||||
}
|
||||
|
||||
async loadTimedData() {
|
||||
this.loadingTimed = true;
|
||||
async loadTimedData(model: string) {
|
||||
this.loadingTimed[model] = 'loading';
|
||||
m.redraw();
|
||||
|
||||
const data = await app.request({
|
||||
method: 'GET',
|
||||
url: app.forum.attribute('apiUrl') + '/statistics',
|
||||
});
|
||||
try {
|
||||
const data = await app.request({
|
||||
method: 'GET',
|
||||
url: app.forum.attribute('apiUrl') + '/statistics',
|
||||
params: {
|
||||
period: 'timed',
|
||||
model,
|
||||
},
|
||||
});
|
||||
|
||||
this.timedData = data;
|
||||
this.loadingTimed = false;
|
||||
this.timedData[model] = data;
|
||||
this.loadingTimed[model] = 'loaded';
|
||||
|
||||
// Create a Date object which represents the start of the day in the
|
||||
// configured timezone. To do this we convert a UTC time into that timezone,
|
||||
// reset to the first hour of the day, and then convert back into UTC time.
|
||||
// We'll be working with seconds rather than milliseconds throughout too.
|
||||
let todayDate = new Date();
|
||||
todayDate.setTime(todayDate.getTime() + this.timedData.timezoneOffset * 1000);
|
||||
todayDate.setUTCHours(0, 0, 0, 0);
|
||||
todayDate.setTime(todayDate.getTime() - this.timedData.timezoneOffset * 1000);
|
||||
// Create a Date object which represents the start of the day.
|
||||
let todayDate = new Date();
|
||||
todayDate.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
const today = todayDate.getTime() / 1000;
|
||||
const today = todayDate.getTime() / 1000;
|
||||
|
||||
this.periods = {
|
||||
today: { start: today, end: today + 86400, step: 3600 },
|
||||
last_7_days: { start: today - 86400 * 7, end: today, step: 86400 },
|
||||
previous_7_days: { start: today - 86400 * 14, end: today - 86400 * 7, step: 86400 },
|
||||
last_28_days: { start: today - 86400 * 28, end: today, step: 86400 },
|
||||
previous_28_days: { start: today - 86400 * 28 * 2, end: today - 86400 * 28, step: 86400 },
|
||||
last_12_months: { start: today - 86400 * 364, end: today, step: 86400 * 7 },
|
||||
};
|
||||
this.periods = {
|
||||
today: { start: today, end: today + 86400, step: 3600 },
|
||||
last_7_days: { start: today - 86400 * 7, end: today, step: 86400 },
|
||||
previous_7_days: { start: today - 86400 * 14, end: today - 86400 * 7, step: 86400 },
|
||||
last_28_days: { start: today - 86400 * 28, end: today, step: 86400 },
|
||||
previous_28_days: { start: today - 86400 * 28 * 2, end: today - 86400 * 28, step: 86400 },
|
||||
last_12_months: { start: today - 86400 * 364, end: today, step: 86400 * 7 },
|
||||
};
|
||||
|
||||
this.selectedPeriod = 'last_7_days';
|
||||
this.selectedPeriod = 'last_7_days';
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
this.loadingTimed[model] = 'fail';
|
||||
}
|
||||
|
||||
m.redraw();
|
||||
}
|
||||
|
||||
async loadCustomRangeData(model: string): Promise<void> {
|
||||
this.loadingCustom[model] = 'loading';
|
||||
m.redraw();
|
||||
|
||||
// We clone so we can check that the same period is still selected
|
||||
// once the HTTP request is complete and the data is to be displayed
|
||||
const range = { ...this.customPeriod };
|
||||
try {
|
||||
const data = await app.request({
|
||||
method: 'GET',
|
||||
url: app.forum.attribute('apiUrl') + '/statistics',
|
||||
params: {
|
||||
period: 'custom',
|
||||
model,
|
||||
dateRange: {
|
||||
start: range.start,
|
||||
end: range.end,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (JSON.stringify(range) !== JSON.stringify(this.customPeriod)) {
|
||||
// The range this method was called with is no longer the selected.
|
||||
// Bail out here.
|
||||
return;
|
||||
}
|
||||
|
||||
this.customPeriodData[model] = data;
|
||||
this.loadingCustom[model] = 'loaded';
|
||||
|
||||
m.redraw();
|
||||
} catch (e) {
|
||||
if (JSON.stringify(range) !== JSON.stringify(this.customPeriod)) {
|
||||
// The range this method was called with is no longer the selected.
|
||||
// Bail out here.
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(e);
|
||||
this.loadingCustom[model] = 'fail';
|
||||
}
|
||||
}
|
||||
|
||||
className() {
|
||||
return 'StatisticsWidget';
|
||||
}
|
||||
|
||||
content() {
|
||||
const thisPeriod = this.loadingTimed ? null : this.periods![this.selectedPeriod!];
|
||||
const loadingSelectedEntity = (this.selectedPeriod === 'custom' ? this.loadingCustom : this.loadingTimed)[this.selectedEntity] !== 'loaded';
|
||||
|
||||
const thisPeriod = loadingSelectedEntity
|
||||
? null
|
||||
: this.selectedPeriod === 'custom'
|
||||
? {
|
||||
start: this.customPeriod?.end!,
|
||||
end: this.customPeriod?.end!,
|
||||
step: 86400,
|
||||
}
|
||||
: this.periods![this.selectedPeriod!];
|
||||
|
||||
if (this.selectedPeriod === 'custom') {
|
||||
if (!this.customPeriodData[this.selectedEntity] && this.loadingCustom[this.selectedEntity] === 'unloaded') {
|
||||
this.loadCustomRangeData(this.selectedEntity);
|
||||
}
|
||||
} else {
|
||||
if (!this.timedData[this.selectedEntity] && this.loadingTimed[this.selectedEntity] === 'unloaded') {
|
||||
this.loadTimedData(this.selectedEntity);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="StatisticsWidget-table">
|
||||
@@ -112,20 +200,60 @@ export default class StatisticsWidget extends DashboardWidget {
|
||||
<div className="StatisticsWidget-labels">
|
||||
<div className="StatisticsWidget-label">{app.translator.trans('flarum-statistics.admin.statistics.total_label')}</div>
|
||||
<div className="StatisticsWidget-label">
|
||||
{this.loadingTimed ? (
|
||||
{loadingSelectedEntity ? (
|
||||
<LoadingIndicator size="small" display="inline" />
|
||||
) : (
|
||||
<SelectDropdown disabled={this.loadingTimed} buttonClassName="Button Button--text" caretIcon="fas fa-caret-down">
|
||||
{Object.keys(this.periods!).map((period) => (
|
||||
<Button
|
||||
key={period}
|
||||
active={period === this.selectedPeriod}
|
||||
onclick={this.changePeriod.bind(this, period)}
|
||||
icon={period === this.selectedPeriod ? 'fas fa-check' : true}
|
||||
>
|
||||
{app.translator.trans(`flarum-statistics.admin.statistics.${period}_label`)}
|
||||
</Button>
|
||||
))}
|
||||
<SelectDropdown disabled={loadingSelectedEntity} buttonClassName="Button Button--text" caretIcon="fas fa-caret-down">
|
||||
{Object.keys(this.periods!)
|
||||
.map((period) => (
|
||||
<Button
|
||||
key={period}
|
||||
active={period === this.selectedPeriod}
|
||||
onclick={this.changePeriod.bind(this, period)}
|
||||
icon={period === this.selectedPeriod ? 'fas fa-check' : true}
|
||||
>
|
||||
{app.translator.trans(`flarum-statistics.admin.statistics.${period}_label`)}
|
||||
</Button>
|
||||
))
|
||||
.concat([
|
||||
<Button
|
||||
key="custom"
|
||||
active={this.selectedPeriod === 'custom'}
|
||||
onclick={() => {
|
||||
const attrs: IStatisticsWidgetDateSelectionModalAttrs = {
|
||||
onModalSubmit: (dates: IDateSelection) => {
|
||||
if (JSON.stringify(dates) === JSON.stringify(this.customPeriod)) {
|
||||
// If same period is selected, don't reload data
|
||||
return;
|
||||
}
|
||||
|
||||
this.customPeriodData = {};
|
||||
Object.keys(this.loadingCustom).forEach((k) => (this.loadingCustom[k] = 'unloaded'));
|
||||
this.customPeriod = dates;
|
||||
this.changePeriod('custom');
|
||||
},
|
||||
} as any;
|
||||
|
||||
// If we have a custom period set already,
|
||||
// let's prefill the modal with it
|
||||
if (this.customPeriod) {
|
||||
attrs.value = this.customPeriod;
|
||||
}
|
||||
|
||||
app.modal.show(StatisticsWidgetDateSelectionModal as any, attrs as any);
|
||||
}}
|
||||
icon={this.selectedPeriod === 'custom' ? 'fas fa-check' : true}
|
||||
>
|
||||
{this.selectedPeriod === 'custom'
|
||||
? extractText(
|
||||
app.translator.trans(`flarum-statistics.admin.statistics.custom_label_specified`, {
|
||||
fromDate: dayjs.utc(this.customPeriod!.start! * 1000).format('ll'),
|
||||
toDate: dayjs.utc(this.customPeriod!.end! * 1000).format('ll'),
|
||||
})
|
||||
)
|
||||
: app.translator.trans(`flarum-statistics.admin.statistics.custom_label`)}
|
||||
</Button>,
|
||||
])}
|
||||
</SelectDropdown>
|
||||
)}
|
||||
</div>
|
||||
@@ -133,14 +261,17 @@ export default class StatisticsWidget extends DashboardWidget {
|
||||
|
||||
{this.entities.map((entity) => {
|
||||
const totalCount = this.loadingLifetime ? app.translator.trans('flarum-statistics.admin.statistics.loading') : this.getTotalCount(entity);
|
||||
const thisPeriodCount = this.loadingTimed
|
||||
const thisPeriodCount = loadingSelectedEntity
|
||||
? app.translator.trans('flarum-statistics.admin.statistics.loading')
|
||||
: this.getPeriodCount(entity, thisPeriod!);
|
||||
const lastPeriodCount = this.loadingTimed
|
||||
? app.translator.trans('flarum-statistics.admin.statistics.loading')
|
||||
: this.getPeriodCount(entity, this.getLastPeriod(thisPeriod!));
|
||||
const lastPeriodCount =
|
||||
this.selectedPeriod === 'custom'
|
||||
? null
|
||||
: loadingSelectedEntity
|
||||
? app.translator.trans('flarum-statistics.admin.statistics.loading')
|
||||
: this.getPeriodCount(entity, this.getLastPeriod(thisPeriod!));
|
||||
const periodChange =
|
||||
this.loadingTimed || lastPeriodCount === 0
|
||||
loadingSelectedEntity || lastPeriodCount === 0 || lastPeriodCount === null
|
||||
? 0
|
||||
: (((thisPeriodCount as number) - (lastPeriodCount as number)) / (lastPeriodCount as number)) * 100;
|
||||
|
||||
@@ -154,7 +285,7 @@ export default class StatisticsWidget extends DashboardWidget {
|
||||
{this.loadingLifetime ? <LoadingIndicator display="inline" /> : abbreviateNumber(totalCount as number)}
|
||||
</div>
|
||||
<div className="StatisticsWidget-period" title={thisPeriodCount}>
|
||||
{this.loadingTimed ? <LoadingIndicator display="inline" /> : abbreviateNumber(thisPeriodCount as number)}
|
||||
{loadingSelectedEntity ? <LoadingIndicator display="inline" /> : abbreviateNumber(thisPeriodCount as number)}
|
||||
{periodChange !== 0 && (
|
||||
<>
|
||||
{' '}
|
||||
@@ -170,12 +301,34 @@ export default class StatisticsWidget extends DashboardWidget {
|
||||
})}
|
||||
</div>
|
||||
|
||||
{this.loadingTimed ? (
|
||||
<div className="StatisticsWidget-chart">
|
||||
<LoadingIndicator size="large" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="StatisticsWidget-chart" oncreate={this.drawChart.bind(this)} onupdate={this.drawChart.bind(this)} />
|
||||
<>
|
||||
{loadingSelectedEntity ? (
|
||||
<div key="loading" className="StatisticsWidget-chart" data-loading="true">
|
||||
<LoadingIndicator size="large" />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
key="loaded"
|
||||
className="StatisticsWidget-chart"
|
||||
data-loading="false"
|
||||
oncreate={this.drawChart.bind(this)}
|
||||
onupdate={this.drawChart.bind(this)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
{this.noData && <Placeholder text={app.translator.trans(`flarum-statistics.admin.statistics.no_data`)} />}
|
||||
|
||||
{!this.noData && !!this.chart && (
|
||||
<Button
|
||||
className="StatisticsWidget-chartExport Button"
|
||||
icon="fas fa-file-export"
|
||||
onclick={() => {
|
||||
this.chart.export();
|
||||
}}
|
||||
>
|
||||
{app.translator.trans('flarum-statistics.admin.statistics.export_chart_button')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -186,10 +339,16 @@ export default class StatisticsWidget extends DashboardWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
const offset = this.timedData.timezoneOffset;
|
||||
const period = this.periods![this.selectedPeriod!];
|
||||
const period =
|
||||
this.selectedPeriod === 'custom'
|
||||
? {
|
||||
start: this.customPeriod?.start!,
|
||||
end: this.customPeriod?.end!,
|
||||
step: 86400,
|
||||
}
|
||||
: this.periods![this.selectedPeriod!];
|
||||
const periodLength = period.end - period.start;
|
||||
const labels = [];
|
||||
const labels: string[] = [];
|
||||
const thisPeriod = [];
|
||||
const lastPeriod = [];
|
||||
|
||||
@@ -197,29 +356,53 @@ export default class StatisticsWidget extends DashboardWidget {
|
||||
let label;
|
||||
|
||||
if (period.step < 86400) {
|
||||
label = dayjs.unix(i + offset).format('h A');
|
||||
label = dayjs.unix(i).utc().format('h A');
|
||||
} else {
|
||||
label = dayjs.unix(i + offset).format('D MMM');
|
||||
label = dayjs.unix(i).utc().format('D MMM');
|
||||
|
||||
if (period.step > 86400) {
|
||||
label += ' - ' + dayjs.unix(i + offset + period.step - 1).format('D MMM');
|
||||
label +=
|
||||
' - ' +
|
||||
dayjs
|
||||
.unix(i + period.step - 1)
|
||||
.utc()
|
||||
.format('D MMM');
|
||||
}
|
||||
}
|
||||
|
||||
labels.push(label);
|
||||
|
||||
thisPeriod.push(this.getPeriodCount(this.selectedEntity, { start: i, end: i + period.step }));
|
||||
|
||||
lastPeriod.push(this.getPeriodCount(this.selectedEntity, { start: i - periodLength, end: i - periodLength + period.step }));
|
||||
lastPeriod.push(this.getPeriodCount(this.selectedEntity, { start: i - periodLength, end: i - periodLength }));
|
||||
}
|
||||
|
||||
const datasets = [{ values: lastPeriod }, { values: thisPeriod }];
|
||||
if (thisPeriod.length === 0) {
|
||||
this.noData = true;
|
||||
m.redraw();
|
||||
return;
|
||||
} else {
|
||||
this.noData = false;
|
||||
m.redraw();
|
||||
}
|
||||
|
||||
const datasets = [
|
||||
{
|
||||
name: extractText(app.translator.trans('flarum-statistics.admin.statistics.current_period')),
|
||||
values: thisPeriod,
|
||||
},
|
||||
{
|
||||
name: extractText(app.translator.trans('flarum-statistics.admin.statistics.previous_period')),
|
||||
values: lastPeriod,
|
||||
},
|
||||
];
|
||||
const data = {
|
||||
labels,
|
||||
datasets,
|
||||
};
|
||||
|
||||
if (!this.chart) {
|
||||
// If the dom element no longer exists, recreate the chart
|
||||
// https://stackoverflow.com/a/2620373/11091039
|
||||
if (!this.chart || !(document.compareDocumentPosition(this.chart.parent) & 16)) {
|
||||
this.chart = new Chart(vnode.dom, {
|
||||
data,
|
||||
type: 'line',
|
||||
@@ -231,8 +414,9 @@ export default class StatisticsWidget extends DashboardWidget {
|
||||
},
|
||||
lineOptions: {
|
||||
hideDots: 1,
|
||||
regionFill: 1,
|
||||
},
|
||||
colors: ['black', app.forum.attribute('themePrimaryColor')],
|
||||
colors: [app.forum.attribute('themePrimaryColor'), 'black'],
|
||||
});
|
||||
} else {
|
||||
this.chart.update(data);
|
||||
@@ -255,7 +439,7 @@ export default class StatisticsWidget extends DashboardWidget {
|
||||
}
|
||||
|
||||
getPeriodCount(entity: string, period: { start: number; end: number }) {
|
||||
const timed: Record<string, number> = this.timedData[entity];
|
||||
const timed: Record<string, number> = (this.selectedPeriod === 'custom' ? this.customPeriodData : this.timedData)[entity];
|
||||
let count = 0;
|
||||
|
||||
for (const t in timed) {
|
||||
|
@@ -0,0 +1,161 @@
|
||||
import app from 'flarum/admin/app';
|
||||
import ItemList from 'flarum/common/utils/ItemList';
|
||||
import generateElementId from 'flarum/admin/utils/generateElementId';
|
||||
import Modal, { IInternalModalAttrs } from 'flarum/common/components/Modal';
|
||||
|
||||
import Mithril from 'mithril';
|
||||
import Button from 'flarum/common/components/Button';
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
import dayjsUtc from 'dayjs/plugin/utc';
|
||||
|
||||
dayjs.extend(dayjsUtc);
|
||||
|
||||
export interface IDateSelection {
|
||||
/**
|
||||
* Timestamp (seconds, not ms) for start date
|
||||
*/
|
||||
start: number;
|
||||
/**
|
||||
* Timestamp (seconds, not ms) for end date
|
||||
*/
|
||||
end: number;
|
||||
}
|
||||
|
||||
export interface IStatisticsWidgetDateSelectionModalAttrs extends IInternalModalAttrs {
|
||||
onModalSubmit: (dates: IDateSelection) => void;
|
||||
value?: IDateSelection;
|
||||
}
|
||||
|
||||
interface IStatisticsWidgetDateSelectionModalState {
|
||||
inputs: {
|
||||
startDateVal: string;
|
||||
endDateVal: string;
|
||||
};
|
||||
ids: {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default class StatisticsWidgetDateSelectionModal extends Modal<IStatisticsWidgetDateSelectionModalAttrs> {
|
||||
/* @ts-expect-error core typings don't allow us to set the type of the state attr :( */
|
||||
state: IStatisticsWidgetDateSelectionModalState = {
|
||||
inputs: {
|
||||
startDateVal: dayjs().format('YYYY-MM-DD'),
|
||||
endDateVal: dayjs().format('YYYY-MM-DD'),
|
||||
},
|
||||
ids: {
|
||||
startDate: generateElementId(),
|
||||
endDate: generateElementId(),
|
||||
},
|
||||
};
|
||||
|
||||
oninit(vnode: Mithril.Vnode<IStatisticsWidgetDateSelectionModalAttrs, this>) {
|
||||
super.oninit(vnode);
|
||||
|
||||
if (this.attrs.value) {
|
||||
this.state.inputs = {
|
||||
startDateVal: dayjs.utc(this.attrs.value.start * 1000).format('YYYY-MM-DD'),
|
||||
endDateVal: dayjs.utc(this.attrs.value.end * 1000).format('YYYY-MM-DD'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
className(): string {
|
||||
return 'StatisticsWidgetDateSelectionModal Modal--small';
|
||||
}
|
||||
|
||||
title(): Mithril.Children {
|
||||
return app.translator.trans('flarum-statistics.admin.date_selection_modal.title');
|
||||
}
|
||||
|
||||
content(): Mithril.Children {
|
||||
return <div class="Modal-body">{this.items().toArray()}</div>;
|
||||
}
|
||||
|
||||
items(): ItemList<Mithril.Children> {
|
||||
const items = new ItemList<Mithril.Children>();
|
||||
|
||||
items.add('intro', <p>{app.translator.trans('flarum-statistics.admin.date_selection_modal.description')}</p>, 100);
|
||||
|
||||
items.add(
|
||||
'date_start',
|
||||
<div class="Form-group">
|
||||
<label htmlFor={this.state.ids.startDate}>{app.translator.trans('flarum-statistics.admin.date_selection_modal.start_date')}</label>
|
||||
<input
|
||||
type="date"
|
||||
id={this.state.ids.startDate}
|
||||
value={this.state.inputs.startDateVal}
|
||||
onchange={this.updateState('startDateVal')}
|
||||
className="FormControl"
|
||||
/>
|
||||
</div>,
|
||||
90
|
||||
);
|
||||
|
||||
items.add(
|
||||
'date_end',
|
||||
<div class="Form-group">
|
||||
<label htmlFor={this.state.ids.endDate}>{app.translator.trans('flarum-statistics.admin.date_selection_modal.end_date')}</label>
|
||||
<input
|
||||
type="date"
|
||||
id={this.state.ids.endDate}
|
||||
value={this.state.inputs.endDateVal}
|
||||
onchange={this.updateState('endDateVal')}
|
||||
className="FormControl"
|
||||
/>
|
||||
</div>,
|
||||
80
|
||||
);
|
||||
|
||||
items.add(
|
||||
'submit',
|
||||
<Button class="Button Button--primary" type="submit">
|
||||
{app.translator.trans('flarum-statistics.admin.date_selection_modal.submit_button')}
|
||||
</Button>,
|
||||
0
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
updateState(field: keyof IStatisticsWidgetDateSelectionModalState['inputs']): (e: InputEvent) => void {
|
||||
return (e: InputEvent) => {
|
||||
this.state.inputs[field] = (e.currentTarget as HTMLInputElement).value;
|
||||
};
|
||||
}
|
||||
|
||||
submitData(): IDateSelection {
|
||||
// We force 'zulu' time (UTC)
|
||||
return {
|
||||
start: Math.floor(+dayjs.utc(this.state.inputs.startDateVal + 'Z') / 1000),
|
||||
// Ensures that the end date is the end of the day
|
||||
end: Math.floor(
|
||||
+dayjs
|
||||
.utc(this.state.inputs.endDateVal + 'Z')
|
||||
.hour(23)
|
||||
.minute(59)
|
||||
.second(59)
|
||||
.millisecond(999) / 1000
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
onsubmit(e: SubmitEvent): void {
|
||||
e.preventDefault();
|
||||
|
||||
const data = this.submitData();
|
||||
|
||||
if (data.end < data.start) {
|
||||
this.alertAttrs = {
|
||||
type: 'error',
|
||||
controls: app.translator.trans('flarum-statistics.admin.date_selection_modal.errors.end_before_start'),
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
this.attrs.onModalSubmit(data);
|
||||
this.hide();
|
||||
}
|
||||
}
|
@@ -93,22 +93,29 @@
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
.dataset-0 {
|
||||
.dataset-1 {
|
||||
opacity: 0.2;
|
||||
}
|
||||
.chart-legend {
|
||||
display: none;
|
||||
}
|
||||
// Hide the "last period" data from the tooltip
|
||||
.graph-svg-tip ul.data-point-list > li:first-child {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&-viewFull {
|
||||
padding: 12px 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.Placeholder {
|
||||
padding-bottom: 32px;
|
||||
}
|
||||
|
||||
&-chartExport {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin: 16px;
|
||||
margin-top: -32px;
|
||||
}
|
||||
}
|
||||
|
||||
/*!
|
||||
@@ -119,9 +126,9 @@
|
||||
position: relative; /* for absolutely positioned tooltip */
|
||||
|
||||
/* https://www.smashingmagazine.com/2015/11/using-system-ui-fonts-practical-guide/ */
|
||||
font-family: -apple-system, BlinkMacSystemFont,
|
||||
'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell',
|
||||
'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
|
||||
"Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
|
||||
sans-serif;
|
||||
|
||||
.axis,
|
||||
.chart-label {
|
||||
@@ -194,6 +201,10 @@
|
||||
min-width: 90px;
|
||||
flex: 1;
|
||||
font-weight: 600;
|
||||
|
||||
&:nth-child(2) {
|
||||
border-top-color: #5a5a5a !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
strong {
|
||||
|
@@ -1,24 +1,40 @@
|
||||
flarum-statistics:
|
||||
|
||||
##
|
||||
# UNIQUE KEYS - The following keys are used in only one location each.
|
||||
##
|
||||
|
||||
# Translations in this namespace are used by the admin interface.
|
||||
admin:
|
||||
# These translations are used in the date selection modal.
|
||||
date_selection_modal:
|
||||
description: |
|
||||
Pick a custom date range to display statistics for. Loading data may take
|
||||
multiple minutes on forums with a lot of activity.
|
||||
end_date: End date (inclusive)
|
||||
errors:
|
||||
end_before_start: The end date must be after the start date.
|
||||
start_date: Start date (inclusive)
|
||||
submit_button: Confirm date range
|
||||
title: Choose custom date range
|
||||
|
||||
# These translations are used in the Statistics dashboard widget.
|
||||
statistics:
|
||||
discussions_heading: => core.ref.discussions
|
||||
export_chart_button: Export chart to SVG
|
||||
last_12_months_label: Last 12 months
|
||||
last_28_days_label: Last 28 days
|
||||
last_7_days_label: Last 7 days
|
||||
mini_heading: Forum statistics
|
||||
previous_28_days_label: Previous 28 days
|
||||
previous_7_days_label: Previous 7 days
|
||||
custom_label: Choose custom range...
|
||||
custom_label_specified: "{fromDate} to {toDate}"
|
||||
loading: => core.ref.loading
|
||||
posts_heading: => core.ref.posts
|
||||
today_label: Today
|
||||
total_label: Total
|
||||
users_heading: => core.ref.users
|
||||
view_full: View more statistics
|
||||
no_data: There is no data available for this date range.
|
||||
current_period: Current period
|
||||
previous_period: Previous period
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user