1
0
mirror of https://github.com/flarum/core.git synced 2025-08-16 13:24:11 +02:00

Compare commits

..

2 Commits

Author SHA1 Message Date
Sami Mazouz
ad47ac3266 test: setup integration tests for current expected behavior
Signed-off-by: Sami Mazouz <ilyasmazouz@gmail.com>
2022-08-21 23:34:34 +01:00
Ian Morland
121e4d3c0e fix: add viewPrivate visibility scope 2022-08-12 18:11:42 +02:00
389 changed files with 11926 additions and 5231 deletions

View File

@@ -20,6 +20,3 @@ indent_size = 4
[tsconfig.json]
indent_size = 2
[*.neon]
indent_style = tab

1
.github/CODEOWNERS vendored
View File

@@ -1 +0,0 @@
* @flarum/core

View File

@@ -9,12 +9,6 @@ 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
@@ -25,19 +19,12 @@ on:
description: Versions of PHP to test with. Should be array of strings encoded as JSON array
type: string
required: false
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'
default: '["7.4", "8.0", "8.1"]'
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", "mysql:8.0.30", "mariadb"]'
default: '["mysql:5.7", "mariadb"]'
php_ini_values:
description: PHP ini values
@@ -57,41 +44,23 @@ jobs:
matrix:
php: ${{ fromJSON(inputs.php_versions) }}
service: ${{ fromJSON(inputs.db_versions) }}
prefix: ['']
prefix: ['', flarum_]
# 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 5.7
- service: 'mysql:8.0.30'
db: MySQL 8.0
db: MySQL
- service: mariadb
db: MariaDB
# 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_
- prefix: flarum_
prefixStr: (prefix)
# To reduce number of actions, we exclude some PHP versions from running with some DB versions.
exclude:
- php: ${{ fromJSON(inputs.php_versions)[1] }}
service: 'mysql:8.0.30'
- php: ${{ fromJSON(inputs.php_versions)[2] }}
service: 'mysql:8.0.30'
- php: 8.0
service: 'mysql:5.7'
prefix: flarum_
- php: 8.0
service: mariadb
prefix: flarum_
services:
mysql:
@@ -101,9 +70,7 @@ jobs:
name: 'PHP ${{ matrix.php }} / ${{ matrix.db }} ${{ matrix.prefixStr }}'
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')
if: inputs.enable_backend_testing
steps:
- uses: actions/checkout@master
@@ -113,7 +80,7 @@ jobs:
with:
php-version: ${{ matrix.php }}
coverage: xdebug
extensions: ${{ inputs.php_extensions }}
extensions: curl, dom, gd, json, mbstring, openssl, pdo_mysql, tokenizer, zip
tools: phpunit, composer:v2
ini-values: ${{ inputs.php_ini_values }}
@@ -143,35 +110,3 @@ 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

View File

@@ -91,9 +91,6 @@ 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

View File

@@ -1,12 +0,0 @@
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: .

View File

@@ -1,131 +1,5 @@
# Changelog
## [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

View File

@@ -140,8 +140,8 @@
"require-dev": {
"mockery/mockery": "^1.4",
"phpunit/phpunit": "^9.0",
"phpstan/phpstan": ">=1.8.11 < 1.9.0",
"nunomaduro/larastan": "^1.0"
"phpstan/phpstan-php-parser": "^1.0",
"phpstan/phpstan": "^1.2"
},
"config": {
"sort-packages": true
@@ -178,11 +178,5 @@
"extension.neon"
]
}
},
"scripts": {
"analyse:phpstan": "phpstan analyse"
},
"scripts-descriptions": {
"analyse:phpstan": "Run static analysis"
}
}

View File

@@ -19,7 +19,7 @@
}
],
"require": {
"flarum/core": "^1.6",
"flarum/core": "^1.4",
"flarum/approval": "^1.2"
},
"autoload": {

View File

@@ -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() ?? 'unknown',
$extensions->getExtension('flarum-akismet')->getVersion(),
$config->inDebugMode()
);
});

View File

@@ -19,7 +19,7 @@
}
],
"require": {
"flarum/core": "^1.6",
"flarum/core": "^1.4",
"flarum/flags": "^1.2"
},
"autoload": {

View File

@@ -10,7 +10,6 @@
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;
@@ -49,7 +48,6 @@ return [
new Extend\Locales(__DIR__.'/locale'),
(new Extend\Event())
->listen(PostWasApproved::class, Listener\UpdateDiscussionAfterPostApproval::class)
->subscribe(Listener\ApproveContent::class)
->subscribe(Listener\UnapproveNewContent::class),

View File

@@ -21,8 +21,12 @@ 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'];
@@ -42,4 +46,30 @@ 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();
}
}

View File

@@ -1,40 +0,0 @@
<?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();
}
}
}

View File

@@ -28,8 +28,11 @@ 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' => 4, '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' => 2, '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],
@@ -37,39 +40,26 @@ 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' => 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' => 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' => 4, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 1, 'is_approved' => 0, 'number' => 3],
['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' => 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' => 4, 'name_singular' => 'Acme', 'name_plural' => 'Acme', 'is_hidden' => 0]
['id' => 100, 'name_singular' => 'Acme', 'name_plural' => 'Acme', 'is_hidden' => 0]
],
'group_user' => [
['user_id' => 3, 'group_id' => 4]
['user_id' => 3, 'group_id' => 100]
],
'group_permission' => [
['permission' => 'discussion.approvePosts', 'group_id' => 4]
['permission' => 'discussion.approvePosts', 'group_id' => 100]
]
]);
}
/**
* 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]];
}
}

View File

@@ -29,10 +29,10 @@ class ListDiscussionsTest extends TestCase
}
/**
* @dataProvider unallowedUsers
* @dataProvider userVisibleDiscussionsDataProvider
* @test
*/
public function can_only_see_approved_if_not_allowed_to_approve(?int $authenticatedAs)
public function can_only_see_approved_if_allowed(?int $authenticatedAs, array $visibleDiscussionIds)
{
$response = $this->send(
$this->request('GET', '/api/discussions', compact('authenticatedAs'))
@@ -41,22 +41,17 @@ class ListDiscussionsTest extends TestCase
$body = json_decode($response->getBody()->getContents(), true);
$this->assertEquals(200, $response->getStatusCode());
$this->assertEqualsCanonicalizing([1, 4, 5, 7], Arr::pluck($body['data'], 'id'));
$this->assertEqualsCanonicalizing($visibleDiscussionIds, Arr::pluck($body['data'], 'id'));
}
/**
* @dataProvider allowedUsers
* @test
*/
public function can_see_unapproved_if_allowed_to_approve(int $authenticatedAs)
public function userVisibleDiscussionsDataProvider(): array
{
$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'));
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]],
];
}
}

View File

@@ -29,10 +29,10 @@ class ListPostsTest extends TestCase
}
/**
* @dataProvider unallowedUsers
* @dataProvider userVisiblePostsDataProvider
* @test
*/
public function can_only_see_approved_if_not_allowed_to_approve(?int $authenticatedAs)
public function can_only_see_approved_if_allowed(?int $authenticatedAs, array $visiblePostIds)
{
$response = $this->send(
$this
@@ -47,28 +47,22 @@ class ListPostsTest extends TestCase
$body = json_decode($response->getBody()->getContents(), true);
$this->assertEquals(200, $response->getStatusCode());
$this->assertEqualsCanonicalizing([7, 8, 10], Arr::pluck($body['data'], 'id'));
$this->assertEqualsCanonicalizing($visiblePostIds, Arr::pluck($body['data'], 'id'));
}
/**
* @dataProvider allowedUsers
* @test
*/
public function can_see_unapproved_if_allowed_to_approve(int $authenticatedAs)
public function userVisiblePostsDataProvider(): array
{
$response = $this->send(
$this
->request('GET', '/api/posts', compact('authenticatedAs'))
->withQueryParams([
'filter' => [
'discussion' => 7
]
])
);
return [
// Admin can view unapproved posts.
[1, [7, 8, 9, 10, 11, 12]],
$body = json_decode($response->getBody()->getContents(), true);
// User with approval perms can view unapproved posts.
[3, [7, 8, 9, 10, 11, 12]],
$this->assertEquals(200, $response->getStatusCode());
$this->assertEqualsCanonicalizing([7, 8, 9, 10, 11], Arr::pluck($body['data'], 'id'));
// 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]],
];
}
}

View File

@@ -19,7 +19,7 @@
}
],
"require": {
"flarum/core": "^1.6"
"flarum/core": "^1.4"
},
"extra": {
"branch-alias": {

View File

@@ -19,7 +19,7 @@
}
],
"require": {
"flarum/core": "^1.6"
"flarum/core": "^1.4"
},
"autoload": {
"psr-4": {

View File

@@ -19,7 +19,7 @@
}
],
"require": {
"flarum/core": "^1.6"
"flarum/core": "^1.4"
},
"extra": {
"branch-alias": {

View File

@@ -19,7 +19,7 @@
}
],
"require": {
"flarum/core": "^1.6"
"flarum/core": "^1.4"
},
"autoload": {
"psr-4": {

View File

@@ -7,7 +7,7 @@
],
"license": "MIT",
"require": {
"flarum/core": "^1.6"
"flarum/core": "^1.4"
},
"extra": {
"branch-alias": {

View File

@@ -19,7 +19,7 @@
}
],
"require": {
"flarum/core": "^1.6"
"flarum/core": "^1.4"
},
"autoload": {
"psr-4": {

View File

@@ -19,7 +19,7 @@
}
],
"require": {
"flarum/core": "^1.6"
"flarum/core": "^1.4"
},
"autoload": {
"psr-4": {

View File

@@ -19,7 +19,7 @@
}
],
"require": {
"flarum/core": "^1.6"
"flarum/core": "^1.4"
},
"extra": {
"branch-alias": {

View File

@@ -19,7 +19,7 @@
}
],
"require": {
"flarum/core": "^1.6"
"flarum/core": "^1.4"
},
"autoload": {
"psr-4": {

View File

@@ -12,11 +12,10 @@ 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\Group\Group;
use Flarum\Mentions\Notification\PostMentionedBlueprint;
use Flarum\Mentions\Notification\UserMentionedBlueprint;
use Flarum\Post\Event\Deleted;
use Flarum\Post\Event\Hidden;
use Flarum\Post\Event\Posted;
@@ -38,16 +37,13 @@ return [
->configure(ConfigureMentions::class)
->render(Formatter\FormatPostMentions::class)
->render(Formatter\FormatUserMentions::class)
->render(Formatter\FormatGroupMentions::class)
->unparse(Formatter\UnparsePostMentions::class)
->unparse(Formatter\UnparseUserMentions::class)
->parse(Formatter\CheckPermissions::class),
->unparse(Formatter\UnparseUserMentions::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('mentionsGroups', Group::class, 'post_mentions_group', 'post_id', 'mentions_group_id'),
->belongsToMany('mentionsUsers', User::class, 'post_mentions_user', 'post_id', 'mentions_user_id'),
new Extend\Locales(__DIR__.'/locale'),
@@ -55,28 +51,25 @@ return [
->namespace('flarum-mentions', __DIR__.'/views'),
(new Extend\Notification())
->type(Notification\PostMentionedBlueprint::class, PostSerializer::class, ['alert'])
->type(Notification\UserMentionedBlueprint::class, PostSerializer::class, ['alert'])
->type(Notification\GroupMentionedBlueprint::class, PostSerializer::class, ['alert']),
->type(PostMentionedBlueprint::class, PostSerializer::class, ['alert'])
->type(UserMentionedBlueprint::class, PostSerializer::class, ['alert']),
(new Extend\ApiSerializer(BasicPostSerializer::class))
->hasMany('mentionedBy', BasicPostSerializer::class)
->hasMany('mentionsPosts', BasicPostSerializer::class)
->hasMany('mentionsUsers', BasicUserSerializer::class)
->hasMany('mentionsGroups', GroupSerializer::class),
->hasMany('mentionsUsers', BasicUserSerializer::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', 'firstPost.mentionsGroups',
'lastPost.mentionsUsers', 'lastPost.mentionsPosts', 'lastPost.mentionsPosts.user', 'lastPost.mentionsGroups'
'firstPost.mentionsUsers', 'firstPost.mentionsPosts', 'firstPost.mentionsPosts.user',
'lastPost.mentionsUsers', 'lastPost.mentionsPosts', 'lastPost.mentionsPosts.user'
]),
(new Extend\ApiController(Controller\ShowPostController::class))
@@ -87,16 +80,13 @@ 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'),
->addInclude(['mentionsPosts', 'mentionsPosts.mentionedBy']),
(new Extend\ApiController(Controller\UpdatePostController::class))
->addInclude(['mentionsPosts', 'mentionsPosts.mentionedBy'])
->addOptionalInclude('mentionsGroups'),
->addInclude(['mentionsPosts', 'mentionsPosts.mentionedBy']),
(new Extend\ApiController(Controller\AbstractSerializeController::class))
->prepareDataForSerialization(FilterVisiblePosts::class),
@@ -113,9 +103,4 @@ 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');
})
];

View File

@@ -1,2 +1,2 @@
(()=>{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})();
(()=>{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})();
//# sourceMappingURL=admin.js.map

View File

@@ -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,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":""}
{"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":""}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,20 +1,10 @@
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'),
})
.registerPermission(
{
permission: 'mentionGroups',
label: app.translator.trans('flarum-mentions.admin.permissions.mention_groups_label'),
icon: 'fas fa-at',
},
'start'
);
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'),
});
});

View File

@@ -10,8 +10,6 @@ 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';
@@ -31,7 +29,6 @@ const throttledSearch = throttle(
buildSuggestions();
});
searched.push(typedLower);
}
}
@@ -69,13 +66,6 @@ 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 + ' ');
@@ -134,41 +124,12 @@ 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 = [];
@@ -180,15 +141,6 @@ 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

View File

@@ -1,4 +1,3 @@
import GroupMentionedNotification from './components/GroupMentionedNotification';
import MentionsUserPage from './components/MentionsUserPage';
import PostMentionedNotification from './components/PostMentionedNotification';
import UserMentionedNotification from './components/UserMentionedNotification';
@@ -14,7 +13,6 @@ 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,

View File

@@ -1,25 +0,0 @@
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);
}
}

View File

@@ -10,16 +10,11 @@ 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();
@@ -41,7 +36,6 @@ 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) {
@@ -56,12 +50,6 @@ 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

View File

@@ -1,7 +1,7 @@
import getCleanDisplayName, { shouldUseOldFormat } from './getCleanDisplayName';
/**
* Fetches the mention text for a specified user (and optionally a post ID for replies, or group).
* Fetches the mention text for a specified user (and optionally a post ID for replies).
*
* Automatically determines which mention syntax to be used based on the option in the
* admin dashboard. Also performs display name clean-up automatically.
@@ -17,13 +17,9 @@ 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, group) {
if (user !== undefined && postId === undefined) {
export default function getMentionText(user, postId) {
if (postId === undefined) {
if (shouldUseOldFormat()) {
// Plain @username
const cleanText = getCleanDisplayName(user, false);
@@ -32,14 +28,9 @@ export default function getMentionText(user, postId, group) {
// @"Display name"#UserID
const cleanText = getCleanDisplayName(user);
return `@"${cleanText}"#${user.id()}`;
} else if (user !== undefined && postId !== undefined) {
} else {
// @"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';
}
}

View File

@@ -4,7 +4,7 @@
export default function selectedText(body) {
const selection = window.getSelection();
if (!selection.isCollapsed) {
if (selection?.rangeCount) {
const range = selection.getRangeAt(0);
const parent = range.commonAncestorContainer;

View File

@@ -1,7 +1,6 @@
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;
@@ -32,20 +31,3 @@ 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();
}

View File

@@ -1,4 +1,4 @@
.PostMention, .UserMention, .GroupMention {
.PostMention, .UserMention {
background: @control-bg;
color: @control-color;
border-radius: @border-radius;
@@ -14,7 +14,7 @@
color: @link-color;
}
}
.UserMention, .PostMention, .GroupMention {
.UserMention, .PostMention {
&--deleted {
opacity: 0.8;
filter: grayscale(1);
@@ -97,45 +97,6 @@
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 {

View File

@@ -7,9 +7,6 @@ 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)
@@ -22,7 +19,7 @@ flarum-mentions:
# These translations are used by the composer (reply autocompletion function).
composer:
mention_tooltip: Mention a user, group or post
mention_tooltip: Mention a user or post
reply_to_post_text: "Reply to #{number}"
# These translations are used by the Notifications dropdown, a.k.a. "the bell".
@@ -30,7 +27,6 @@ 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:
@@ -45,7 +41,6 @@ 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:
@@ -55,9 +50,6 @@ 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:
@@ -88,16 +80,4 @@ 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}

View File

@@ -1,29 +0,0 @@
<?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');
}
];

View File

@@ -9,12 +9,10 @@
namespace Flarum\Mentions;
use Flarum\Group\Group;
use Flarum\Http\UrlGenerator;
use Flarum\Post\CommentPost;
use Flarum\Settings\SettingsRepositoryInterface;
use Flarum\User\User;
use Illuminate\Support\Str;
use s9e\TextFormatter\Configurator;
class ConfigureMentions
@@ -36,7 +34,6 @@ class ConfigureMentions
{
$this->configureUserMentions($config);
$this->configurePostMentions($config);
$this->configureGroupMentions($config);
}
private function configureUserMentions(Configurator $config)
@@ -139,80 +136,4 @@ 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;
}
}

View File

@@ -54,8 +54,8 @@ class FilterVisiblePosts
|| $controller instanceof Controller\CreatePostController
|| $controller instanceof Controller\UpdatePostController) {
$relations = [
'mentionsUsers', 'mentionsPosts', 'mentionsPosts.user', 'mentionedBy', 'mentionsGroups',
'mentionedBy.mentionsPosts', 'mentionedBy.mentionsPosts.user', 'mentionedBy.mentionsUsers', 'mentionedBy.mentionsGroups.group'
'mentionsUsers', 'mentionsPosts', 'mentionsPosts.user', 'mentionedBy',
'mentionedBy.mentionsPosts', 'mentionedBy.mentionsPosts.user', 'mentionedBy.mentionsUsers'
];
$posts = [$data];

View File

@@ -1,26 +0,0 @@
<?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;
}
}

View File

@@ -1,59 +0,0 @@
<?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;
});
}
}

View File

@@ -40,8 +40,5 @@ class UpdateMentionsMetadataWhenInvisible
// Remove post mentions
$event->post->mentionsPosts()->sync([]);
// Remove group mentions
$event->post->mentionsGroups()->sync([]);
}
}

View File

@@ -9,7 +9,6 @@
namespace Flarum\Mentions\Listener;
use Flarum\Mentions\Notification\GroupMentionedBlueprint;
use Flarum\Mentions\Notification\PostMentionedBlueprint;
use Flarum\Mentions\Notification\UserMentionedBlueprint;
use Flarum\Notification\NotificationSyncer;
@@ -51,11 +50,6 @@ 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)
@@ -66,7 +60,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();
@@ -81,8 +75,8 @@ class UpdateMentionsMetadataWhenVisible
$posts = Post::with('user')
->whereIn('id', $mentioned)
->get()
->filter(function (Post $post) use ($reply) {
return $post->user && $post->user_id !== $reply->user_id && $reply->isVisibleTo($post->user);
->filter(function ($post) use ($reply) {
return $post->user && $post->user->id !== $reply->user_id && $reply->isVisibleTo($post->user);
})
->all();
@@ -90,21 +84,4 @@ 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);
}
}

View File

@@ -1,89 +0,0 @@
<?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;
}
}

View File

@@ -1,420 +0,0 @@
<?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));
}
}

View File

@@ -47,14 +47,12 @@ 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 &quot;#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 &quot;#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>'],
],
'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]
],
]);
@@ -419,90 +417,6 @@ 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

View File

@@ -44,11 +44,10 @@ 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 &quot;#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 &quot;#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]
],
]);
@@ -439,62 +438,6 @@ 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

View File

@@ -1,7 +0,0 @@
{!! $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
]) !!}

View File

@@ -19,7 +19,7 @@
}
],
"require": {
"flarum/core": "^1.6"
"flarum/core": "^1.4"
},
"autoload": {
"psr-4": {

View File

@@ -22,7 +22,7 @@
"source": "https://github.com/flarum/package-manager"
},
"require": {
"flarum/core": "^1.5.0",
"flarum/core": "^1.0.0",
"composer/composer": "^2.3"
},
"require-dev": {

View File

@@ -41,8 +41,7 @@ return [
$paths = resolve(Paths::class);
$document->payload['flarum-package-manager.writable_dirs'] = is_writable($paths->vendor)
&& is_writable($paths->storage)
&& (! file_exists($paths->storage.'/.composer') || is_writable($paths->storage.'/.composer'))
&& is_writable($paths->storage.'/.composer')
&& is_writable($paths->base.'/composer.json')
&& is_writable($paths->base.'/composer.lock');

View File

@@ -1,7 +1,5 @@
/// <reference types="mithril" />
import Component from 'flarum/common/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;
export default class ControlSection extends Component {
view(): JSX.Element;
}

View File

@@ -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 '../states/ControlSectionState';
import { UpdatedPackage } from './Updater';
export interface ExtensionItemAttrs extends ComponentAttrs {
extension: Extension;
updates: UpdatedPackage;

View File

@@ -1,13 +1,14 @@
import type Mithril from 'mithril';
import Component, { ComponentAttrs } from 'flarum/common/Component';
import Stream from 'flarum/common/utils/Stream';
export interface InstallerAttrs extends ComponentAttrs {
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 {};

View File

@@ -1,14 +1,15 @@
import type Mithril from 'mithril';
import Component, { ComponentAttrs } from 'flarum/common/Component';
import { UpdatedPackage, UpdateState } from '../states/ControlSectionState';
export interface MajorUpdaterAttrs extends ComponentAttrs {
import { UpdatedPackage, UpdateState } from './Updater';
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(): Mithril.Children;
view(vnode: Mithril.Vnode<T, this>): Mithril.Children;
update(dryRun: boolean): void;
}
export {};

View File

@@ -1,5 +1,5 @@
/// <reference types="mithril" />
/// <reference types="@flarum/core/dist-typings/@types/translator-icu-rich" />
/// <reference types="flarum/@types/translator-icu-rich" />
import Modal, { IInternalModalAttrs } from 'flarum/common/components/Modal';
import Task from '../models/Task';
interface TaskOutputModalAttrs extends IInternalModalAttrs {

View File

@@ -1,12 +1,49 @@
/// <reference types="mithril" />
import Mithril from 'mithril';
import Component, { ComponentAttrs } from 'flarum/common/Component';
import ItemList from '@flarum/core/src/common/utils/ItemList';
export interface IUpdaterAttrs extends ComponentAttrs {
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 {
}
export declare type UpdaterLoadingTypes = 'check' | 'minor-update' | 'global-update' | 'extension-update' | null;
export default class Updater extends Component<IUpdaterAttrs> {
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;
view(): (JSX.Element | null)[];
lastUpdateCheckView(): JSX.Element | null;
availableUpdatesView(): JSX.Element;
controlItems(): ItemList<unknown>;
getExtensionUpdates(): Extension[];
getCoreUpdate(): UpdatedPackage | undefined;
checkForUpdates(): void;
updateCoreMinor(): void;
updateExtension(extension: any): void;
updateGlobally(): void;
}
export {};

View File

@@ -1,4 +1,4 @@
/// <reference types="@flarum/core/dist-typings/@types/translator-icu-rich" />
/// <reference types="flarum/@types/translator-icu-rich" />
import type Mithril from 'mithril';
import Modal, { IInternalModalAttrs } from 'flarum/common/components/Modal';
export interface WhyNotModalAttrs extends IInternalModalAttrs {

View File

@@ -1,57 +0,0 @@
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 {};

View File

@@ -1,6 +0,0 @@
import QueueState from './QueueState';
import ControlSectionState from './ControlSectionState';
export default class PackageManagerState {
queue: QueueState;
control: ControlSectionState;
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,17 +1,11 @@
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">

View File

@@ -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 '../states/ControlSectionState';
import { UpdatedPackage } from './Updater';
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(updates['version'])}</span>
<span className="PackageManager-extension-version-current">{this.version(extension.version)}</span>
{latestVersion ? (
<Label className="PackageManager-extension-version-latest" type={updates['latest-minor'] ? 'success' : 'warning'}>
{this.version(latestVersion)}

View File

@@ -9,12 +9,11 @@ import errorHandler from '../utils/errorHandler';
import jumpToQueue from '../utils/jumpToQueue';
import { AsyncBackendResponse } from '../shims';
export interface InstallerAttrs extends ComponentAttrs {}
export type InstallerLoadingTypes = 'extension-install' | null;
interface InstallerAttrs extends ComponentAttrs {}
export default class Installer extends Component<InstallerAttrs> {
packageName!: Stream<string>;
isLoading: boolean = false;
oninit(vnode: Mithril.Vnode<InstallerAttrs, this>): void {
super.oninit(vnode);
@@ -33,13 +32,7 @@ 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={app.packageManager.control.isLoading('extension-install')}
disabled={app.packageManager.control.isLoadingOtherThan('extension-install')}
>
<Button className="Button" icon="fas fa-download" onclick={this.onsubmit.bind(this)} loading={this.isLoading}>
{app.translator.trans('flarum-package-manager.admin.extensions.proceed')}
</Button>
</div>
@@ -54,7 +47,7 @@ export default class Installer extends Component<InstallerAttrs> {
}
onsubmit(): void {
app.packageManager.control.setLoading('extension-install');
this.isLoading = true;
app.modal.show(LoadingModal);
app
@@ -64,6 +57,7 @@ export default class Installer extends Component<InstallerAttrs> {
body: {
data: this.data(),
},
errorHandler,
})
.then((response) => {
if (response.processing) {
@@ -78,10 +72,8 @@ export default class Installer extends Component<InstallerAttrs> {
window.location.reload();
}
})
.catch(errorHandler)
.finally(() => {
app.packageManager.control.setLoading(null);
app.modal.close();
this.isLoading = false;
m.redraw();
});
}

View File

@@ -7,21 +7,20 @@ 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 '../states/ControlSectionState';
import { UpdatedPackage, UpdateState } from './Updater';
import errorHandler from '../utils/errorHandler';
import WhyNotModal from './WhyNotModal';
import ExtensionItem from './ExtensionItem';
import { AsyncBackendResponse } from '../shims';
import jumpToQueue from '../utils/jumpToQueue';
export interface MajorUpdaterAttrs extends ComponentAttrs {
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>) {
@@ -30,7 +29,7 @@ export default class MajorUpdater<T extends MajorUpdaterAttrs = MajorUpdaterAttr
this.updateState = this.attrs.updateState;
}
view(): Mithril.Children {
view(vnode: Mithril.Vnode<T, this>): Mithril.Children {
// @todo move Form-group--danger class to core for reuse
return (
<div className="Form-group Form-group--danger PackageManager-majorUpdate">
@@ -39,21 +38,11 @@ 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)}
disabled={app.packageManager.control.isLoadingOtherThan('major-update-dry-run')}
>
<Button className="Button" icon="fas fa-vial" onclick={this.update.bind(this, true)}>
{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)}
disabled={app.packageManager.control.isLoadingOtherThan('major-update')}
>
<Button className="Button Button--danger" icon="fas fa-play" onclick={this.update.bind(this, false)}>
{app.translator.trans('flarum-package-manager.admin.major_updater.update')}
</Button>
</div>
@@ -94,7 +83,7 @@ export default class MajorUpdater<T extends MajorUpdaterAttrs = MajorUpdaterAttr
}
update(dryRun: boolean) {
app.packageManager.control.setLoading(dryRun ? 'major-update-dry-run' : 'major-update');
this.isLoading = `update-${dryRun ? 'dry-run' : 'run'}`;
app.modal.show(LoadingModal);
app
@@ -104,6 +93,7 @@ export default class MajorUpdater<T extends MajorUpdaterAttrs = MajorUpdaterAttr
body: {
data: { dryRun },
},
errorHandler,
})
.then((response) => {
if (response?.processing) {
@@ -113,14 +103,13 @@ 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(() => {
app.packageManager.control.setLoading(null);
this.isLoading = null;
m.redraw();
});
}

View File

@@ -24,7 +24,7 @@ export default class QueueSection extends Component<{}> {
oninit(vnode: Mithril.Vnode<{}, this>) {
super.oninit(vnode);
app.packageManager.queue.load();
app.packageManagerQueue.load();
}
view() {
@@ -36,7 +36,7 @@ export default class QueueSection extends Component<{}> {
<Button
className="Button Button--icon"
icon="fas fa-sync-alt"
onclick={() => app.packageManager.queue.load()}
onclick={() => app.packageManagerQueue.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.packageManager.queue.getItems();
const tasks = app.packageManagerQueue.getItems();
if (!tasks) {
return <LoadingIndicator />;
@@ -193,7 +193,7 @@ export default class QueueSection extends Component<{}> {
</tbody>
</table>
<Pagination list={app.packageManager.queue} />
<Pagination list={app.packageManagerQueue} />
</>
);
}

View File

@@ -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);
items.setPriority('content', 10);
items.add('control', <ControlSection />, 8);
if (parseInt(app.data.settings['flarum-package-manager.queue_jobs'])) {
if (app.data.settings['flarum-package-manager.queue_jobs']) {
items.add('queue', <QueueSection />, 5);
}
items.add('control', <ControlSection />, 8);
items.setPriority('content', 10);
items.setPriority('permissions', 0);
return items;

View File

@@ -1,126 +1,278 @@
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 interface IUpdaterAttrs extends ComponentAttrs {}
export type UpdatedPackage = {
name: string;
version: string;
latest: string;
'latest-minor': string | null;
'latest-major': string | null;
'latest-status': string;
description: string;
};
export type UpdaterLoadingTypes = 'check' | 'minor-update' | 'global-update' | 'extension-update' | null;
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 default class Updater extends Component<IUpdaterAttrs> {
view() {
const core = app.packageManager.control.coreUpdate;
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'),
},
},
};
}
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.lastUpdateCheckView()}
<div className="PackageManager-updaterControls">{this.controlItems().toArray()}</div>
{this.availableUpdatesView()}
{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}
</div>,
core && core.package['latest-major'] ? (
<MajorUpdater coreUpdate={core.package} updateState={app.packageManager.control.lastUpdateRun.major} />
) : null,
coreUpdate && coreUpdate['latest-major'] ? <MajorUpdater coreUpdate={coreUpdate} updateState={this.lastUpdateRun.major} /> : null,
];
}
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
);
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]);
}
availableUpdatesView() {
const state = app.packageManager.control;
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>
);
getCoreUpdate(): UpdatedPackage | undefined {
return this.lastUpdateCheck?.updates?.installed?.filter((composerPackage: UpdatedPackage) => composerPackage.name === 'flarum/core').pop();
}
controlItems() {
const items = new ItemList();
checkForUpdates() {
this.isLoading = 'check';
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
);
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();
});
}
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>
);
updateCoreMinor() {
if (confirm(extractText(app.translator.trans('flarum-package-manager.admin.minor_update_confirmation.content')))) {
app.modal.show(LoadingModal);
this.isLoading = 'minor-update';
return items;
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();
});
}
}
updateExtension(extension: any) {
app.modal.show(LoadingModal);
this.isLoading = 'extension-update';
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();
});
}
updateGlobally() {
app.modal.show(LoadingModal);
this.isLoading = 'global-update';
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();
});
}
}

View File

@@ -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);
});
}
}

View File

@@ -4,29 +4,21 @@ 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 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.packageManager = new PackageManagerState();
app.packageManagerQueue = new QueueState();
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'),

View File

@@ -1,4 +1,4 @@
import PackageManagerState from './states/PackageManagerState';
import QueueState from './states/QueueState';
export interface AsyncBackendResponse {
processing: boolean;
@@ -6,6 +6,6 @@ export interface AsyncBackendResponse {
declare module 'flarum/admin/AdminApplication' {
export default interface AdminApplication {
packageManager: PackageManagerState;
packageManagerQueue: QueueState;
}
}

View File

@@ -1,239 +0,0 @@
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')),
},
},
},
};
}
}

View File

@@ -1,7 +0,0 @@
import QueueState from './QueueState';
import ControlSectionState from './ControlSectionState';
export default class PackageManagerState {
public queue: QueueState = new QueueState();
public control: ControlSectionState = new ControlSectionState();
}

View File

@@ -4,7 +4,7 @@ import { ApiQueryParamsPlural } from 'flarum/common/Store';
export default class QueueState {
private tasks: Task[] | null = null;
private limit = 20;
private limit = 5;
private offset = 0;
private total = 0;

View File

@@ -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.packageManager.queue.load();
app.packageManagerQueue.load();
setTimeout(() => {
document.getElementById('PackageManager-queueSection')?.scrollIntoView({ block: 'nearest' });
}, 200);

View File

@@ -11,7 +11,6 @@
flex-wrap: wrap;
gap: 8px;
grid-area: controls;
margin-bottom: 16px;
}
.PackageManager-extensions {
@@ -20,6 +19,7 @@
display: grid;
grid-template-columns: repeat(auto-fit, calc(~"100% / 3 - var(--gap)"));
gap: var(--gap);
margin-top: 16px;
}
}

View File

@@ -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, storage/.composer
The package manager requires read and write permissions on the following files and directories: composer.json, composer.lock, vendor, 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,14 +72,12 @@ 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.

View File

@@ -10,8 +10,6 @@
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;
@@ -48,25 +46,18 @@ class UpdateExtensionHandler
*/
protected $events;
/**
* @var Paths
*/
protected $paths;
public function __construct(
ComposerAdapter $composer,
ExtensionManager $extensions,
UpdateExtensionValidator $validator,
LastUpdateCheck $lastUpdateCheck,
Dispatcher $events,
Paths $paths
Dispatcher $events
) {
$this->composer = $composer;
$this->extensions = $extensions;
$this->validator = $validator;
$this->lastUpdateCheck = $lastUpdateCheck;
$this->events = $events;
$this->paths = $paths;
}
/**
@@ -85,17 +76,6 @@ 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

View File

@@ -9,7 +9,6 @@
namespace Flarum\PackageManager\Composer;
use Composer\Config;
use Composer\Console\Application;
use Flarum\Foundation\Paths;
use Flarum\PackageManager\OutputLogger;
@@ -71,9 +70,4 @@ class ComposerAdapter
return new ComposerOutput($exitCode, $output);
}
public static function setPhpVersion(string $phpVersion)
{
Config::$defaultConfig['platform']['php'] = $phpVersion;
}
}

View File

@@ -11,9 +11,7 @@ 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
@@ -24,11 +22,11 @@ class ComposerCommandJob extends AbstractJob
protected $command;
/**
* @var string
* @var int[]
*/
protected $phpVersion;
public function __construct(BusinessCommandInterface $command, string $phpVersion)
public function __construct(BusinessCommandInterface $command, array $phpVersion)
{
$this->command = $command;
$this->phpVersion = $phpVersion;
@@ -37,7 +35,12 @@ class ComposerCommandJob extends AbstractJob
public function handle(Dispatcher $bus)
{
try {
ComposerAdapter::setPhpVersion($this->phpVersion);
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).");
}
$this->command->task->start();
@@ -59,11 +62,4 @@ class ComposerCommandJob extends AbstractJob
$this->fail($exception);
}
public function middleware()
{
return [
new WithoutOverlapping(),
];
}
}

View File

@@ -73,7 +73,7 @@ class Dispatcher
$command->task = $task;
$this->queue->push(
new ComposerCommandJob($command, PHP_VERSION)
new ComposerCommandJob($command, [PHP_MAJOR_VERSION, PHP_MINOR_VERSION])
);
} else {
$data = $this->bus->dispatch($command);

View File

@@ -19,7 +19,7 @@
}
],
"require": {
"flarum/core": "^1.6",
"flarum/core": "^1.4",
"pusher/pusher-php-server": "^2.2"
},
"require-dev": {

View File

@@ -1 +0,0 @@
/*! regenerator-runtime -- Copyright (c) 2014-present, Facebook, Inc. -- license (MIT): https://github.com/facebook/regenerator/blob/main/LICENSE */

View File

@@ -1,13 +1,5 @@
# 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

View File

@@ -19,7 +19,7 @@
}
],
"require": {
"flarum/core": "^1.6"
"flarum/core": "^1.4"
},
"autoload": {
"psr-4": {

View File

@@ -1,5 +1,4 @@
import DashboardWidget, { IDashboardWidgetAttrs } from 'flarum/admin/components/DashboardWidget';
import { IDateSelection } from './StatisticsWidgetDateSelectionModal';
import type Mithril from 'mithril';
interface IPeriodDeclaration {
start: number;
@@ -10,22 +9,17 @@ export default class StatisticsWidget extends DashboardWidget {
entities: string[];
periods: undefined | Record<string, IPeriodDeclaration>;
chart: any;
customPeriod: IDateSelection | null;
timedData: Record<string, undefined | any>;
timedData: any;
lifetimeData: any;
customPeriodData: Record<string, undefined | any>;
noData: boolean;
loadingLifetime: boolean;
loadingTimed: Record<string, 'unloaded' | 'loading' | 'loaded' | 'fail'>;
loadingCustom: Record<string, 'unloaded' | 'loading' | 'loaded' | 'fail'>;
loadingTimed: boolean;
selectedEntity: string;
selectedPeriod: undefined | string;
chartEntity?: string;
chartPeriod?: string;
oncreate(vnode: Mithril.VnodeDOM<IDashboardWidgetAttrs, this>): void;
loadLifetimeData(): Promise<void>;
loadTimedData(model: string): Promise<void>;
loadCustomRangeData(model: string): Promise<void>;
loadTimedData(): Promise<void>;
className(): string;
content(): JSX.Element;
drawChart(vnode: Mithril.VnodeDOM<any, any>): void;

View File

@@ -1,39 +0,0 @@
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 {};

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
/*! regenerator-runtime -- Copyright (c) 2014-present, Facebook, Inc. -- license (MIT): https://github.com/facebook/regenerator/blob/main/LICENSE */

File diff suppressed because one or more lines are too long

View File

@@ -7,15 +7,15 @@
"frappe-charts": "^1.6.2"
},
"devDependencies": {
"@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",
"flarum-webpack-config": "^2.0.0",
"webpack": "^5.73.0",
"webpack-cli": "^4.10.0"
"webpack-cli": "^4.10.0",
"@flarum/prettier-config": "^1.0.0",
"flarum-tsconfig": "^1.0.2",
"typescript": "^4.7.4",
"typescript-coverage-report": "^0.6.4"
},
"scripts": {
"dev": "webpack --mode development --watch",

View File

@@ -3,26 +3,16 @@ 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;
@@ -35,23 +25,11 @@ export default class StatisticsWidget extends DashboardWidget {
chart: any;
customPeriod: IDateSelection | null = null;
timedData: Record<string, undefined | any> = {};
timedData: any;
lifetimeData: any;
customPeriodData: Record<string, undefined | any> = {};
noData: boolean = false;
loadingLifetime = 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'>);
loadingTimed = true;
selectedEntity = 'users';
selectedPeriod: undefined | string;
@@ -63,6 +41,7 @@ export default class StatisticsWidget extends DashboardWidget {
super.oncreate(vnode);
this.loadLifetimeData();
this.loadTimedData();
}
async loadLifetimeData() {
@@ -83,116 +62,49 @@ export default class StatisticsWidget extends DashboardWidget {
m.redraw();
}
async loadTimedData(model: string) {
this.loadingTimed[model] = 'loading';
async loadTimedData() {
this.loadingTimed = true;
m.redraw();
try {
const data = await app.request({
method: 'GET',
url: app.forum.attribute('apiUrl') + '/statistics',
params: {
period: 'timed',
model,
},
});
const data = await app.request({
method: 'GET',
url: app.forum.attribute('apiUrl') + '/statistics',
});
this.timedData[model] = data;
this.loadingTimed[model] = 'loaded';
this.timedData = data;
this.loadingTimed = false;
// Create a Date object which represents the start of the day.
let todayDate = new Date();
todayDate.setUTCHours(0, 0, 0, 0);
// 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);
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';
} catch (e) {
console.error(e);
this.loadingTimed[model] = 'fail';
}
this.selectedPeriod = 'last_7_days';
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 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);
}
}
const thisPeriod = this.loadingTimed ? null : this.periods![this.selectedPeriod!];
return (
<div className="StatisticsWidget-table">
@@ -200,60 +112,20 @@ 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">
{loadingSelectedEntity ? (
{this.loadingTimed ? (
<LoadingIndicator size="small" display="inline" />
) : (
<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 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>
)}
</div>
@@ -261,17 +133,14 @@ 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 = loadingSelectedEntity
const thisPeriodCount = this.loadingTimed
? app.translator.trans('flarum-statistics.admin.statistics.loading')
: this.getPeriodCount(entity, thisPeriod!);
const lastPeriodCount =
this.selectedPeriod === 'custom'
? null
: loadingSelectedEntity
? app.translator.trans('flarum-statistics.admin.statistics.loading')
: this.getPeriodCount(entity, this.getLastPeriod(thisPeriod!));
const lastPeriodCount = this.loadingTimed
? app.translator.trans('flarum-statistics.admin.statistics.loading')
: this.getPeriodCount(entity, this.getLastPeriod(thisPeriod!));
const periodChange =
loadingSelectedEntity || lastPeriodCount === 0 || lastPeriodCount === null
this.loadingTimed || lastPeriodCount === 0
? 0
: (((thisPeriodCount as number) - (lastPeriodCount as number)) / (lastPeriodCount as number)) * 100;
@@ -285,7 +154,7 @@ export default class StatisticsWidget extends DashboardWidget {
{this.loadingLifetime ? <LoadingIndicator display="inline" /> : abbreviateNumber(totalCount as number)}
</div>
<div className="StatisticsWidget-period" title={thisPeriodCount}>
{loadingSelectedEntity ? <LoadingIndicator display="inline" /> : abbreviateNumber(thisPeriodCount as number)}
{this.loadingTimed ? <LoadingIndicator display="inline" /> : abbreviateNumber(thisPeriodCount as number)}
{periodChange !== 0 && (
<>
{' '}
@@ -301,34 +170,12 @@ export default class StatisticsWidget extends DashboardWidget {
})}
</div>
<>
{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>
{this.loadingTimed ? (
<div className="StatisticsWidget-chart">
<LoadingIndicator size="large" />
</div>
) : (
<div className="StatisticsWidget-chart" oncreate={this.drawChart.bind(this)} onupdate={this.drawChart.bind(this)} />
)}
</div>
);
@@ -339,16 +186,10 @@ export default class StatisticsWidget extends DashboardWidget {
return;
}
const period =
this.selectedPeriod === 'custom'
? {
start: this.customPeriod?.start!,
end: this.customPeriod?.end!,
step: 86400,
}
: this.periods![this.selectedPeriod!];
const offset = this.timedData.timezoneOffset;
const period = this.periods![this.selectedPeriod!];
const periodLength = period.end - period.start;
const labels: string[] = [];
const labels = [];
const thisPeriod = [];
const lastPeriod = [];
@@ -356,53 +197,29 @@ export default class StatisticsWidget extends DashboardWidget {
let label;
if (period.step < 86400) {
label = dayjs.unix(i).utc().format('h A');
label = dayjs.unix(i + offset).format('h A');
} else {
label = dayjs.unix(i).utc().format('D MMM');
label = dayjs.unix(i + offset).format('D MMM');
if (period.step > 86400) {
label +=
' - ' +
dayjs
.unix(i + period.step - 1)
.utc()
.format('D MMM');
label += ' - ' + dayjs.unix(i + offset + period.step - 1).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 }));
lastPeriod.push(this.getPeriodCount(this.selectedEntity, { start: i - periodLength, end: i - periodLength + period.step }));
}
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 datasets = [{ values: lastPeriod }, { values: thisPeriod }];
const data = {
labels,
datasets,
};
// 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)) {
if (!this.chart) {
this.chart = new Chart(vnode.dom, {
data,
type: 'line',
@@ -414,9 +231,8 @@ export default class StatisticsWidget extends DashboardWidget {
},
lineOptions: {
hideDots: 1,
regionFill: 1,
},
colors: [app.forum.attribute('themePrimaryColor'), 'black'],
colors: ['black', app.forum.attribute('themePrimaryColor')],
});
} else {
this.chart.update(data);
@@ -439,7 +255,7 @@ export default class StatisticsWidget extends DashboardWidget {
}
getPeriodCount(entity: string, period: { start: number; end: number }) {
const timed: Record<string, number> = (this.selectedPeriod === 'custom' ? this.customPeriodData : this.timedData)[entity];
const timed: Record<string, number> = this.timedData[entity];
let count = 0;
for (const t in timed) {

View File

@@ -1,161 +0,0 @@
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();
}
}

View File

@@ -93,29 +93,22 @@
}
.chart-container {
.dataset-1 {
.dataset-0 {
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;
}
}
/*!
@@ -126,9 +119,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 {
@@ -201,10 +194,6 @@
min-width: 90px;
flex: 1;
font-weight: 600;
&:nth-child(2) {
border-top-color: #5a5a5a !important;
}
}
}
strong {

View File

@@ -1,40 +1,24 @@
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