diff --git a/docs/extend/api.md b/docs/extend/api.md index e58294178..ffcb47097 100644 --- a/docs/extend/api.md +++ b/docs/extend/api.md @@ -415,12 +415,12 @@ public function endpoints(): array return [ Endpoint\Show::make() ->authenticated() - ->can('view'), // equivalent to $actor->can('view', $label) + ->can('view'), // equivalent to $actor->assertCan('view', $label) Endpoint\Create::make() ->authenticated() - ->can('createLabel'), // equivalent to $actor->can('createLabel'), + ->can('createLabel'), // equivalent to $actor->assertCan('createLabel'), Endpoint\Update::make() - ->admin(), // equivalent to $actor->isAdmin() + ->admin(), // equivalent to $actor->assertAdmin() ]; } ``` @@ -874,6 +874,12 @@ return [ Schema\Str::make('customField'), Schema\Relationship\ToOne::make('customRelation') ->type('customRelationType'), + ]) + ->fieldsBefore('email', fn () => [ + Schema\Str::make('customFieldBeforeEmail'), + ]) + ->fieldsAfter('email', fn () => [ + Schema\Str::make('customFieldAfterEmail'), ]), ] ``` @@ -914,6 +920,7 @@ return [ You can add endpoints to an existing resource through the `endpoints` method. ```php +use Flarum\Api\Context; use Flarum\Api\Resource; use Flarum\Api\Endpoint; use Flarum\Extend; @@ -921,11 +928,35 @@ use Flarum\Extend; return [ (new Extend\ApiResource(Resource\UserResource::class)) ->endpoints(fn () => [ - Endpoint\Show::make(), Endpoint\Endpoint::make('custom') - ->route('GET', '/custom') - ->action(fn (Context $context) => 'custom'), - ]), + ->route('GET', '/{id}/custom') + ->action(function (Context $context) { + $user = $context->model; + + // logic... + }), + ]) + ->endpointsBefore('show', fn () => [ + Endpoint\Endpoint::make('customBeforeShow') + ->route('GET', '/customBeforeShow') + ->action(function (Context $context) { + // logic ... + }), + ]) + ->endpointsAfter('show', fn () => [ + Endpoint\Endpoint::make('customAfterShow') + ->route('GET', '/customAfterShow') + ->action(function (Context $context) { + // logic ... + }), + ]) + ->endpointsBeforeAll(fn () => [ + Endpoint\Endpoint::make('customBeforeAll') + ->route('GET', '/customBeforeAll') + ->action(function (Context $context) { + // logic ... + }), + ]) ]; ``` @@ -1031,3 +1062,62 @@ return [ API Resources don't have to correspond to Eloquent models: you can define JSON:API resources for anything. You need to extend the [`Flarum\Api\Rsource\AbstractResource`](https://github.com/flarum/framework/blob/2.x/framework/core/src/Api/Resource/AbstractResource.php) class instead. For instance, Flarum core uses the [`Flarum\Api\Resource\ForumResource`](hhttps://github.com/flarum/framework/blob/2.x/framework/core/src/Api/Resource/ForumResource.php) to send an initial payload to the frontend. This can include settings, whether the current user can perform certain actions, and other data. Many extensions add data to the payload by extending the fields of `ForumResource`. + +## Programmatically calling an API endpoint + +You can internally execute an endpoint's logic through the `Flarum\Api\JsonApi` object. For example, this is what Flarum does to immediately create the first post of a discussion: + +```php +/** @var JsonApi $api */ +$api = $context->api; + +/** @var Post $post */ +$post = $api->forResource(PostResource::class) + ->forEndpoint('create') + ->withRequest($context->request) + ->process([ + 'data' => [ + 'attributes' => [ + 'content' => Arr::get($context->body(), 'data.attributes.content'), + ], + 'relationships' => [ + 'discussion' => [ + 'data' => [ + 'type' => 'discussions', + 'id' => (string) $model->id, + ], + ], + ], + ], + ], ['isFirstPost' => true]); +``` + +If you do not have access to the `Flarum\Api\Context $context` object, then you can directly inject the api object: + +```php +use Flarum\Api\JsonApi; + +public function __construct( + protected JsonApi $api +) { +} + +public function handle(): void +{ + $group = $api->forResource(GroupResource::class) + ->forEndpoint('create') + ->process( + body: [ + 'data' => [ + 'attributes' => [ + 'nameSingular' => 'test group', + 'namePlural' => 'test groups', + 'color' => '#000000', + 'icon' => 'fas fa-crown', + ] + ], + ], + options: ['actor' => User::find(1)] + ) +} +``` diff --git a/docs/extend/cli.md b/docs/extend/cli.md index 2456ed478..b60550a14 100644 --- a/docs/extend/cli.md +++ b/docs/extend/cli.md @@ -14,3 +14,60 @@ See the [package's readme](https://github.com/flarum/cli#readme) for information - Upgrading - Available commands - Some implementation details, if you're interested + +## Installing Multiple CLI versions + +To assist in upgrading extensions and maintaining compatibility with both v1 and v2 of the project, developers may need to use both versions of the CLI tool simultaneously. This guide explains how to install and manage multiple CLI versions side-by-side. + +#### Installing Specific Versions + +To install CLI versions 2 and 3 globally, you can alias them for easy access: + +```bash +npm install -g fl1@npm:@flarum/cli@2 --force +npm install -g fl2@npm:@flarum/cli@3 --force +``` + +This will allow you to use the CLI with the following commands: +* `fl1` for the v2 CLI (compatible with project v1) +* `fl2` for the v3 CLI (compatible with project v2) + +To confirm the installation and version of each CLI, run: + +```bash +fl1 flarum info +fl2 flarum info +``` + +##### Switching Between Versions + +If you have any of the latest v2 or v3 versions of the CLI, you can also use the following command to install the counterpart version: + +```bash +fl flarum change +``` + +This will install the latest counterpart version of the CLI, allowing you to switch between them as needed. It will also set the default `fl` bin to the version you have just changed to. + +```shell +$ fl flarum info +Flarum version: 2.x +CLI version: 3.0.1 +$ fl flarum change +Currently using CLI 3.x compatible with Flarum 2.x + +✔ Switch to CLI 2.x compatible with Flarum 1.x? … yes +$ fl flarum info +Flarum version: 1.x +CLI version: 2.0.2 +``` + +You will still be able to use the individual version specific bins: +```bash +$ fl1 flarum info +Flarum version: 1.x +CLI version: 2.0.2 +$ fl2 flarum info +Flarum version: 2.x +CLI version: 3.0.1 +``` diff --git a/docs/extend/update-1_0.md b/docs/extend/update-1_0.md deleted file mode 100644 index 5fb57b004..000000000 --- a/docs/extend/update-1_0.md +++ /dev/null @@ -1,261 +0,0 @@ -# Updating For 1.0 - -Flarum version 1.0 is the long-awaited stable release! This release brings a number of refactors, cleanup, and small improvements that should make your Flarum experience just a bit better! - -:::tip - -If you need help applying these changes or using new features, please start a discussion on the [community forum](https://discuss.flarum.org/t/extensibility) or [Discord chat](https://flarum.org/discord/). - -::: - -## Full Stack - -### Translations and transChoice - -#### Background - -Historically, Flarum has used Symfony for backend translations, and a port for frontend translations to keep format consistent. There are a few limitations to this approach though: - -- Developers need to decide between using `trans` or `transChoice` for pluralization -- The pluralization format is proprietary to Symfony -- We have to maintain the JS port ourselves -- Keys for values provided to backend translations need to be wrapped in curly braces. (e.g. `$this->translator->trans('some.translation', ['{username}' => 'Some Username!'])`). -- There's no support for complex applications (nested pluralization, non-number-based selection) -- As a result of the previous point, genderization is impossible. And that's kinda important for a lot of languages. - -### New System - -In v5, Symfony dropped their proprietary `transChoice` system in favor of the more-or-less standard [ICU MessageFormat](https://symfony.com/doc/5.2/translation/message_format.html). This solves pretty much every single one of the aforementioned issues. In this release, Flarum will be fully switching to ICU MessageFormat as well. What does this mean for extensions? - -- `transChoice` should not be used at all; instead, the variable passed for pluralization should be included in the data. -- Keys for backend translations no longer need to be surrounded by curly braces. -- Translations can now use the [`select` and `plural`](https://symfony.com/doc/5.2/translation/message_format.html) formatter syntaxes. For the `plural` formatter, the `offset` parameter and `#` magic variables are supported. -- These `select` and `plural` syntaxes can be nested to arbitrary depth. This is often a bad idea though (beyond, say, 2 levels), as things can get unnecessarily complex. - -No change to translation file naming is necessary (Symfony docs say that an `+intl-icu` suffix is necessary, but Flarum will now interpret all translation files as internationalized). - -#### Future Changes - -In the future, this will serve as a basis for additional features: - -- Translator preprocessors will allow extensions to modify arguments passed to translations. This will enable genderization (extensions could automatically extract a gender field from any objects of type "user" passed in). -- We could support internationalized "magic variables" for numbers: currently, `one` is supported, but others (`few`, `many`, etc) currently aren't. -- We could support ordinal formatting in additional to just plural formatting. - -#### Changes Needed in Extensions - -The `transChoice` methods in the frontend and backend have been removed. -The `trans` method should always be used for translating, regardless of pluralization. -If a translation requires pluralization, make sure you pass in the controlling variable as one of the arguments. - -In the frontend, code that looked like this: - -```js -app.translator.transChoice('some-translation', guestCount, {host: hostName}); -``` - -should be changed to: - -```js -// This uses ES6 key-property shorthand notation. {guestCount: guestCount} is equivalent to {guestCount} -app.translator.trans('some-translation', {host: hostName, guestCount }); -``` - -Similarly, in the backend, - -```php -$translator->transChoice('some-translation', $guestCount, ['{host}' => $hostName]); -``` - -should be changed to: - -```php -$translator->trans('some-translation', ['host' => $hostName, 'guestCount' => $guestCount]); -``` - -Note that in the backend, translation keys were previously wrapped in curly braces. -This is no longer needed. - -#### Changes Needed in Translations - -Translations that aren't using pluralization don't need any changes. - -Pluralized translations should be changed as follows: - -`For {count} minute|For {count} minutes` - -to - -`{count, plural, one {For # minute} other {For # minutes}}` - -Note that in this example, `count` is the variable that controls pluralization. If a different variable were used (such as guestCount in the example above), this would look like: - -`{guestCount, plural, one {For # minute} other {For # minutes}}` - -See [our i18n docs](i18n.md) for more information. - -### Permissions Changes - -For a long time, the `viewDiscussions` and `viewUserList` permissions have been confusing. Despite their names: - -- `viewDiscussions` controls viewing both discussions and users. -- `viewUserList` controls searching users, not viewing user profiles. - -To clear this up, in v1.0, these permissions have been renamed to `viewForum` and `searchUsers` respectively. -A migration in core will automatically adjust all permissions in the database to use the new naming. However, any extension code using the old name must switch to the new ones immediately to avoid security issues. To help the transfer, a warning will be thrown if the old permissions are referenced. - -We have also slightly improved tag scoping for permissions. Currently, permissions can be applied to tags if: - -- The permission is `viewForum` -- The permission is `startDiscussion` -- The permission starts with `discussion.` - -However, this doesn't work for namespaced permissions (`flarum-acme.discussion.bookmark`), or permissions that don't really have anything to do with discussions, but should still be scoped (e.g. `viewTag`). To counter this, a `tagScoped` attribute can be used on the object passed to [`registerPermission`](admin.md) to explicitly indicate whether the permission should be tag scopable. If this attribute is not provided, the current rules will be used to determine whether the permission should be tag scopable. - -## Frontend - -### Tooltip Changes - -The `flarum/common/components/Tooltip` component has been introduced as a simpler and less framework-dependent way to add tooltips. If your code is creating tooltips directly (e.g. via `$.tooltip()` in `oncreate` methods), you should wrap your components in the `Tooltip` component instead. For example: - -```tsx - - - -``` - -See [the source code](https://github.com/flarum/core/blob/master/js/src/common/components/Tooltip.tsx) for more examples and instructions. - -See [the PR](https://github.com/flarum/core/pull/2843/files) for examples of how to change existing code to use tooltips. - -### PaginatedListState - -The `flarum/common/states/PaginatedListState` state class has been introduced to abstract away most of the logic of `DiscussionListState` and `NotificationListState`. It provides support for loading and displaying paginated lists of JSON:API resources (usually models). In future releases, we will also provide an `PaginatedList` (or `InfiniteScroll`) component that can be used as a base class for these paginated lists. - -Please see [the source code](https://github.com/flarum/core/blob/master/js/src/common/states/PaginatedListState.ts) for a list of methods. - -Note that `flarum/forum/states/DiscussionListState`'s `empty` and `hasDiscussions` methods have been removed, and replaced with `isEmpty` and `hasItems` respectively. This is a breaking change. - -### New Loading Spinner - -The old `spin.js` based loading indicator has been replaced with a CSS-based solution. For the most part, no changes should be needed in extensions, but in some cases, you might need to update your spinner. This change also makes it easier to customize the spinner. - -See [this discussion](https://discuss.flarum.org/d/26994-beta16-using-the-new-loading-spinner) for more information. - -### classList util - -Ever wrote messy code trying to put together a list of classes for some component? Well, no longer! The [clsx library](https://www.npmjs.com/package/clsx) is now available as the `flarum/common/utils/classList` util. - -### User List - -An extensible user list has been added to the admin dashboard. In future releases, we hope to extract a generic model table component that can be used to list any model in the admin dashboard. - -See [the source code](https://github.com/flarum/core/blob/master/js/src/admin/components/UserListPage.tsx#L41) for a list of methods to extend, and examples of how columns should look like (can be added by extending the `columns` method and adding items to the [ItemList](frontend.md)). - -### Miscellaneous - -- Components should now call `super` for ALL Mithril lifecycle methods they define. Before, this was only needed for `oninit`, `onbeforeupdate`, and `oncreate`. Now, it is also needed in `onupdate`, `onbeforeremove`, and `onremove`. See [this GitHub issue](https://github.com/flarum/core/issues/2446) for information on why this change was made. -- The `flarum/common/utils/insertText` and `flarum/common/utils/styleSelectedText` utils have been moved to core from `flarum/markdown`. See `flarum/markdown` for an example of usage. -- The `extend` and `override` utils can now modify several methods at once by passing in an array of method names instead of a single method name string as the second argument. This is useful for extending the `oncreate` and `onupdate` methods at once. -- The `EditUserModal` component is no longer available through the `flarum/forum` namespace, it has been moved to `flarum/common`. Imports should be adjusted. -- The `Model` and `Route` JS extenders have been removed for now. There aren't currently used in any extensions that we know of. We will be reintroducing JS extenders during v1.x releases. -- The `Search` component can now be used with the `SearchState` state class. Previously, `SearchState` was missing the `getInitialSearch` method expected by the `Search` component. - -## Backend - -### Filesystem Extenders - -In this release, we refactored our use of the filesystem to more consistently use [Laravel's filesystem API](https://laravel.com/docs/8.x/filesystem). Extensions can now declare new disks, more easily use core's `flarum-assets` and `flarum-avatars` disks, and create their own storage drivers, enabling CDN and cloud storage support. - -### Compat and Closure Extenders - -In early Flarum versions, the `extend.php` file allowed arbitrary functions that allowed execution of arbitrary code one extension boot. For example: - -```php -return [ - // other extenders - function (Dispatcher $events) { - $events->subscribe(Listener\FilterDiscussionListByTags::class); - $events->subscribe(Listener\FilterPostsQueryByTag::class); - $events->subscribe(Listener\UpdateTagMetadata::class); - } -]; -``` - -This approach was difficult to maintain and provide a well-tested public API for, frequently resolved classes early (breaking all sorts of things), and was not very descriptive. With the extender API completed in beta 16, this approach is no longer necessary. Support for these closures has been removed in this stable version. - -One type of functionality for which the extender replacement isn't obvious is container bindings ([e.g. flarum/pusher](https://github.com/flarum/pusher/blob/v0.1.0-beta.14/extend.php#L33-L49)). This can be done with via the service provider extender (e.g. [a newer version of flarum/pusher](https://github.com/flarum/pusher/blob/master/extend.php#L40-L41)). - -If you are unsure about which extenders should be used to replace your use of callbacks in `extend.php`, or are not sure that such an extender exists, please comment so below or reach out! We're in the final stages of finishing up the extender API, so now is the time to comment. - -### Scheduled Commands - -The [fof/console](https://github.com/FriendsOfFlarum/console) library has been a popular way to schedule commands (e.g. for publishing scheduled posts, running DB-heavy operations, etc) for several release. In Flarum 1.0, this functionality has been brought into core's `Console` extender. See our [console extension documentation](console.md) for more information on how to create schedule commands, and our [console user documentation](../console.md) for more information on how to run scheduled commands. - -### Eager Loading Extender - -As part of solving [N+1 Query issues](https://secure.phabricator.com/book/phabcontrib/article/n_plus_one/) in some [Flarum API endpoints](https://github.com/flarum/core/issues/2637), we have introduced a `load` method on the `ApiController` extender that allows you to indicate relations that should be eager loaded. - -This should be done if you know a relation will always be included, or will always be referenced by controller / permission logic. For example, we will always need the tags of a discussion to check what permissions a user has on that discussion, so we should eager load the discussion's `tags` relationship. For example: - -```php -return [ - // other extenders - (new Extend\ApiController(FlarumController\ListDiscussionsController::class)) - ->addInclude(['tags', 'tags.state', 'tags.parent']) - ->load('tags'), -]; -``` - -### RequestUtil - -The `Flarum\Http\RequestUtil`'s `getActor` and `withActor` should be used for getting/setting the actor (user) on requests. `$request->getAttribute('actor')` and `$request->withAttribute('actor')` are deprecated, and will be removed in v2.0. - -### Miscellaneous - -- The `Formatter` extender now has an `unparse` method that allows modifying XML before unparsing content. -- All route names must now be unique. In beta 16, uniqueness was enforced per-method; now, it is mandatory for all routes. -- API requests sent through `Flarum\Api\Client` now run through middleware, including `ThrottleApi`. This means that it is now possible to throttle login/registration calls. -- In beta 16, registering custom [searchers](search.md) was broken. It has been fixed in stable. -- The `post_likes` table in the [flarum/likes](https://github.com/flarum/likes) extension now logs the timestamp when likes were created. This isn't used in the extension, but could be used in other extensions for analytics. -- The `help` attribute of [admin settings](admin.md) no longer disappears on click. -- The `generate:migration` console command has been removed. The [Flarum CLI](https://discuss.flarum.org/d/26525-rfc-flarum-cli-alpha) should be used instead. -- The `GambitManager` util class is now considered internal API, and should not be used directly by extensions. -- The `session` attribute is no longer available on the `User` class. This caused issues with queue drivers, and was not conceptually correct (a user can have multiple sessions). The current session is still available via the `$request` instance. -- The `app`, `base_path`, `public_path`, `storage_path`, and `event` global helpers have been restored, but deprecated perpetually. These exist in case Laravel packages need them; they **should not** be used directly by Flarum extension code. The `flarum/laravel-helpers` package has been abandoned. -- The following deprecated features from beta 16 have been removed: - - The `TextEditor`, `TextEditorButton`, and `SuperTextarea` components are no longer exported through the `flarum/forum` namespace, but through `flarum/common` (with the exception of `SuperTextarea`, which has been replaced with `flarum/common/utils/BasicEditorDriver`). - - Support for `Symfony\Component\Translation\TranslatorInterface` has been removed, `Symfony\Contracts\Translation\TranslatorInterface` should be used instead. - - All backwards compatibility layers for the [beta 16 access token refactors](update-b16.md#access-token-and-authentication-changes) have been removed - - The `GetModelIsPrivate` event has been removed. The `ModelPrivate` extender should be used instead. - - The `Searching` (for both `User` and `Discussion` models), `ConfigureAbstractGambits`, `ConfigureDiscussionGambits`, and `ConfigureUserGambits` events have been removed. The `SimpleFlarumSearch` extender should be used instead. - - The `ConfigurePostsQuery` event has been removed. The `Filter` extender should be used instead. - - The `ApiSerializer` extender's `mutate` method has been removed. The same class's `attributes` method should be used instead. - - The `SearchCriteria` and `SearchResults` util classes have been removed. `Flarum\Query\QueryCriteria` and `Flarum\Query\QueryResults` should be used instead. - - The `pattern` property of `AbstractRegexGambit` has been removed; the `getGambitPattern` method is now a required abstract method. - - The `AbstractSearch` util class has been removed. `Flarum\Search\SearchState` and `Flarum\Filter\FilterState` should be used instead. - - The `CheckingPassword` event has been removed, the `Auth` extender should be used instead. - -## Tags Extension Changes - -As mentioned above, [tag scopable permissions](#permissions-changes) can now be explicitly declared. - -We've also made several big refactors to significantly improve tags performance. - -Firstly, the [Tag](https://github.com/flarum/tags/blob/d093ca777ba81f826157522c96680717d3a90e24/src/Tag.php#L38-L38) model's static `getIdsWhereCan` and `getIdsWhereCannot` methods have been removed. These methods scaled horribly on forums with many tags, sometimes adding several seconds to response time. - -These methods have been replaced with a `whereHasPermission` [Eloquent dynamic scope](https://laravel.com/docs/8.x/eloquent#dynamic-scopes) that returns a query. For example: - -`Tag::whereHasPermission($actor, 'viewDiscussions)`. - -That query can then be used in other DB queries, or further constricted to retrieve individual tags. - -Secondly, we no longer load in all tags as part of the initial payload. The initial payload contains all top-level primary tags, and the top 3 secondary tags. Other tags are loaded in as needed. Future releases will paginate secondary tags. This should enable forums to have thousands of secondary tags without significant performance impacts. These changes mean that extensions should not assume that all tags are available in the model store. - -## Testing Library Changes - -- Bundled extensions will not be automatically enabled; all enabled extensions must be specified in that test case. -- `setting` and `config` methods have been added that allow configuring settings and config.php values before the tested application boots. See [the testing docs](testing.md) for more information. -- The `php flarum test:setup` command will now drop the existing test DB tables before creating the database. This means that you can run `php flarum test:setup` to ensure a clean database without needing to go into the database and drop tables manually. diff --git a/docs/extend/update-1_x.md b/docs/extend/update-1_x.md deleted file mode 100644 index c33134f19..000000000 --- a/docs/extend/update-1_x.md +++ /dev/null @@ -1,139 +0,0 @@ -# Updating For 1.x - -:::tip - -If you need help applying these changes or using new features, please start a discussion on the [community forum](https://discuss.flarum.org/t/extensibility) or [Discord chat](https://flarum.org/discord/). - -::: - -## 1.7 - -### Frontend - -- Frontend extenders similar to the backend have been added, we highly recommend using them instead of the old method (https://docs.flarum.org/extend/models#adding-new-models-1), (https://docs.flarum.org/extend/routes#frontend-routes). -- There is a new tag selection component (https://github.com/flarum/framework/blob/360a2ba1d886df3fc6d326be932c5431ee9df8cf/extensions/tags/js/src/common/components/TagSelectionModal.tsx). - -### Backend - -- Support for php 8.2, deprecations are still thrown by some packages, the testing workflow has been updated to ignore deprecations on 8.2 -- The `/api` endpoint now contains the actor as an included relationship. -- The `Model::dateAttribute($attribute)` extender is deprecated, use `Model::cast($attribute, 'datetime')` instead. - -### Tooling - -- New `phpstan` package to run static code analysis on your extensions for code safety (https://docs.flarum.org/extend/static-code-analysis). -- New `jest-config` package to run frontend unit and component tests (https://docs.flarum.org/extend/testing). - -## 1.6 Changes - -### Backend - -- Added customizable session drivers through the `Session` extender. - -## 1.5 Changes - -### Frontend - -- More portions of the frontend are now written in TypeScript, providing a better extension development experience. -- Modals can be used in stacks, allowing for multiple modals to be open at once. This is useful for modals that open other modals: `app.modal.show(ModalComponent, { attrs }, true)`. - -### Backend - -- There is a new `createTableIfNotExists` migration helper, which can be used to create tables only if they don't already exist. - -## 1.4 Changes - -No developer-facing changes were made in Flarum v1.4. - -## 1.3 Changes - -Flarum v1.3 included mostly QoL improvements. Below are listed note worthy changes for extension developers: - -### Frontend - -- More portions of the frontend are now written in TypeScript, providing a better extension development experience. -- Frontend errors will no longer cause the forum to crash into a NoJs page. The extension will fail to execute its frontend code (initializer) and display an error to the admin through an alert, and to all users through the console. The frontend will continue to execute without the extension's frontend changes. -- The markdown toolbar can now be used on the admin side. - -### Backend - -- Calculation of the `number` attribute on `posts` has changed and no longer relies on the discussion model's `post_number_index` attribute which is now deprecated and will be removed in 2.0 -- Extension event listeners are now added after core even listeners, this should generally cause no changes in behavior. - -### Tooling - -- The backend tests workflow now automatically fails tests if there are any PHP warnings and notices. - -## 1.2 Changes - -Flarum v1.2 included quite a few bugfixes, internal refactors, and new features. The following recaps the most important changes for extension developers: - -### Frontend - -- Flarum core now passes TypeScript type checking (on the portion written in TypeScript). Additionally, major portions of the frontend (models, the application instance, and others) are now written in TypeScript. These changes should make it much easier and more fruitful to write extensions in TypeScript. -- Instead of directly using Less variables in CSS code, core now uses CSS variables. For the most part, we've just created CSS variables and set their values to the Less variables. This should make theming and customizing CSS a lot easier. https://github.com/flarum/core/pull/3146. -- Dropdowns can now be lazy-drawn to improve performance. You can do this by setting the lazy draw attr to "true". https://github.com/flarum/core/pull/2925. -- [Textarea-type settings](https://github.com/flarum/core/pull/3141) are now supported through the `app.extensionData.registerSetting` util. -- You can now use Webpack 5 to bundle your extension's code. This will offer minor bundle size improvements. -- A new `flarum/common/components/ColorPreviewInput` component [has been added](https://github.com/flarum/core/pull/3140). It can be used directly, or through the `color-preview` type when registered via `app.extensionData.registerSetting`. -- Extensions can now [modify the minimum search length](https://github.com/flarum/core/pull/3130) of the `Search` component. -- The following components are now extensible: - - The "colors" part of the Appearance page in the admin dashboard: https://github.com/flarum/core/pull/3186 - - The `StatusWidget` dropdown in the admin dashboard: https://github.com/flarum/core/pull/3189 - - `primaryControls` in the notification list: https://github.com/flarum/core/pull/3204 -- Extensions now have finer control over positioning when adding elements to the DiscussionPage sidebar items: https://github.com/flarum/core/pull/3165 -- - -### Backend - -- An [extender for settings defaults](https://github.com/flarum/core/pull/3127) has been added. This should be used instead of the `addSettings` migration helper, which has been deprecated. -- You can now [generate Less variables from setting values](https://github.com/flarum/core/pull/3011) via the `Settings` extender. -- Extensions can now [override/supplement blade template namespaces](https://github.com/flarum/core/pull/3167) through the `View` extender, meaning custom blade templates can be used instead of the default ones added by core or extensions. -- You can now define [custom Less functions through PHP](https://github.com/flarum/core/pull/3190), allowing you to use some backend logic in your Less theming. -- Extensions can now create [custom page title drivers](https://github.com/flarum/core/pull/3109/files) for titles in server-returned HTML. -- Custom logic can now be used when [deciding which relations to eager-load](https://github.com/flarum/core/pull/3116). -- Events [are now dispatched](https://github.com/flarum/core/pull/3203) for the `Notification\Read` and `Notification\ReadAll` events. -- An `ImageManager` instance is now [bound into the container](https://github.com/flarum/core/pull/3195), and can be configured to use either the `gd` or `imagick` backing drivers via the `"intervention.driver"` key in `config.php`. -- User IP addresses are now passed to the [API Client](https://github.com/flarum/core/pull/3124). -- A custom revision versioner implentation [can be set via container bindings](https://github.com/flarum/core/pull/3183) to customize how asset versions are named. -- A SlugManager instance [is now available](https://github.com/flarum/core/pull/3194) in blade templates via the `slugManager` variable. - -### Misc - -- Translations now support the `zero`, `one`, `two`, `few`, and `many` localized plural rules for `plural` ICU MessageFormat translations. This was done through the [`Intl.PluralRules` helper](https://github.com/flarum/core/issues/3072). -- Translations are now used for page titles, so that the format can be customized via language packs or [Linguist](https://discuss.flarum.org/d/7026-linguist-customize-translations-with-ease): https://github.com/flarum/core/pull/3077, https://github.com/flarum/core/pull/3228 -- API endpoints for retrieving single groups, as well as support for filtering groups on the plural get endpoint, [have been added](https://github.com/flarum/core/pull/3084). - - -### Tooling - - -- The `flarum-cli infra` command can now be used to update or enable various infrastructure features. You can now add the following to your extension in just one command: - - TypeScript - - Prettier for JS/TS formatting - - Backend testing with PHPUnit - - Code formatting with StyleCI - - EditorConfig support - - GitHub actions for automating testing, linting, type checking, and building. -- You can also exclude any files from these updates by adding their relative path to the "extra.flarum-cli" key's array in your extension's `composer.json` file. For example, if you wanted to exclude your tsconfig file from any updates by the infra system, the "extra.flarum-cli" key's value should be `["js/tsconfig.json"]`. -- The `flarum-cli audit infra` can be used to check that all infra modules your extension uses are up to date. The `--fix` flag can be used to automatically fix any issues, which has essentially the same effect as running `flarum-cli infra` for each outdated module. -- All `flarum-cli` commands can now be run with a `--no-interaction` flag to prevent prompts. Defaults will be used when possible, and errors will be thrown if a prompt is needed and there is no default. -- Frontend GH actions now support type-checking, as well as type coverage reports. - -## 1.1 Changes - -Flarum version 1.1 mostly focuses on bugfixes and quality-of-life improvements following our stable release earlier this year. These are mainly user-facing and internal infrastructure changes, so extensions are not significantly affected. - -### Frontend - -- Flarum now has an organization-wide prettier config package under [`@flarum/prettier-config`](https://github.com/flarum/prettier-config). -- Most custom (setting or data based) coloring in core is now done via [CSS custom properties](https://github.com/flarum/core/pull/3001). -- Typehinting for Flarum's globals are now [supported in extensions](https://github.com/flarum/core/pull/2992). -- You can now pass extra attrs to the `Select` component, and they will be [passed through to the DOM](https://github.com/flarum/core/pull/2959). -- The `DiscussionPage` component is now organized [as an item list](https://github.com/flarum/core/pull/3004), so it's easier for extensions to change its content. -- Extensions [can now edit](https://github.com/flarum/core/pull/2935) the `page` parameter of `PaginatedListState`. - -### Backend - -- Flarum now comes with a [Preload extender](https://github.com/flarum/core/pull/3057) for preloading any custom frontend assets. -- A new [Theme](https://github.com/flarum/core/pull/3008) extender now allows overriding Less files and internal imports. This allows themes to more easily completely replace Less modules. diff --git a/docs/extend/update-2_0-api.md b/docs/extend/update-2_0-api.md new file mode 100644 index 000000000..e672b026c --- /dev/null +++ b/docs/extend/update-2_0-api.md @@ -0,0 +1,1858 @@ +# Upgrading to 2.0 API Layer + +This guide is meant to show examples of different scenarios that you might encounter while upgrading your JSON:API implementation from Flarum 1.x to 2.x. + +## API Layer From 1.x to 2.x (fof/drafts) + +We will use the drafts extension as an example, the changes [from this PR](https://github.com/FriendsOfFlarum/drafts/pull/103/files#diff-61a49159d1d76521b7a75a0ce1f3a7847e5059e8cc37ff10aa7abadc73b7f919) will be used as a reference for this section. + +* The [1.x compatible version](https://github.com/FriendsOfFlarum/drafts/tree/master/src/Api) of drafts has the following for its API implementation: + * Controllers: `CreateDraftController`, `DeleteDraftController`, `ListDraftsController`, `ShowDraftController`, `UpdateDraftController`, `DeleteMyDraftsController`. + * A serializer: `DraftSerializer`. + * Command handlers: `CreateDraftHandler`, `DeleteDraftHandler`, `UpdateDraftHandler`. +* The [2.x compatible version](https://github.com/SychO9/drafts/tree/sm/2.0) only has the following 128 lines ApiResource class: + * `DraftResource`. + +Lets go through the process of converting the 1.x version to 2.x. + +### Starting with the Serializer + +The first thing we need to do is look at the fields (attributes and relationships) exposed from the serializer: + +```php +class DraftSerializer extends AbstractSerializer +{ + /** + * {@inheritdoc} + */ + protected $type = 'drafts'; + + /** + * @param \FoF\Drafts\Draft $draft + */ + protected function getDefaultAttributes($draft) + { + return [ + 'title' => $draft->title, + 'content' => $draft->content, + 'extra' => $draft->extra ? json_decode($draft->extra) : null, + 'scheduledValidationError' => $draft->scheduled_validation_error, + 'scheduledFor' => $this->formatDate($draft->scheduled_for), + 'updatedAt' => $this->formatDate($draft->updated_at), + ]; + } + + /** + * @return \Tobscure\JsonApi\Relationship + */ + protected function user($draft) + { + return $this->hasOne($draft, BasicUserSerializer::class); + } +} +``` + +We have the following fields: +* `title` (string) +* `content` (string) +* `extra` (array) +* `scheduledValidationError` (string) +* `scheduledFor` (DateTime) +* `updatedAt` (DateTime) +* `user` (one-to-one relationship) + +We can already start filling these [fields](https://docs.flarum.org/2.x/extend/api#fields-attributes-and-relationships) in the DraftResource class, all we know so far about these fields is that they are visible (serialized) and that they all directly point to the equivalent snake case model attribute. + +We also know from the serializer that the `type` of this resource is: `drafts` and the model is `Draft`. + +```php +/** + * @extends Resource\AbstractDatabaseResource + */ +class DraftResource extends Resource\AbstractDatabaseResource +{ + public function type(): string + { + // insert-next-line + return 'drafts'; + } + + public function model(): string + { + // insert-next-line + return Draft::class; + } + + public function endpoints(): array + { + return [ + // + ]; + } + + public function fields(): array + { + return [ + // insert-start + Schema\Str::make('title'), + Schema\Str::make('content'), + Schema\Arr::make('extra'), + Schema\Str::make('scheduledValidationError'), + Schema\DateTime::make('scheduledFor'), + Schema\DateTime::make('updatedAt'), + + Schema\Relationship\ToOne::make('user') + ->includable() + ->inverse('drafts') + ->type('users'), + // insert-end + ]; + } +} +``` + +Let's now look into the different endpoints, what fields they change and how they do so. + +### Creation endpoint + +Starting with the creation endpoint (`CreateDraftController` and the logic in `CreateDraftHandler`). + +```php +class CreateDraftController extends AbstractCreateController +{ + public $serializer = DraftSerializer::class; + + public $include = [ + 'user', + ]; + + protected $bus; + + public function __construct(Dispatcher $bus) + { + $this->bus = $bus; + } + + protected function data(ServerRequestInterface $request, Document $document) + { + $actor = RequestUtil::getActor($request); + $ipAddress = $request->getAttribute('ipAddress'); + + return $this->bus->dispatch( + new CreateDraft($actor, Arr::get($request->getParsedBody(), 'data', []), $ipAddress) + ); + } +} + +class CreateDraftHandler +{ + use Scheduled; + + public function handle(CreateDraft $command) + { + $actor = $command->actor; + $data = $command->data; + $attributes = Arr::get($data, 'attributes', []); + + $actor->assertCan('user.saveDrafts'); + + $draft = new Draft(); + + $draft->user_id = $actor->id; + $draft->title = Arr::pull($attributes, 'title'); + $draft->content = Arr::pull($attributes, 'content'); + + $draft->extra = count($attributes) > 0 ? json_encode($attributes) : null; + $draft->scheduled_for = $this->getScheduledFor($attributes, $actor); + $draft->updated_at = Carbon::now(); + $draft->ip_address = $command->ipAddress; + + if (Arr::has($attributes, 'clearValidationError')) { + $draft->scheduled_validation_error = ''; + } + + $draft->save(); + + return $draft; + } +} + +trait Scheduled +{ + protected function getScheduledFor(array $attributes, User $actor): ?Carbon + { + $scheduled = Arr::get($attributes, 'scheduledFor'); + + if ($scheduled && $actor->can('user.scheduleDrafts')) { + return Carbon::parse($scheduled); + } + + return null; + } +} +``` + +If there was any validation we would take note of the rules for each field, in this case it's more straightforward, so what we know is: +* The endpoint is only accessible to users with the `user.saveDrafts` permission. (safe to assume only logged-in users as well). +* We are including the `user` relationship by default. +* The `user_id` field is always the actor's ID. +* The `title` field is a nullable string that can be set on creation. +* The `content` field is a nullable string that can be set on creation. +* The `extra` field is an nullable array that can be set on creation. +* The `scheduled_for` field is an nullable DateTime only filled if the actor can schedule drafts, that can be set on creation. +* The `updated_at` field is always the current time. +* The `ip_address` field is the IP address from the request. +* The `scheduled_validation_error` field is cleared if the `clearValidationError` attribute is present. + +This leads us to the following changes on the `DraftResource` class: + +```php +/** + * @extends Resource\AbstractDatabaseResource + */ +class DraftResource extends Resource\AbstractDatabaseResource +{ + public function type(): string + { + return 'drafts'; + } + + public function model(): string + { + return Draft::class; + } + + public function endpoints(): array + { + return [ + // insert-start + Endpoint\Create::make() + ->authenticated() + ->can('user.saveDrafts') + ->defaultInclude(['user']), + // insert-end + ]; + } + + public function fields(): array + { + return [ + Schema\Str::make('title') + // insert-start + ->nullable() + ->writableOnCreate(), + // insert-end + Schema\Str::make('content') + // insert-start + ->nullable() + ->writableOnCreate(), + // insert-end + Schema\Arr::make('extra') + // insert-start + ->nullable() + ->writableOnCreate(), + // insert-end + Schema\Str::make('scheduledValidationError'), + Schema\DateTime::make('scheduledFor') + // insert-start + ->nullable() + ->writable(function (Draft $draft, Context $context) { + return $context->creating(self::class) && $context->getActor()->can('user.scheduleDrafts'); + }), + // insert-end + Schema\DateTime::make('updatedAt'), + // insert-start + Schema\Boolean::make('clearValidationError') + ->writableOnCreate() + ->set(function (Draft $draft, bool $value) { + if ($value) { + $draft->scheduled_validation_error = ''; + } + }), + // insert-end + + Schema\Relationship\ToOne::make('user') + ->includable() + ->inverse('drafts') + ->type('users'), + ]; + } + + // insert-start + public function creating(object $model, OriginalContext $context): ?object + { + $model->user_id = $context->getActor()->id; + $model->ip_address = $context->request->getAttribute('ipAddress'); + $model->updated_at = Carbon::now(); + + return $model; + } + // insert-end +} +``` + +### Update endpoint + +Moving on to the update endpoint (`UpdateDraftController` and the logic in `UpdateDraftHandler`). + +```php +class UpdateDraftController extends AbstractShowController +{ + public $serializer = DraftSerializer::class; + + protected $bus; + + public function __construct(Dispatcher $bus) + { + $this->bus = $bus; + } + + protected function data(ServerRequestInterface $request, Document $document) + { + $actor = RequestUtil::getActor($request); + $ipAddress = $request->getAttribute('ipAddress'); + + return $this->bus->dispatch( + new UpdateDraft(Arr::get($request->getQueryParams(), 'id'), $actor, Arr::get($request->getParsedBody(), 'data', []), $ipAddress) + ); + } +} + +class UpdateDraftHandler +{ + use Scheduled; + + public function handle(UpdateDraft $command) + { + $actor = $command->actor; + $data = $command->data; + + $draft = Draft::findOrFail($command->draftId); + + if (intval($actor->id) !== intval($draft->user_id)) { + throw new PermissionDeniedException(); + } + + $actor->assertCan('user.saveDrafts'); + + $attributes = Arr::get($data, 'attributes', []); + + if ($title = Arr::get($attributes, 'title')) { + $draft->title = $title; + } + + if ($content = Arr::get($attributes, 'content')) { + $draft->content = $content; + } + + if ($extra = Arr::get($attributes, 'extra')) { + $draft->extra = json_encode($extra); + } + + if (Arr::has($attributes, 'clearValidationError')) { + $draft->scheduled_validation_error = ''; + } + + $draft->scheduled_for = $this->getScheduledFor($attributes, $actor); + $draft->ip_address = $command->ipAddress; + $draft->updated_at = Carbon::now(); + + $draft->save(); + + return $draft; + } +} +``` + +Still no validation, but if there was we would take note of it for each field, in this case it's more straightforward, so what we know is: +* Only the draft owner can update the draft. (safe to assume only logged-in users). +* The endpoint is only accessible to users with the `user.saveDrafts` permission. +* The `title` field can be optionally updated (not required in this endpoint). +* The `content` field can be optionally updated (not required in this endpoint). +* The `extra` field can be optionally updated (not required in this endpoint). +* The `scheduled_for` field can be optionally updated (not required in this endpoint). +* The `updated_at` field is always the current time. +* The `ip_address` field is the IP address from the request. +* The `scheduled_validation_error` field is cleared if the `clearValidationError` attribute is present. + +This leads us to the following changes on the `DraftResource` class: + +```php +/** + * @extends Resource\AbstractDatabaseResource + */ +class DraftResource extends Resource\AbstractDatabaseResource +{ + public function type(): string + { + return 'drafts'; + } + + public function model(): string + { + return Draft::class; + } + + public function endpoints(): array + { + return [ + Endpoint\Create::make() + ->authenticated() + ->can('user.saveDrafts') + ->defaultInclude(['user']), + // insert-start + Endpoint\Update::make() + ->authenticated() + ->can('user.saveDrafts') + ->visible(fn (Draft $draft, Context $context) => $context->getActor()->id === $draft->user_id), + // insert-end + ]; + } + + public function fields(): array + { + return [ + Schema\Str::make('title') + ->nullable() + // remove-next-line + ->writableOnCreate(), + // insert-next-line + ->writable(), + Schema\Str::make('content') + ->nullable() + // remove-next-line + ->writableOnCreate(), + // insert-next-line + ->writable(), + Schema\Arr::make('extra') + ->nullable() + // remove-next-line + ->writableOnCreate(), + // insert-next-line + ->writable(), + Schema\Str::make('scheduledValidationError'), + Schema\DateTime::make('scheduledFor') + ->nullable() + ->writable(function (Draft $draft, Context $context) { + // remove-next-line + return $context->creating(self::class) && $context->getActor()->can('user.scheduleDrafts'); + // insert-next-line + return $context->getActor()->can('user.scheduleDrafts'); + }), + Schema\DateTime::make('updatedAt'), + Schema\Boolean::make('clearValidationError') + // remove-next-line + ->writableOnCreate(), + // insert-next-line + ->writable(), + ->set(function (Draft $draft, bool $value) { + if ($value) { + $draft->scheduled_validation_error = ''; + } + }), + + Schema\Relationship\ToOne::make('user') + ->includable() + ->inverse('drafts') + ->type('users'), + ]; + } + + public function creating(object $model, OriginalContext $context): ?object + { + $model->user_id = $context->getActor()->id; + $model->ip_address = $context->request->getAttribute('ipAddress'); + $model->updated_at = Carbon::now(); + + return $model; + } + + // insert-start + public function updating(object $model, OriginalContext $context): ?object + { + $model->ip_address = $context->request->getAttribute('ipAddress'); + $model->updated_at = Carbon::now(); + + return $model; + } + // insert-end +} +``` + +### Deletion endpoint + +Onto the deletion endpoint (`DeleteDraftController` and the logic in `DeleteDraftHandler`). + +```php +class DeleteDraftController extends AbstractDeleteController +{ + protected $bus; + + public function __construct(Dispatcher $bus) + { + $this->bus = $bus; + } + + protected function delete(ServerRequestInterface $request) + { + $actor = RequestUtil::getActor($request); + + $this->bus->dispatch( + new DeleteDraft(Arr::get($request->getQueryParams(), 'id'), $actor) + ); + } +} + +class DeleteDraftHandler +{ + public function handle(DeleteDraft $command) + { + $actor = $command->actor; + + $draft = Draft::findOrFail($command->draftId); + + if (strval($actor->id) !== strval($draft->user_id)) { + throw new PermissionDeniedException(); + } + $draft->delete(); + + return $draft; + } +} +``` + +Usually, the deletion endpoint is the simplest one, in this case, we know that: +* Only the draft owner can delete the draft. (safe to assume only logged-in users). + +This leads us to the following changes on the `DraftResource` class: + +```php +/** + * @extends Resource\AbstractDatabaseResource + */ +class DraftResource extends Resource\AbstractDatabaseResource +{ + public function type(): string + { + return 'drafts'; + } + + public function model(): string + { + return Draft::class; + } + + public function endpoints(): array + { + return [ + Endpoint\Create::make() + ->authenticated() + ->can('user.saveDrafts') + ->defaultInclude(['user']), + Endpoint\Update::make() + ->authenticated() + ->can('user.saveDrafts') + ->visible(fn (Draft $draft, Context $context) => $context->getActor()->id === $draft->user_id), + // insert-start + Endpoint\Delete::make() + ->authenticated() + ->visible(fn (Draft $draft, Context $context) => $context->getActor()->id === $draft->user_id), + // insert-end + ]; + } + + public function fields(): array + { + return [ + Schema\Str::make('title') + ->nullable() + ->writable(), + Schema\Str::make('content') + ->nullable() + ->writable(), + Schema\Arr::make('extra') + ->nullable() + ->writable(), + Schema\Str::make('scheduledValidationError'), + Schema\DateTime::make('scheduledFor') + ->nullable() + ->writable(function (Draft $draft, Context $context) { + return $context->getActor()->can('user.scheduleDrafts'); + }), + Schema\DateTime::make('updatedAt'), + Schema\Boolean::make('clearValidationError') + ->writable(), + ->set(function (Draft $draft, bool $value) { + if ($value) { + $draft->scheduled_validation_error = ''; + } + }), + + Schema\Relationship\ToOne::make('user') + ->includable() + ->inverse('drafts') + ->type('users'), + ]; + } + + public function creating(object $model, OriginalContext $context): ?object + { + $model->user_id = $context->getActor()->id; + $model->ip_address = $context->request->getAttribute('ipAddress'); + $model->updated_at = Carbon::now(); + + return $model; + } + + public function updating(object $model, OriginalContext $context): ?object + { + $model->ip_address = $context->request->getAttribute('ipAddress'); + $model->updated_at = Carbon::now(); + + return $model; + } +} +``` + +### Listing endpoint + +Lastly, the listing endpoint (`ListDraftsController`). + +```php +class ListDraftsController extends AbstractListController +{ + public $serializer = DraftSerializer::class; + + public $include = [ + 'user', + ]; + + protected function data(ServerRequestInterface $request, Document $document) + { + /** + * @var User + */ + $actor = RequestUtil::getActor($request); + + $actor->assertCan('user.saveDrafts'); + + return Draft::where('user_id', $actor->id)->get(); + } +} +``` + +In this case, we know that: +* We are including the `user` relationship by default. +* The endpoint is only accessible to users with the `user.saveDrafts` permission. (safe to assume only logged-in users). +* We are only listing drafts that belong to the actor. + +This leads to the following changes: + +```php +/** + * @extends Resource\AbstractDatabaseResource + */ +class DraftResource extends Resource\AbstractDatabaseResource +{ + public function type(): string + { + return 'drafts'; + } + + public function model(): string + { + return Draft::class; + } + + // insert-start + public function scope(Builder $query, OriginalContext $context): void + { + $query->where('user_id', $context->getActor()->id); + } + // insert-end + + public function endpoints(): array + { + return [ + Endpoint\Create::make() + ->authenticated() + ->can('user.saveDrafts') + ->defaultInclude(['user']), + Endpoint\Update::make() + ->authenticated() + ->can('user.saveDrafts') + ->visible(fn (Draft $draft, Context $context) => $context->getActor()->id === $draft->user_id), + Endpoint\Delete::make() + ->authenticated() + ->visible(fn (Draft $draft, Context $context) => $context->getActor()->id === $draft->user_id), + // insert-start + Endpoint\Index::make() + ->authenticated() + ->can('user.saveDrafts') + ->defaultInclude(['user']), + // insert-end + ]; + } + + public function fields(): array + { + return [ + Schema\Str::make('title') + ->nullable() + ->writable(), + Schema\Str::make('content') + ->nullable() + ->writable(), + Schema\Arr::make('extra') + ->nullable() + ->writable(), + Schema\Str::make('scheduledValidationError'), + Schema\DateTime::make('scheduledFor') + ->nullable() + ->writable(function (Draft $draft, Context $context) { + return $context->getActor()->can('user.scheduleDrafts'); + }), + Schema\DateTime::make('updatedAt'), + Schema\Boolean::make('clearValidationError') + ->writable(), + ->set(function (Draft $draft, bool $value) { + if ($value) { + $draft->scheduled_validation_error = ''; + } + }), + + Schema\Relationship\ToOne::make('user') + ->includable() + ->inverse('drafts') + ->type('users'), + ]; + } + + public function creating(object $model, OriginalContext $context): ?object + { + $model->user_id = $context->getActor()->id; + $model->ip_address = $context->request->getAttribute('ipAddress'); + $model->updated_at = Carbon::now(); + + return $model; + } + + public function updating(object $model, OriginalContext $context): ?object + { + $model->ip_address = $context->request->getAttribute('ipAddress'); + $model->updated_at = Carbon::now(); + + return $model; + } +} +``` + +### Custom delete my drafts endpoint + +The last endpoint we will cover is the `DeleteMyDraftsController`. This is a custom endpoint that deletes all drafts for the current user. + +```php +class DeleteMyDraftsController extends AbstractDeleteController +{ + protected $bus; + + public function __construct(Dispatcher $bus) + { + $this->bus = $bus; + } + + protected function delete(ServerRequestInterface $request) + { + $actor = RequestUtil::getActor($request); + + $actor->drafts()->delete(); + } +} + +// from extend.php +(new Extend\Routes('api')) + ->get('/drafts', 'fof.drafts.index', Controller\ListDraftsController::class) + ->post('/drafts', 'fof.drafts.create', Controller\CreateDraftController::class) + ->delete('/drafts/all', 'fof.drafts.delete.all', Controller\DeleteMyDraftsController::class) + ->patch('/drafts/{id}', 'fof.drafts.update', Controller\UpdateDraftController::class) + ->delete('/drafts/{id}', 'fof.drafts.delete', Controller\DeleteDraftController::class), +``` + +In this case, we know that: +* The endpoint is only accessible to logged-in users. +* The endpoint deletes all drafts for the current user. +* This is a `DELETE` endpoint with the route `/drafts/all` and named `fof.drafts.delete.all`. +* This endpoint is not specific to a single draft model. + +:::danger + +To prevent this custom endpoint `DELETE /api/drafts/all` from conflicting with the existing one `DELETE /api/drafts/:id` endpoint, you should add the custom endpoint before the default delete endpoint. + +::: + +This leads to the following changes: + +```php +/** + * @extends Resource\AbstractDatabaseResource + */ +class DraftResource extends Resource\AbstractDatabaseResource +{ + public function type(): string + { + return 'drafts'; + } + + public function model(): string + { + return Draft::class; + } + + public function scope(Builder $query, OriginalContext $context): void + { + $query->where('user_id', $context->getActor()->id); + } + + public function endpoints(): array + { + return [ + Endpoint\Create::make() + ->authenticated() + ->can('user.saveDrafts') + ->defaultInclude(['user']), + Endpoint\Update::make() + ->authenticated() + ->can('user.saveDrafts') + ->visible(fn (Draft $draft, Context $context) => $context->getActor()->id === $draft->user_id), + // insert-start + Endpoint\Endpoint::make('delete.all') + ->route('DELETE', '/all') + ->authenticated() + ->action(function (Context $context) { + $context->getActor()->drafts()->delete(); + }) + ->response(fn () => new EmptyResponse(204)), + // insert-end + Endpoint\Delete::make() + ->authenticated() + ->visible(fn (Draft $draft, Context $context) => $context->getActor()->id === $draft->user_id), + Endpoint\Index::make() + ->authenticated() + ->can('user.saveDrafts') + ->defaultInclude(['user']), + ]; + } + + public function fields(): array + { + return [ + Schema\Str::make('title') + ->nullable() + ->writable(), + Schema\Str::make('content') + ->nullable() + ->writable(), + Schema\Arr::make('extra') + ->nullable() + ->writable(), + Schema\Str::make('scheduledValidationError'), + Schema\DateTime::make('scheduledFor') + ->nullable() + ->writable(function (Draft $draft, Context $context) { + return $context->getActor()->can('user.scheduleDrafts'); + }), + Schema\DateTime::make('updatedAt'), + Schema\Boolean::make('clearValidationError') + ->writable(), + ->set(function (Draft $draft, bool $value) { + if ($value) { + $draft->scheduled_validation_error = ''; + } + }), + + Schema\Relationship\ToOne::make('user') + ->includable() + ->inverse('drafts') + ->type('users'), + ]; + } + + public function creating(object $model, OriginalContext $context): ?object + { + $model->user_id = $context->getActor()->id; + $model->ip_address = $context->request->getAttribute('ipAddress'); + $model->updated_at = Carbon::now(); + + return $model; + } + + public function updating(object $model, OriginalContext $context): ?object + { + $model->ip_address = $context->request->getAttribute('ipAddress'); + $model->updated_at = Carbon::now(); + + return $model; + } +} +``` + +### Additions/Improvements + +Here are some ways we can improve the implementation and good practices that we should generally follow: + +#### Visibility Scoper + +We can add then use a [visibility scope](https://docs.flarum.org/2.x/extend/model-visibility#registering-custom-scopers), which can be re-used inn different places or by other extensions without having to duplicate the logic. + +```php +class ScopeDraftVisibility +{ + public function __invoke(User $actor, Builder $query) + { + $query->where('user_id', $actor->id); + } +} + +class DraftResource extends Resource\AbstractDatabaseResource +{ + ... + + public function scope(Builder $query, OriginalContext $context): void + { + $query->whereVisibleTo($context->getActor()); + } + + ... +} +``` + +#### Policy + +We can also use a [policy](https://docs.flarum.org/2.x/extend/authorization#registering-policies) to handle the permissions logic, this can be re-used in different places or by other extensions without having to duplicate the logic. + +```php +class GlobalPolicy +{ + public function createDrafts(User $actor) + { + return $actor->hasPermission('user.saveDrafts'); + } +} + +class DraftPolicy +{ + public function update(User $actor, Draft $draft) + { + return $actor->id === $draft->user_id && $actor->hasPermission('user.saveDrafts'); + } +} + +class DraftResource extends Resource\AbstractDatabaseResource +{ + ... + + public function endpoints(): array + { + return [ + Endpoint\Create::make() + ->authenticated() + // no specific model is related to this endpoint, + // so this will go to the global policies + // equivalent to: $actor->can('createDrafts') + ->can('createDrafts') + ->defaultInclude(['user']), + Endpoint\Update::make() + ->authenticated() + // this will go to the DraftPolicy for the related draft model. + // equivalent to: $actor->can('update', $draft) + ->can('update') + ->visible(fn (Draft $draft, Context $context) => $context->getActor()->id === $draft->user_id), + ... + ]; + } + + ... +} +``` + +#### Validation + +We can add additional appropriate validation rules to the fields, for example, the `title` field can have a maximum length of 255 characters as it is a `varchar` field in the MySQL database. + +The content field can be changed to required, and have a maximum length of 65535 characters as it is a `text` field in the MySQL database. + +```php +class DraftResource extends Resource\AbstractDatabaseResource +{ + ... + + public function fields(): array + { + return [ + Schema\Str::make('title') + ->nullable() + ->writable() + ->maxLength(255), + Schema\Str::make('content') + ->requiredOnCreate() + ->maxLength(65535) + ->writable(), + ... + ]; + } + + ... +} +``` + +### Pagination + +The drafts extension assumes that drafts will not exceed an unreasonable amount, but better be safe than sorry, we can add pagination to the listing endpoint. + +```php +class DraftResource extends Resource\AbstractDatabaseResource +{ + ... + + public function endpoints(): array + { + return [ + ... + Endpoint\Index::make() + ->authenticated() + ->can('user.saveDrafts') + ->defaultInclude(['user']) + ->paginate(20, 50), // default is 20 items per page, maximum is 50 + ]; + } + + ... +} +``` + +## API Layer From 1.x to 2.x (fof/gamification) + + + +## Extending an existing API Layer + +If you are using the `ApiController` or `ApiSerializer` extenders from 1.x, you can migrate the logic to using the `ApiResource` extender from 2.x, which uses the same field and endpoint definitions as shown before. For example, we have the following 1.x extenders: + +### Exposing Attributes + +The following is a basic example from the fof/drafts extension: + +```php +(new Extend\ApiSerializer(CurrentUserSerializer::class)) + ->attributes(function (CurrentUserSerializer $serializer) { + $attributes['draftCount'] = (int) Draft::where('user_id', $serializer->getActor()->id)->count(); + + return $attributes; + }), + +(new Extend\ApiSerializer(ForumSerializer::class)) + ->attributes(function (ForumSerializer $serializer) { + $attributes['canSaveDrafts'] = $serializer->getActor()->hasPermissionLike('user.saveDrafts'); + $attributes['canScheduleDrafts'] = $serializer->getActor()->hasPermissionLike('user.scheduleDrafts'); + + return $attributes; + }), +``` + +The equivalent 2.x implementation would be: + +```php +(new Extend\ApiResource(Resource\UserResource::class)) + ->fields(fn () => [ + Schema\Number::make('draftCount') + ->visible(fn (User $user, Context $context) => $context->getActor()->id === $user->id) + ->countRelation('drafts', function (Builder $query, Context $context) { + $query->whereVisibleTo($context->getActor()); // visibility scope ;) + }), + ]), + +(new Extend\ApiResource(Resource\ForumResource::class)) + ->fields(fn () => [ + Schema\Boolean::make('canSaveDrafts') + ->get(function (object $forum, Context $context) { + return $context->getActor()->hasPermissionLike('user.saveDrafts'); + }), + Schema\Boolean::make('canScheduleDrafts') + ->get(function (object $forum, Context $context) { + return $context->getActor()->hasPermissionLike('user.scheduleDrafts'); + }), + ]), +``` + +:::info + +Notice how for the `draftCount` attribute, we added the visibility check: + +`$context->getActor()->id === $user->id` + +because in 1.x the attribute was added to the `CurrentUserSerializer`. + +::: + +:::info + +Notice how instead of using a `get` accessor like this: + +```php +->get(fn (User $user, Context $context) => Draft::where('user_id', $context->getActor()->id)->count() +``` + +we used [the relationship aggregate `countRelation` method](http://localhost:3000/2.x/extend/api#relationship-aggregates) which does the same but far more efficiently, without creating a query for each model in the response. + +::: + +### Saving data + +In 1.x to save additional data for an existing model (like posts or discussions) you would listen to the `Saving` event of that model. + +For example, this is how the `fof/gamification` extension saves the upvote or downvote: + +```js +// Frontend saving trigger +function saveVote(post, upvoted, downvoted) { + return post.save([upvoted, downvoted, 'vote']); +} + +saveVote(post, true, false); // upvoting +saveVote(post, false, true); // downvoting +saveVote(post, false, false); // removing vote +``` + +This would send the following payload: + +```json +{ + "data": { + "type": "posts", + "attributes": [ + true, + false, + "vote" + ], + "id": "199067" + } +} +``` + +:::caution + +The attributes value is not conventional and will not work in 2.0 which is stricter. + +::: + +And would be saved through the following logic: + +```php +// Backend Listener +use Flarum\Post\Event\Saving; + +public function handle(Saving $event) +{ + $post = $event->post; + + if ($post->exists()) { + $data = Arr::get($event->data, 'attributes', []); + + if (Arr::exists($data, 2) && Arr::get($data, 2) === 'vote') { + $actor = $event->actor; + $user = $post->user; + + $actor->assertCan('vote', $post); + + if ($this->settings->get('fof-gamification.rateLimit')) { + $this->assertNotFlooding($actor); + } + + $isUpvoted = Arr::get($data, 0, false); + + $isDownvoted = Arr::get($data, 1, false); + + $this->vote($post, $isDownvoted, $isUpvoted, $actor, $user); + } + } + + ... +} +``` + +In 2.0 doing this will not work, instead we need to add a new writable attribute that we can call `vote` and is hidden since we only need it to write data. + +```js +function saveVote(post, upvoted, downvoted) { + let action; + + switch (true) { + case (upvoted && downvoted) || (!upvoted && !downvoted): + action = null; // remove vote + break; + case upvoted: + action = 'up'; // upvoting + break; + case downvoted: + action = 'down'; // downvoting + break; + } + + return post.save({ vote: action }); +} +``` + +```php +Schema\Str::make('vote') + ->hidden() + ->writable(function (Post $post, Context $context) { + return $context->updating() + && $context->getActor()->can('vote', $post); + }) + ->in(['up', 'down']) + ->nullable() + ->set(function (Post $post, ?string $value, Context $context) { + if ($this->settings->get('fof-gamification.rateLimit')) { + $this->assertNotFlooding($context->getActor()); + } + + $this->vote($post, $value, $context->getActor()); + }), +``` + +We highly recommend moving any logic you have within a saving event listener to a new writable API field. Unless your logic is mutating data without relying on new information from the API. + +### Endpoints + +The following is a larger example from the fof/gamification extension: + +```php +// extend.php +return [ + ... + + // highlight-start + (new Extend\ApiController(Controller\ListUsersController::class)) + ->addInclude('ranks'), + + (new Extend\ApiController(Controller\ShowUserController::class)) + ->addInclude('ranks'), + + (new Extend\ApiController(Controller\CreateUserController::class)) + ->addInclude('ranks'), + + (new Extend\ApiController(Controller\UpdateUserController::class)) + ->addInclude('ranks'), + // highlight-end + + // highlight-start + (new Extend\ApiController(Controller\ShowDiscussionController::class)) + ->addInclude('posts.user.ranks') + ->loadWhere('posts.actualvotes', [LoadActorVoteRelationship::class, 'mutateRelation']) + ->prepareDataForSerialization([LoadActorVoteRelationship::class, 'sumRelation']), + + (new Extend\ApiController(Controller\ListDiscussionsController::class)) + ->addSortField('hotness') + ->addSortField('votes') + ->loadWhere('firstPost.actualvotes', [LoadActorVoteRelationship::class, 'mutateRelation']) + ->prepareDataForSerialization([LoadActorVoteRelationship::class, 'sumRelation']), + // highlight-end + + // highlight-start + (new Extend\ApiController(Controller\ListPostsController::class)) + ->addInclude('user.ranks') + ->addOptionalInclude(['upvotes', 'downvotes']) + ->loadWhere('actualvotes', [LoadActorVoteRelationship::class, 'mutateRelation']) + ->prepareDataForSerialization([LoadActorVoteRelationship::class, 'sumRelation']), + + (new Extend\ApiController(Controller\ShowPostController::class)) + ->addInclude('user.ranks') + ->addOptionalInclude(['upvotes', 'downvotes']) + ->loadWhere('actualvotes', [LoadActorVoteRelationship::class, 'mutateRelation']) + ->prepareDataForSerialization([LoadActorVoteRelationship::class, 'sumRelation']), + + (new Extend\ApiController(Controller\CreatePostController::class)) + ->addInclude('user.ranks') + ->addOptionalInclude(['upvotes', 'downvotes']), + + (new Extend\ApiController(Controller\UpdatePostController::class)) + ->addInclude('user.ranks') + ->addOptionalInclude(['upvotes', 'downvotes']) + ->loadWhere('actualvotes', [LoadActorVoteRelationship::class, 'mutateRelation']) + ->prepareDataForSerialization([LoadActorVoteRelationship::class, 'sumRelation']), + // highlight-end + + // highlight-start + (new Extend\ApiSerializer(Serializer\PostSerializer::class)) + ->attributes(function (PostSerializer $serializer, Post $post, array $attributes) { + $attributes['votes'] = $post->actualvotes_sum_value; + + return $attributes; + }), + // highlight-end + + ... +]; + +// src/LoadActorVoteRelationship.php +class LoadActorVoteRelationship +{ + public static function mutateRelation(HasMany $query, ServerRequestInterface $request): HasMany + { + $actor = RequestUtil::getActor($request); + + return $query + // So that we can tell if the current user has liked the post. + ->where('user_id', $actor->id); + } + + public static function sumRelation($controller, $data): void + { + $loadable = null; + + if ($data instanceof Discussion) { + $loadable = $data->newCollection($data->posts)->filter(function ($post) { + return $post instanceof Post; + }); + } elseif ($data instanceof Collection) { + $loadable = (new Post())->newCollection($data->map(function ($model) { + return $model instanceof Discussion ? ($model->mostRelevantPost ?? $model->firstPost) : $model; + })->filter()); + } elseif ($data instanceof Post) { + $loadable = $data->newCollection([$data]); + } + + if ($loadable && $loadable instanceof Collection) { + $loadable->loadSum('actualvotes', 'value'); + } + } +} +``` + +The equivalent in 2.x is a lot more simple and straightforward, but there are some crucial things to point out: + +```php +// extend.php +return [ + ... + + // highlight-start + (new Extend\ApiResource(Resource\UserResource::class)) + ->endpoint(['show', 'update', 'create', 'index'], function (Endpoint\Show|Endpoint\Update|Endpoint\Create|Endpoint\Index $endpoint) { + return $endpoint->addDefaultInclude(['ranks']); + }) + ->sorts(fn () => [ + SortColumn::make('votes') + ->visible(function (Context $context) { + return $context->getActor()->can('fof.gamification.viewRankingPage'); + }) + ]), + // highlight-end + + // highlight-start + (new Extend\ApiResource(Resource\DiscussionResource::class)) + ->sorts(fn () => [ + SortColumn::make('hotness'), + SortColumn::make('votes'), + ]) + ->endpoint('index', function (Endpoint\Index $endpoint) { + return $endpoint->eagerLoadWhere('firstPost.actualvotes', function ($query, Context $context) { + $query->where('user_id', $context->getActor()->id); + }); + }), + // highlight-end + + // highlight-start + (new Extend\ApiResource(Resource\PostResource::class)) + ->fields(fn () => [ + Schema\Number::make('votes') + ->sumRelation('actualvotes', 'value') + ]) + ->endpoint(['index', 'show', 'create', 'update'], function (Endpoint\Index|Endpoint\Show|Endpoint\Create|Endpoint\Update $endpoint) { + return $endpoint->addDefaultInclude(['user.ranks']); + }) + ->endpoint(['index', 'show', 'update'], function (Endpoint\Index|Endpoint\Show|Endpoint\Update $endpoint) { + return $endpoint->eagerLoadWhere('actualvotes', function ($query, Context $context) { + $query->where('user_id', $context->getActor()->id); + }); + }), + // highlight-end + + ... +]; +``` + +:::info + +Notice how we replaced the use of: + +`loadWhere('actualvotes', [LoadActorVoteRelationship::class, 'mutateRelation'])` + +with the mutation of the appropriate endpoints using the `eagerLoadWhere` method. + +::: + +:::info + +Notice how instead of converting to 2.x, we completely removed: + +```php +(new Extend\ApiController(Controller\ShowDiscussionController::class)) + ->addInclude('posts.user.ranks') + ->loadWhere('posts.actualvotes', [LoadActorVoteRelationship::class, 'mutateRelation']) + ->prepareDataForSerialization([LoadActorVoteRelationship::class, 'sumRelation']), +``` + +This is because in 2.x, the show discussion endpoint no longer tries to load the `posts` relation models, so it is enough to make such mutations to the list posts endpoint. + +::: + +:::info + +Notice how we replaced the use of: + +```php +->prepareDataForSerialization([LoadActorVoteRelationship::class, 'sumRelation'])` +``` +```php +$attributes['votes'] = $post->actualvotes_sum_value; +``` + +With [the relationship aggregate `sumRelation` method](http://localhost:3000/2.x/extend/api#relationship-aggregates) which does the same but in a more readable and flexible way: + +```php +Schema\Number::make('votes') + ->sumRelation('actualvotes', 'value') +``` + +::: + +## Additional Scenarios + +### Custom new model + +The flags extension create a new flag model uniquely by the user and the post, the following is the 1.x implementation: + +```php +class CreateFlagHandler +{ + ... + public function handle(CreateFlag $command) + { + $actor = $command->actor; + $data = $command->data; + + $postId = Arr::get($data, 'relationships.post.data.id'); + $post = $this->posts->findOrFail($postId, $actor); + + if (! ($post instanceof CommentPost)) { + throw new InvalidParameterException; + } + + $actor->assertCan('flag', $post); + + if ($actor->id === $post->user_id && ! $this->settings->get('flarum-flags.can_flag_own')) { + throw new PermissionDeniedException(); + } + + if (Arr::get($data, 'attributes.reason') === null && Arr::get($data, 'attributes.reasonDetail') === '') { + throw new ValidationException([ + 'message' => $this->translator->trans('flarum-flags.forum.flag_post.reason_missing_message') + ]); + } + + Flag::unguard(); + + // highlight-start + $flag = Flag::firstOrNew([ + 'post_id' => $post->id, + 'user_id' => $actor->id + ]); + // highlight-end + + $flag->post_id = $post->id; + $flag->user_id = $actor->id; + $flag->type = 'user'; + $flag->reason = Arr::get($data, 'attributes.reason'); + $flag->reason_detail = Arr::get($data, 'attributes.reasonDetail'); + $flag->created_at = Carbon::now(); + + $flag->save(); + + $this->events->dispatch(new Created($flag, $actor, $data)); + + return $flag; + } +} +``` + +In 2.x's `ApiResource` class, we can override the `newModel` method: + +```php +/** + * @extends AbstractDatabaseResource + */ +class FlagResource extends AbstractDatabaseResource +{ + // insert-start + public function newModel(Context $context): object + { + if ($context->creating(self::class)) { + Flag::unguard(); + + return Flag::query()->firstOrNew([ + 'post_id' => (int) Arr::get($context->body(), 'data.relationships.post.data.id'), + 'user_id' => $context->getActor()->id + ]); + } + + return parent::newModel($context); + } + // insert-end +} +``` + +### Setting a relationship + +The flags extension sets a relationship between the flag and the post, the following is the 1.x implementation: + +```php +class CreateFlagHandler +{ + ... + public function handle(CreateFlag $command) + { + $actor = $command->actor; + $data = $command->data; + + // highlight-start + $postId = Arr::get($data, 'relationships.post.data.id'); + $post = $this->posts->findOrFail($postId, $actor); + + if (! ($post instanceof CommentPost)) { + throw new InvalidParameterException; + } + + $actor->assertCan('flag', $post); + + if ($actor->id === $post->user_id && ! $this->settings->get('flarum-flags.can_flag_own')) { + throw new PermissionDeniedException(); + } + // highlight-end + + if (Arr::get($data, 'attributes.reason') === null && Arr::get($data, 'attributes.reasonDetail') === '') { + throw new ValidationException([ + 'message' => $this->translator->trans('flarum-flags.forum.flag_post.reason_missing_message') + ]); + } + + Flag::unguard(); + + $flag = Flag::firstOrNew([ + 'post_id' => $post->id, + 'user_id' => $actor->id + ]); + + // highlight-next-line + $flag->post_id = $post->id; + $flag->user_id = $actor->id; + $flag->type = 'user'; + $flag->reason = Arr::get($data, 'attributes.reason'); + $flag->reason_detail = Arr::get($data, 'attributes.reasonDetail'); + $flag->created_at = Carbon::now(); + + $flag->save(); + + $this->events->dispatch(new Created($flag, $actor, $data)); + + return $flag; + } +} +``` + +The equivalent 2.x implementation would be: + +```php +/** + * @extends AbstractDatabaseResource + */ +class FlagResource extends AbstractDatabaseResource +{ + public function fields(): array + { + return [ + ... + // insert-start + Schema\Relationship\ToOne::make('post') + ->includable() + ->writable(fn (Flag $flag, FlarumContext $context) => $context->creating()) + ->set(function (Flag $flag, Post $post, FlarumContext $context) { + if (! ($post instanceof CommentPost)) { + throw new InvalidParameterException; + } + + $actor = $context->getActor(); + + $actor->assertCan('flag', $post); + + if ($actor->id === $post->user_id && ! $this->settings->get('flarum-flags.can_flag_own')) { + throw new PermissionDeniedException; + } + + $flag->post_id = $post->id; + }), + // insert-end + ... + ]; + } +} +``` + +### Custom listing query + +When listing flags, the flags extension groups them up by `post_id`, but also sets the actor's `read_flags_at` field: + +```php +class ListFlagsController extends AbstractListController +{ + public $serializer = FlagSerializer::class; + + public $include = [ + 'user', + 'post', + 'post.user', + 'post.discussion' + ]; + + protected function data(ServerRequestInterface $request, Document $document) + { + $actor = RequestUtil::getActor($request); + $include = $this->extractInclude($request); + + $actor->assertRegistered(); + + // highlight-start + $actor->read_flags_at = Carbon::now(); + $actor->save(); + // highlight-end + + // highlight-start + $flags = Flag::whereVisibleTo($actor) + ->latest('flags.created_at') + ->groupBy('post_id') + ->get(); + // highlight-end + + if (in_array('post.user', $include)) { + $include[] = 'post.user.groups'; + } + + $this->loadRelations($flags, $include); + + return $flags; + } +} +``` + +We can accomplish this in 2.x through the `scope` method: + +```php +class FlagResource extends AbstractDatabaseResource +{ + ... + + // insert-start + public function scope(Builder $query, OriginalContext $context): void + { + $query->whereVisibleTo($actor); + + if ($context->listing(self::class)) { + $query->groupBy('post_id'); + } + } + // insert-end + + public function endpoints(): array + { + return [ + ... + Endpoint\Index::make() + ->authenticated() + ->defaultInclude(['user', 'post', 'post.user', 'post.discussion']) + // insert-next-line + ->defaultSort('-createdAt') + ->paginate() + // insert-start + ->after(function (FlarumContext $context, $data) { + $actor = $context->getActor(); + + $actor->read_flags_at = Carbon::now(); + $actor->save(); + + return $data; + }), + // insert-end + ... + ]; + } + + // insert-start + public function sorts(): array + { + return [ + SortColumn::make('createdAt'), + ]; + } + // insert-end + + ... +} +``` + +### Custom find query + +The core discussions support tag slugs, so the following api request is possible: `GET /api/discussions/1-discussion-title?bySlug` + +This is done by overriding the `find` method: + +```php +/** + * @extends AbstractDatabaseResource + */ +class DiscussionResource extends AbstractDatabaseResource +{ + ... + // insert-start + public function find(string $id, \Tobyz\JsonApiServer\Context $context): ?object + { + $actor = $context->getActor(); + + if (Arr::get($context->request->getQueryParams(), 'bySlug', false)) { + $discussion = $this->slugManager->forResource(Discussion::class)->fromSlug($id, $actor); + } else { + $discussion = $this->query($context)->findOrFail($id); + } + + return $discussion; + } + // insert-end + ... +} +``` + +### Sortmap + +In 1.x the sortmap for discussions was stored on the container which you had to extend to add new sort options to: + +```php +// core code from: Flarum\Forum\Content\Index +class Index +{ + ... + + public function __invoke(Document $document, Request $request) + { + $queryParams = $request->getQueryParams(); + + $sort = Arr::pull($queryParams, 'sort'); + $q = Arr::pull($queryParams, 'q'); + $page = max(1, intval(Arr::pull($queryParams, 'page'))); + $filters = Arr::pull($queryParams, 'filter', []); + + // highlight-next-line + $sortMap = resolve('flarum.forum.discussions.sortmap'); + + $params = [ + 'sort' => $sort && isset($sortMap[$sort]) ? $sortMap[$sort] : '', + 'filter' => $filters, + 'page' => ['offset' => ($page - 1) * 20, 'limit' => 20] + ]; + + if ($q) { + $params['filter']['q'] = $q; + } + + $apiDocument = $this->getApiDocument($request, $params); + ... + + return $document; + } + ... +} + +// extend.php +// highlight-start +(new Extend\ApiController(Controller\ListDiscussionsController::class)) + ->addSortField('hotness') + ->addSortField('votes'), +// highlight-end + +// custom provider +class CustomServiceProvider extends AbstractServiceProvider +{ + public function register() + { + // highlight-start + $this->container->extend('flarum.forum.discussions.sortmap', function (array $options) { + return array_merge($options, [ + 'votes' => '-votes', + 'hot' => '-hotness', + ]); + }); + // highlight-end + } +} +``` + +In 2.x you can achieve the same thing while adding the sort fields: + +```php +// extend.php +(new Extend\ApiResource(Resource\DiscussionResource::class)) + ->sorts(fn () => [ + SortColumn::make('votes') + ->descendingAlias('votes'), + SortColumn::make('hotness') + ->descendingAlias('hot'), + ]), + +// core code from: Flarum\Forum\Content\Index +class Index +{ + public function __construct( + ... + protected DiscussionResource $resource, + ) { + } + + public function __invoke(Document $document, Request $request) + { + $queryParams = $request->getQueryParams(); + + $sort = Arr::pull($queryParams, 'sort'); + $q = Arr::pull($queryParams, 'q'); + $page = max(1, intval(Arr::pull($queryParams, 'page'))); + + // highlight-next-line + $sortMap = $this->resource->sortMap(); + + $params = [ + ...$queryParams, + 'sort' => $sort && isset($sortMap[$sort]) ? $sortMap[$sort] : null, + 'page' => [ + 'number' => $page + ], + ]; + + if ($q) { + $params['filter']['q'] = $q; + } + + $apiDocument = $this->getApiDocument($request, $params); + ... + + return $document; + } + ... +} +``` diff --git a/docs/extend/update-2_0.md b/docs/extend/update-2_0.md index cd5bf5d68..2ec8e1013 100644 --- a/docs/extend/update-2_0.md +++ b/docs/extend/update-2_0.md @@ -4,18 +4,45 @@ Flarum 2.0 is a major release that includes a number of breaking changes and new :::tip -If you need help applying these changes or using new features, please start a discussion on the [community forum](https://discuss.flarum.org/t/extensibility) or [Discord chat](https://flarum.org/discord/). +If you need help applying these changes or using new features, please start a discussion on the [community forum](https://discuss.flarum.org/t/extensibility). ::: -:::info +## Upgrade Automation You can use the [Flarum CLI](cli.md) to automate as much of the upgrade steps as possible: +Keep in mind the following information about the upgrade command: +* The process goes through multiple steps, each step points to the related documentation, and provides helpful tips. +* After each step, the changes **are committed to Git** so that you can always review the changes commit by commit. +* Mistakes can and will be made, after every step is finished you can view the changes made and make any necessary adjustments. +* Most of the steps should require little to no manual adjustments, except for the following: + * Adapt to accessing/extending lazy loaded Flarum modules. _(Can require some amount of manual changes)_ + * Prepare for JSON:API changes. _(100% manual changes requires)_ + * Search & Filter API changes. _(Can require some amount of manual changes)_ + +### Installation + +The upgrader is just another command of the [`@flarum/cli` npm package](https://github.com/flarum/cli#readme). + +```bash +npm install -g @flarum/cli@3 +``` + +v3 of the CLI is only compatible with v2.x of Flarum, if you wish to continue using the CLI for both v1.x and v2.x of Flarum, you can install them together. Read more about it [here](cli.md#installation). + +### Usage + +Now to use, simply run the following command: + ```bash -flarum-cli upgrade 2.0 +fl upgrade 2.0 ``` +:::caution + +**Before you use the automated upgrade command, make sure to read the entire upgrade guide at least once to understand the changes.** + ::: ## Frontend @@ -191,6 +218,13 @@ For more details, read the [Flysystem 1.x to V2 & V3 upgrade guide](https://flys Checkout the [Symfony Mailer documentation](https://symfony.com/doc/current/mailer.html) for more details. +#### Intervention Image v3 + +##### Breaking +The Intervention Image library (`intervention/image`) has been updated to version 3. If your extension makes any image manipulations, you should check the [Intervention Image v3 upgrade guide](https://image.intervention.io/v3/introduction/upgrade) for the breaking changes and adjust your code accordingly. + +You may also check out the core pull request that updated the library [here](https://github.com/flarum/framework/pull/3947/files). + ### JSON:API Flarum 2.0 completely refactors the JSON:API implementation. The way resource CRUD operations, serialization and extending other resources is done has completely changed. @@ -203,18 +237,24 @@ Flarum 2.0 completely refactors the JSON:API implementation. The way resource CR * The various validators have been removed. This includes the `DiscussionValidator`, `PostValidator`, `TagValidator`, `SuspendValidator`, `GroupValidator`, `UserValidator`. * Many command handlers have been removed. Use the `JsonApi` class if you wish to execute logic from an existing endpoint internally instead. * The `flarum.forum.discussions.sortmap` singleton has been removed. Instead, you can define an `ascendingAlias` and `descendingAlias` [on your added `SortColumn` sorts](./api#adding-sort-columns). -* The `show` discussion endpoint no longer includes the `posts` relationship, so any `posts.*` relation includes or eager loads added to that endpoint must be removed. +* The `show` discussion endpoint no longer includes the `posts` relationship, so any `posts.*` relation includes or eager loads added to that endpoint must be removed. You can move those to the `list` posts endpoint if you are not already doing the same on that endpoint. Replacing the deleted classes is the new `AbstractResource` and `AbstractDatabaseResource` classes. We recommend looking at a comparison between the bundled extensions (like tags) from 1.x to 2.x to have a better understanding of the changes: * Tags 1.x: https://github.com/flarum/framework/blob/1.x/extensions/tags * Tags 2.x: https://github.com/flarum/framework/blob/2.x/extensions/tags -:::caution Refer to the documentation +:::info Refer to the documentation Read about the full extent of the new introduced implementation and its usage in the [JSON:API](./api) section. ::: +:::tip + +Checkout our [guide to upgrading the JSON:API layer from 1.x to 2.x](./update-2_0-api), which provides concrete examples for different scenarios. + +::: + ##### Notable * We now do not recommend adding default includes to endpoints. Instead it is preferable to add what relations you need included in the payloads of individual requests. This improves the performance of the forum. @@ -264,6 +304,18 @@ Checkout the [database documentation](./database) for more details. background: black; } ``` + * The `@config-colored-header` variable has been removed. Instead, you can use the `[data-colored-header=true]` CSS selector. + ```less + // before + & when (@config-colored-header = true) { + background: @primary-color; + } + + // after + [data-colored-header=true] & { + background: var(--primary-color); + } + ``` ##### Notable * New high contrast color schemes have been added. diff --git a/docs/extend/update-2_x.md b/docs/extend/update-2_x.md new file mode 100644 index 000000000..c022749de --- /dev/null +++ b/docs/extend/update-2_x.md @@ -0,0 +1,10 @@ +# Updating For 2.x + +:::tip + +If you need help applying these changes or using new features, please start a discussion on the [community forum](https://discuss.flarum.org/t/extensibility) or [Discord chat](https://flarum.org/discord/). + +::: + +## 2.1 Changes + diff --git a/docs/extend/update-b10.md b/docs/extend/update-b10.md deleted file mode 100644 index aaa98a2a5..000000000 --- a/docs/extend/update-b10.md +++ /dev/null @@ -1,53 +0,0 @@ -# Updating For Beta 10 - -Beta 10 further solidifies the core architecture, offering new extenders as a stable, use-case-driven API for extending Flarum's core. A few small changes may necessitate updates to your extensions to make them compatible with Beta 10. These are detailed below. - -:::tip - -If you need help applying these changes or using new features, please start a discussion on the [community forum](https://discuss.flarum.org/t/extensibility) or [Discord chat](https://flarum.org/discord/). - -::: - -## Breaking Changes - -- The `Flarum\Event\GetDisplayName` class has been moved to `Flarum\User\Event\GetDisplayName`. -- The `Flarum\Http\Exception\ForbiddenException` has been removed. Use `Flarum\User\Exception\PermissionDeniedException` instead. -- The `assertGuest()` method of the `Flarum\User\AssertPermissionTrait` has been removed without replacement. -- Old error handling middleware and exception handler classes were removed (see "New Features" for more details): - - `Flarum\Api\Middleware\HandleErrors` - - `Flarum\Http\Middleware\HandleErrorsWithView` - - `Flarum\Http\Middleware\HandleErrorsWithWhoops` - - `Flarum\Api\ErrorHandler` - - `Flarum\Api\ExceptionHandler\FallbackExceptionHandler` - - `Flarum\Api\ExceptionHandler\FloodingExceptionHandler` - - `Flarum\Api\ExceptionHandler\IlluminateValidationExceptionHandler` - - `Flarum\Api\ExceptionHandler\InvalidAccessTokenExceptionHandler` - - `Flarum\Api\ExceptionHandler\InvalidConfirmationTokenExceptionHandler` - - `Flarum\Api\ExceptionHandler\MethodNotAllowedExceptionHandler` - - `Flarum\Api\ExceptionHandler\ModelNotFoundExceptionHandler` - - `Flarum\Api\ExceptionHandler\PermissionDeniedExceptionHandler` - - `Flarum\Api\ExceptionHandler\RouteNotFoundExceptionHandler` - - `Flarum\Api\ExceptionHandler\TokenMismatchExceptionHandler` - - `Flarum\Api\ExceptionHandler\ValidationExceptionHandler` - - `Flarum\Api\ExceptionHandler\FallbackExceptionHandler` - - `Flarum\Api\ExceptionHandler\FallbackExceptionHandler` - -## Recommendations - -- We tweaked the [recommended flarum/core version constraints for extensions](start.md#composer-json). We now recommend you mark your extension as compatible with the current and the upcoming beta release. (For beta.10, that would be any beta.10.x and beta.11.x version.) The core team will strive to make this work well by deprecating features before removing them. More details on this change in [this pull request](https://github.com/flarum/docs/pull/75). - -## New Features - -- New, extensible **error handling** stack in the `Flarum\Foundation\ErrorHandling` namespace: The `Registry` maps exceptions to "types" and HTTP status codes, `HttpFormatter` instances turn them into HTTP responses. Finally, `Reporter` instances are notified about unknown exceptions. - - You can build custom exception classes that will abort the current request (or console command). If they have semantic meaning to your application, they should implement the `Flarum\Foundation\KnownError` interface, which exposes a "type" that is used to render pretty error pages or dedicated error messages. - - More consistent use of HTTP 401 and 403 status codes. HTTP 401 should be used when logging in (i.e. authenticating) could make a difference; HTTP 403 is reserved for requests that fail because the already authenticated user is lacking permissions to do something. - - The `assertRegistered()` and `assertPermission()` methods of the `Flarum\User\AssertPermissionTrait` trait have been changed to match above semantics. See [this pull request](https://github.com/flarum/core/pull/1854) for more details. - - Error views are now determined based on error "type", not on status code (see [bdac88b](https://github.com/flarum/core/commit/bdac88b5733643b9c5dabae9e09a64d9f6e41d58)) -- **Queue support**: This release incorporates Laravel's illuminate/queue package, which allows offloading long-running tasks (such as email sending or regular cleanup jobs) onto a dedicated worker process. These changes are mostly under the hood, the next release(s) will start using the queue system for sending emails. By default, Flarum will use the "sync" queue driver, which executes queued tasks immediately. This is far from ideal and mostly guarantees a hassle-free setups. Production-grade Flarum installs are expected to upgrade to a more full-featured queue adapter. -- The `Flarum\Extend\LanguagePack` now accepts an optional path in its constructor. That way, language packs can store their locales in a different directory if they want to. -- The `formatContent()` method of `Flarum\Post\CommentPost` can now be called without an HTTP request instance, e.g. when rendering a post in an email template. - -## Deprecations - -- **Reminder**: In previous versions of Flarum, an extensions' main file was named `bootstrap.php`. This name will no longer be supported in the stable 0.1 release. Make sure your extension uses the name `extend.php`. -- Laravel's global string and array helpers (e.g. `str_contains()` and `array_only()`) are deprecated in favor of their class based alternatives (`Illuminate\Support\Str::contains()` and `Illuminate\Support\Arr::only()`). See the [announcement](https://laravel-news.com/laravel-5-8-deprecates-string-and-array-helpers) and [pull request](https://github.com/laravel/framework/pull/26898) for more information. diff --git a/docs/extend/update-b12.md b/docs/extend/update-b12.md deleted file mode 100644 index 81cb3eee0..000000000 --- a/docs/extend/update-b12.md +++ /dev/null @@ -1,35 +0,0 @@ -# Updating For Beta 12 - -Beta 12 packs several new features for extension developers, but also continues our cleanup efforts which results in a few changes, so please read this guide carefully to find out whether - your extensions are affected. We invested extra effort to introduce new functionality in a backward-compatible manner or first deprecate functionality before it will be removed in the next release, in line with our [versioning recommendations](start.md#composer-json). - -:::tip - -If you need help applying these changes or using new features, please start a discussion on the [community forum](https://discuss.flarum.org/t/extensibility) or [Discord chat](https://flarum.org/discord/). - -::: - -## Deprecations / Upcoming breaking changes - -- **Reminder**: In previous versions of Flarum, an extensions' main file was named `bootstrap.php`. This name will no longer be supported in the stable 0.1 release. Make sure your extension uses the name `extend.php`. -- PHP 7.1 support will be dropped in beta.13. -- Using library classes from the `Zend` namespace is now deprecated and will be removed in beta.13. Use the `Laminas` namespace instead. See [PR #1963](https://github.com/flarum/core/pull/1963). -- The `Flarum\Util\Str::slug()` method has been deprecated. Use `Illuminate\Support\Str::slug()` instead. -- The `Flarum\Event\ConfigureMiddleware` has been deprecated. We finally have a [proper replacement](middleware.md) - see "New Features" below. Therefore, it will be removed in beta.13. -- If you implement the `Flarum\Mail\DriverInterface`: - - Returning a plain array of field names from the `availableSettings()` method is deprecated, but still supported. It must now return an array of field names mapping to their type. See [the inline documentation](https://github.com/flarum/core/blob/08e40bc693cce7be02d4fb24633553c7eaf2738d/src/Mail/DriverInterface.php#L25-L32) for more details. - - Implement the `validate()` method that will be required in beta.13. See [its documentation](https://github.com/flarum/core/blob/08e40bc693cce7be02d4fb24633553c7eaf2738d/src/Mail/DriverInterface.php#L34-L48). - - Implement the `canSend()` method that will be required in beta.13. See [its documentation](https://github.com/flarum/core/blob/08e40bc693cce7be02d4fb24633553c7eaf2738d/src/Mail/DriverInterface.php#L50-L54). - -## New Features - -- New **PHP extenders**: - - `Flarum\Extend\Middleware` offers methods for adding, removing or replacing middleware in our three middleware stacks (api, forum, admin). We also added [documentation](middleware.md) for this feature. - - `Flarum\Extend\ErrorHandling` lets you configure status codes and other aspects of our error handling stack depending on error types or exception classes. -- **JavaScript**: - - The `flarum/components/Select` component now supports a `disabled` prop. See [PR #1978](https://github.com/flarum/core/pull/1978). - -## Other changes / Recommendations - -- The `TextFormatter` library has been updated to (at least) version 2.3.6. If you are using it (likely through our own `Flarum\Formatter\Formatter` class), we recommend scanning [the library's changelog](https://github.com/s9e/TextFormatter/blob/2.3.6/CHANGELOG.md). -- The JS `slug()` helper from the `flarum/utils/string` module should only be used to *suggest* slugs to users, not enforce them. It does not employ any sophisticated transliteration logic like its PHP counterpart. diff --git a/docs/extend/update-b13.md b/docs/extend/update-b13.md deleted file mode 100644 index 1b19a9714..000000000 --- a/docs/extend/update-b13.md +++ /dev/null @@ -1,40 +0,0 @@ -# Updating For Beta 13 - -Beta 13 ships with several new extenders to simplify building and maintaining extensions. We do our best to create backward compatibility changes. We recommend changing to new Extenders as soon as they are available. - -:::tip - -If you need help applying these changes or using new features, please start a discussion on the [community forum](https://discuss.flarum.org/t/extensibility) or [Discord chat](https://flarum.org/discord/). - -::: - -## Breaking Changes - -- Dropped support for PHP 7.1. -- Classes from the `Zend` namespace are now removed. Use the `Laminas` namespace instead. See [PR #1963](https://github.com/flarum/core/pull/1963). -- The `Flarum\Util\Str::slug()` method has been removed including the class. Use `Illuminate\Support\Str::slug()` instead. -- The `Flarum\Event\ConfigureMiddleware` has been removed. Use the [proper replacement](middleware.md). -- Several events used in Event Listeners have been removed, use their [replacement extender](start.md#extenders) instead. -- The LanguagePack extender only loads keys from extensions that are enabled. The translations loaded are based on the yaml files matching the [i18n namespace](i18n.md#appendix-a-standard-key-format). -- All notifications are now sent through the queue; without a queue driver they will run as usual. -- The implementation of avatar upload changed, we're [no longer storing files temporarily on disk](https://github.com/flarum/core/pull/2117). -- The SES mail driver [has been removed](https://github.com/flarum/core/pull/2011). -- Mail driver backward compatibility from beta 12 has been removed, use the new Mail extender or implement the [modified interface](https://github.com/flarum/core/blob/master/src/Mail/DriverInterface.php). - -## Recommendations - -- Beta 14 will ship with a rewrite in the frontend (javascript). If you're building for that release, make sure to follow our [progress](https://github.com/flarum/core/pull/2126). - -## New Features - -- A ton of new extenders: - - [Middleware extender](https://github.com/flarum/core/pull/2017) - - [Console extender](https://github.com/flarum/core/pull/2057) - - [CSRF extender](https://github.com/flarum/core/pull/2095) - - [Event extender](https://github.com/flarum/core/pull/2097) - - [Mail extender](https://github.com/flarum/core/pull/2012) - - [Model extender](https://github.com/flarum/core/pull/2100) - -## Deprecations - -- Several events [have been marked deprecated](https://github.com/flarum/core/commit/4efdd2a4f2458c8703aae654f95c6958e3f7b60b) to be removed in beta 14. diff --git a/docs/extend/update-b14.md b/docs/extend/update-b14.md deleted file mode 100644 index fb95b24fe..000000000 --- a/docs/extend/update-b14.md +++ /dev/null @@ -1,777 +0,0 @@ -# Updating For Beta 14 - -This release brings a large chunk of breaking changes - hopefully the last chunk of this size before our stable release. -In order to prepare the codebase for the upcoming stable release, we decided it was time to modernize / upgrade / exchange some of the underlying JavaScript libraries that are used in the frontend. -Due to the nature and size of these upgrades, we have to pass on some of the breaking changes to you, our extension developers. - -On the bright side, this overdue upgrade brings us closer to the conventions of best practices of [Mithril.js](https://mithril.js.org/), the mini-framework used for Flarum's UI. -Mithril's 2.0 release sports a more consistent component interface, which should be a solid foundation for years to come. -Where possible, we replicated old APIs, to ease the upgrade and give you time to do the full transition. -Quite a few breaking changes remain, though - read more below. - -:::tip - -If you need help with the upgrade, our friendly community will gladly help you out either [on the forum](https://discuss.flarum.org/t/extensibility) or [in chat](https://flarum.org/chat/). - -::: - -To ease the process, we've clearly separated the changes to the [frontend (JS)](#frontend-javascript) from those in the [backend (PHP)](#backend-php) below. -If your extension does not change the UI, consider yourself lucky. :-) - -A [summary of the frontend changes](#required-frontend-changes-recap) is available towards the end of this guide. - -## Frontend (JavaScript) - -### Mithril 2.0: Concepts - -Most breaking changes required by beta 14 are prompted by changes in Mithril 2. -[Mithril's upgrade guide](https://mithril.js.org/migration-v02x.html) is an extremely useful resource, and should be consulted for more detailed information. A few key changes are explained below: - -#### props -> attrs; initProps -> initAttrs - -Props passed into component are now referred to as `attrs`, and can be accessed via `this.attrs` where you would prior use `this.props`. This was done to be closer to Mithril's preferred terminology. We have provided a temporary backwards compatibility layer for `this.props`, but recommend using `this.attrs`. - -Accordingly, `initProps` has been replaced with `initAttrs`, with a similar BC layer. - -#### m.prop -> `flarum/utils/Stream` - -Mithril streams, which were available via `m.prop` in Mithril 0.2, are now available via `flarum/utils/Stream`. `m.prop` will still work for now due to a temporary BC layer. - -#### m.withAttr -> withAttr - -The `m.withAttr` util has been removed from Mithril. We have provided `flarum/utils/withAttr`, which does the same thing. A temporary BC layer has been added for `m.withAttr`. - -#### Lifecycle Hooks - -In mithril 0.2, we had 2 "lifecycle hooks": - -`init`, an unofficial hook which ran when the component instance was initialized. - -`config`, which ran when components were created, and on every redraw. - - -Mithril 2 has the following hooks; each of which take `vnode` as an argument: - -- `oninit` -- `oncreate` -- `onbeforeupdate` -- `onupdate` -- `onbeforeremove` -- `onremove` - -Please note that if your component is extending Flarum's helper `Component` class, you must call `super.METHOD(vnode)` if using `oninit`, `oncreate`, and `onbeforeupdate`. - -More information about what each of these do can be found [in Mithril's documentation](https://mithril.js.org/lifecycle-methods.html). - -A trivial example of how the old methods map to the new is: - -```js -class OldMithrilComponent extends Component { - init() { - console.log('Code to run when component instance created, but before attached to the DOM.'); - } - - config(element, isInitialized) { - console.log('Code to run on every redraw AND when the element is first attached'); - - if (isInitialized) return; - - console.log('Code to execute only once when components are first created and attached to the DOM'); - - context.onunload = () => { - console.log('Code to run when the component is removed from the DOM'); - } - } - - view() { - // In mithril 0, you could skip redrawing a component (or part of a component) by returning a subtree retain directive. - // See https://mithril.js.org/archive/v0.2.5/mithril.render.html#subtree-directives - // dontRedraw is a substitute for logic; usually, this is used together with SubtreeRetainer. - if (dontRedraw()) return { subtree: 'retain' }; - - return

Hello World!

; - } -} - -class NewMithrilComponent extends Component { - oninit(vnode) { - super.oninit(vnode); - - console.log('Code to run when component instance created, but before attached to the DOM.'); - } - - oncreate(vnode) { - super.oncreate(vnode); - - console.log('Code to run when components are first created and attached to the DOM'); - } - - onbeforeupdate(vnode, oldVnode) { - super.onbeforeupdate(vnode); - - console.log('Code to run BEFORE diffing / redrawing components on every redraw'); - - // In mithril 2, if we want to skip diffing / redrawing a component, we return "false" in its onbeforeupdate lifecycle hook. - // See https://mithril.js.org/lifecycle-methods.html#onbeforeupdate - // This is also typically used with SubtreeRetainer. - if (dontRedraw()) return false; - } - - onupdate(vnode) { - // Unlike config, this does NOT run when components are first attached. - // Some code might need to be replicated between oncreate and onupdate. - console.log('Code to run on every redraw AFTER the DOM is updated.'); - } - - onbeforeremove(vnode) { - // This is run before components are removed from the DOM. - // If a promise is returned, the DOM element will only be removed when the - // promise completes. It is only called on the top-level component that has - // been removed. It has no equivalent in Mithril 0.2. - // See https://mithril.js.org/lifecycle-methods.html#onbeforeremove - return Promise.resolve(); - } - - onremove(vnode) { - console.log('Code to run when the component is removed from the DOM'); - } -} -``` - -#### Children vs Text Nodes - -In Mithril 0.2, every child of a vnode is another vnode, stored in `vnode.children`. For Mithril 2, as a performance optimization, vnodes with a single text child now store that text directly in `vnode.text`. For developers, that means that `vnode.children` might not always contain the results needed. Luckily, text being stored in `vnode.text` vs `vnode.children` will be the same each time for a given component, but developers should be aware that at times, they might need to use `vnode.text` and not `vnode.children`. - -Please see [the mithril documentation](https://mithril.js.org/vnodes.html#structure) for more information on vnode structure. - -#### Routing API - -Mithril 2 introduces a few changes in the routing API. Most of these are quite simple: - -- `m.route()` to get the current route has been replaced by `m.route.get()` -- `m.route(NEW_ROUTE)` to set a new route has been replaced by `m.route.set(NEW_ROUTE)` -- When registering new routes, a component class should be provided, not a component instance. - -For example: - -```js -// Mithril 0.2 -app.routes.new_page = { path: '/new', component: NewPage.component() } - -// Mithril 2.0 -app.routes.new_page = { path: '/new', component: NewPage } -``` - -Additionally, the preferred way of defining an internal (doesn't refresh the page when clicked) link has been changed. The `Link` component should be used instead. - -```js -// Mithril 0.2 -Link Content - -// Mithril 2 -import Link from 'flarum/components/Link'; - -Link Content -``` - -You can also use `Link` to define external links, which will just render as plain `Children` html links. - -For a full list of routing-related changes, please see [the mithril documentation](https://mithril.js.org/migration-v02x.html). - -#### Redraw API - -Mithril 2 introduces a few changes in the redraw API. Most of these are quite simple: - -- Instead of `m.redraw(true)` for synchronous redraws, use `m.redraw.sync()` -- Instead of `m.lazyRedraw()`, use `m.redraw()` - -Remember that Mithril automatically triggers a redraw after DOM event handlers. The API for preventing a redraw has also changed: - -```js -// Mithril 0.2 - - -// Mithril 2 - -``` - -#### AJAX - -The `data` parameter of `m.request({...})` has been split up into `body` and `params`. - -For examples and other AJAX changes, see [the mithril documentation](https://mithril.js.org/migration-v02x.html#mrequest). - -#### Promises - -`m.deferred` has been removed, native promises should be used instead. For instance: - -```js -// Mithril 0.2 -const deferred = m.deferred(); - -app.store.find('posts').then(result => deferred.resolve(result)); - -return deferred.promise; - -// Mithril 2 -return app.store.find('posts'); -``` - -#### Component instances should not be stored - -Due to optimizations in Mithril's redrawing algorithms, [component instances should not be stored](https://mithril.js.org/components.html#define-components-statically,-call-them-dynamically). - -So whereas before, you might have done something like: - -```js -class ChildComponent extends Component { - oninit(vnode) { - super.oninit(vnode); - this.counter = 0; - } - - view() { - return

{this.counter}

; - } -} -class ParentComponent extends Component { - oninit(vnode) { - super.oninit(vnode); - this.child = new ChildComponent(); - } - - view() { - return ( -
- - {this.child.render()} -
- ) - } -} -``` - -That will no longer work. In fact; the Component class no longer has a render method. - -Instead, any data needed by a child component that is modified by a parent component should be passed in as an attr. For instance: - -```js -class ChildComponent extends Component { - view() { - return

{this.attrs.counter}

; - } -} - -class ParentComponent extends Component { - oninit(vnode) { - super.oninit(vnode); - this.counter = 0; - } - - view() { - return ( -
- - -
- ) - } -} -``` - -For more complex components, this might require some reorganization of code. For instance, let's say you have data that can be modified by several unrelated components. -In this case, it might be preferable to create a POJO "state instance' for this data. These states are similar to "service" singletons used in Angular and Ember. For instance: - -```js -class Counter { - constructor() { - this._counter = 0; - } - - increaseCounter() { - this._counter += 1; - } - - getCount() { - return this._counter; - } -} - -app.counter = new Counter(); - -extend(HeaderSecondary.prototype, 'items', function(items) { - items.add('counterDisplay', -
-

Counter: {app.counter.getCount()}

-
- ); -}) - -extend(HeaderPrimary.prototype, 'items', function(items) { - items.add('counterButton', -
- -
- ); -}) -``` - -This "state pattern" can be found throughout core. Some non-trivial examples are: - -- PageState -- SearchState and GlobalSearchState -- NotificationListState -- DiscussionListState - -### Changes in Core - -#### Modals - -Previously, modals could be opened by providing a `Modal` component instance: - -```js -app.modal.show(new LoginModal(identification: 'prefilledUsername')); -``` - -Since we don't store component instances anymore, we pass in the component class and any attrs separately. - -```js -app.modal.show(LoginModal, {identification: 'prefilledUsername'}); -``` - -The `show` and `close` methods are still available through `app.modal`, but `app.modal` now points to an instance of `ModalManagerState`, not of the `ModalManager` component. -Any modifications by extensions should accordingly be done to `ModalManagerState`. - -#### Alerts - -Previously, alerts could be opened by providing an `Alert` component instance: - -```js -app.alerts.show(new Alert(type: 'success', children: 'Hello, this is a success alert!')); -``` - -Since we don't store component instances anymore, we pass in a component class, attrs, children separately. The `show` method has 3 overloads: - -```js -app.alerts.show('Hello, this is a success alert!'); -app.alerts.show({type: 'success'}, 'Hello, this is a success alert!'); -app.alerts.show(Alert, {type: 'success'}, 'Hello, this is a success alert!'); -``` - -Additionally, the `show` method now returns a unique key, which can then be passed into the `dismiss` method to dismiss that particular alert. -This replaces the old method of passing the alert instance itself to `dismiss`. - -The `show`, `dismiss`, and `clear` methods are still available through `app.alerts`, but `app.alerts` now points to an instance of `AlertManagerState`, not of the `AlertManager` component. -Any modifications by extensions should accordingly be done to `AlertManagerState`. - -#### Composer - -Since we don't store a component instances anymore, a number of util methods from `Composer`, `ComposerBody` (and it's subclasses), and `TextEditor` have been moved onto `ComposerState`. - -For `forum/components/Composer`, `isFullScreen`, `load`, `clear`, `show`, `hide`, `close`, `minimize`, `fullScreen`, and `exitFullScreen` have been moved to `forum/states/ComposerState`. They all remain accessible via `app.composer.METHOD` - -A `bodyMatches` method has been added to `forum/states/ComposerState`, letting you check whether a certain subclass of `ComposerBody` is currently open. - -Various input fields are now stored as [Mithril Streams](https://mithril.js.org/stream.html) in `app.composer.fields`. For instance, to get the current composer content, you could use `app.composer.fields.content()`. Previously, it was available on `app.composer.component.content()`. **This is a convention that `ComposerBody` subclasses that add inputs should follow.** - -`app.composer.component` is no longer available. - -- Instead of `app.composer.component instanceof X`, use `app.composer.bodyMatches(X)`. -- Instead of `app.composer.component.props`, use `app.composer.body.attrs`. -- Instead of `app.composer.component.editor`, use `app.composer.editor`. - -For `forum/components/TextEditor`, the `setValue`, `moveCursorTo`, `getSelectionRange`, `insertAtCursor`, `insertAt`, `insertBetween`, `replaceBeforeCursor`, `insertBetween` methods have been moved to `forum/components/SuperTextarea`. - -Also for `forum/components/TextEditor`, `this.disabled` is no longer used; `disabled` is passed in as an attr instead. It may be accessed externally via `app.composer.body.attrs.disabled`. - -Similarly to Modals and Alerts, `app.composer.load` no longer accepts a component instance. Instead, pass in the body class and any attrs. For instance, - -```js -// Mithril 0.2 -app.composer.load(new DiscussionComposer({user: app.session.user})); - -// Mithril 2 -app.composer.load(DiscussionComposer, {user: app.session.user}) -``` - -Finally, functionality for confirming before unloading a page with an active composer has been moved into the `common/components/ConfirmDocumentUnload` component. - -#### Widget and DashboardWidget - -The redundant `admin/components/Widget` component has been removed. `admin/components/DashboardWidget` should be used instead. - -#### NotificationList - -For `forum/components/NotificationList`, the `clear`, `load`, `loadMore`, `parseResults`, and `markAllAsRead` methods have been moved to `forum/states/NotificationListState`. - -Methods for `isLoading` and `hasMoreResults` have been added to `forum/states/NotificationListState`. - -`app.cache.notifications` is no longer available; `app.notifications` (which points to an instance of `NotificationListState`) should be used instead. - -#### Checkbox - -Loading state in the `common/components/Checkbox` component is no longer managed through `this.loading`; it is now passed in as a prop (`this.attrs.loading`). - -#### Preference Saver - -The `preferenceSaver` method of `forum/components/SettingsPage` has been removed without replacement. This is done to avoid saving component instances. Instead, preferences should be directly saved. For instance: - -```js -// Old way -Switch.component({ - children: app.translator.trans('core.forum.settings.privacy_disclose_online_label'), - state: this.user.preferences().discloseOnline, - onchange: (value, component) => { - this.user.pushAttributes({ lastSeenAt: null }); - this.preferenceSaver('discloseOnline')(value, component); - }, -}) - -// Without preferenceSaver -Switch.component({ - children: app.translator.trans('core.forum.settings.privacy_disclose_online_label'), - state: this.user.preferences().discloseOnline, - onchange: (value) => { - this.discloseOnlineLoading = true; - - this.user.savePreferences({ discloseOnline: value }).then(() => { - this.discloseOnlineLoading = false; - m.redraw(); - }); - }, - loading: this.discloseOnlineLoading, -}) -``` - -A replacement will eventually be introduced. - -#### DiscussionListState - -For `forum/components/DiscussionList`, the `requestParams`, `sortMap`, `refresh`, `loadResults`, `loadMore`, `parseResults`, `removeDiscussion`, and `addDiscussion` methods have been moved to `forum/states/DiscussionListState`. - -Methods for `hasDiscussions`, `isLoading`, `isSearchResults`, and `empty` have been added to `forum/states/DiscussionListState`. - -`app.cache.discussions` is no longer available; `app.discussions` (which points to an instance of `DiscussionListState`) should be used instead. - -#### PageState - -`app.current` and `app.previous` no longer represent component instances, they are now instances of the `common/states/PageState` class. This means that: - -- Instead of `app.current instanceof X`, use `app.current.matches(X)` -- Instead of `app.current.PROPERTY`, use `app.current.get('PROPERTY')`. Please note that all properties must be exposed EXPLICITLY via `app.current.set('PROPERTY', VALUE)`. - -#### PostStream - -Logic from `forum/components/PostStreamScrubber`'s `update` method has been moved to `forum/components/PostStream`'s `updateScrubber` method. - -For `forum/components/PostStream`, the `update`, `goToFirst`, `goToLast`, `goToNumber`, `goToIndex`, `loadNearNumber`, `loadNearIndex`, `loadNext`, `loadPrevious`, `loadPage`, `loadRange`, `show`, `posts`, `reset`, `count`, and `sanitizeIndex` methods have been moved to `forum/states/PostStreamState`. - -Methods for `disabled` and `viewingEnd` have been added to `forum/states/PostStreamState`. - -#### SearchState and GlobalSearchState - -As with other components, we no longer store instances of `forum/components/Search`. As such, every `Search` component instance should be paired with a `forum/states/SearchState` instance. - -At the minimum, `SearchState` contains the following methods: - -- getValue -- setValue -- clear -- cache (adds a searched value to cache, meaning that we don't need to search for its results again) -- isCached (checks if a value has been searched for before) - -All of these methods have been moved from `Search` to `SearchState`. Additionally, Search's `stickyParams`, `params`, `changeSort`, `getInitialSearch`, and `clearInitialSearch` methods have been moved to `forum/states/GlobalSearchState`, which is now available via `app.search`. - -To use a custom search, you'll want to: - -1. Possibly create a custom subclass of `SearchState` -2. Create a custom subclass of `Search`, which overrides the `selectResult` method to handle selecting results as needed by your use case, and modify the `sourceItems` methods to contain the search sources you need. - -#### moment -> dayjs - -The `moment` library has been removed, and replaced by the `dayjs` library. The global `moment` can still be used for now, but is deprecated. `moment` and `dayjs` have very similar APIs, so very few changes will be needed. Please see the dayjs documentation [for more information](https://day.js.org/en/) on how to use it. - -#### Subtree Retainer - -`SubtreeRetainer` is a util class that makes it easier to avoid unnecessary redraws by keeping track of some pieces of data. -When called, it checks if any of the data has changed; if not, it indicates that a redraw is not necessary. - -In mithril 0.2, its `retain` method returned a [subtree retain directive](https://mithril.js.org/archive/v0.1.25/mithril.render.html#subtree-directives) if no redraw was necessary. - -In mithril 2, we use its `needsRebuild` method in combination with `onbeforeupdate`. For instance: - -```js -class CustomComponent extends Component { - oninit(vnode) { - super.oninit(vnode); - - this.showContent = false; - - this.subtree = new SubtreeRetainer( - () => this.showContent, - ) - } - - onbeforeupdate() { - // If needsRebuild returns true, mithril will diff and redraw the vnode as usual. Otherwise, it will skip this redraw cycle. - // In this example, this means that this component and its children will only be redrawn when extra content is toggled. - return this.subtree.needsRebuild(); - } - - view(vnode) { - return
- -

Hello World!{this.showContent ? ' Extra Content!' : ''}

-
; - } -} -``` - -#### attrs() method - -Previously, some components would have an attrs() method, which provided an extensible way to provide attrs to the top-level child vnode returned by `view()`. For instance, - -```js -class CustomComponent extends Component { - view() { - return

Hello World!

; - } - - attrs() { - return { - className: 'SomeClass', - onclick: () => console.log('click'), - }; - } -} -``` - -Since `this.attrs` is now used for attrs passed in from parent components, `attrs` methods have been renamed to `elementAttrs`. - -#### Children and .component - -Previously, an element could be created with child elements by passing those in as the `children` prop: - -```js -Button.component({ - className: 'Button Button--primary', - children: 'Button Text' -}); -``` - -This will no longer work, and will actually result in errors. Instead, the 2nd argument of the `component` method should be used: - -```js -Button.component({ - className: 'Button Button--primary' -}, 'Button Text'); -``` - -Children can still be passed in through JSX: - -```js - -``` - -#### Tag attr - -Because mithril uses 'tag' to indicate the actual html tag (or component class) used for a vnode, you can no longer pass `tag` as an attr to components -extending Flarum's `Component` helper class. The best workaround here is to just use another name for this attr. - -#### affixSidebar - -The `affixSidebar` util has been removed. Instead, if you want to affix a sidebar, wrap the sidebar code in an `AffixedSidebar` component. For instance, - -```js -class OldWay extends Component { - view() { - return
-
-
- -
Actual Page Content
-
-
-
; - } -} - -class NewWay extends Component { - view() { - return
-
-
- - - -
Actual Page Content
-
-
-
; - } -} -``` - -#### Fragment - -**Warning: For Advanced Use Only** - -In some rare cases, we want to have extremely fine grained control over the rendering and display of some significant chunks of the DOM. These are attached with `m.render`, and do not experience automated redraws. Current use cases in core and bundled extensions are: - -- The "Reply" button that shows up when selecting text in a post -- The mentions autocomplete menu that shows up when typing -- The emoji autocomplete menu that shows up when typing - -For this purpose, we provide a helper class (`common/Fragment`), of which you can create an instance, call methods, and render via `m.render(DOM_ROOT, fragmentInstance.render())`. The main benefit of using the helper class is that it allows you to use lifecycle methods, and to access the underlying DOM via `this.$()`, like you would be able to do with a component. - -This should only be used when absolutely necessary. If you are unsure, you probably don't need it. If the goal is to not store component instances, the "state pattern" as described above is preferable. - -### Required Frontend Changes Recap - -Each of these changes has been explained above, this is just a recap of major changes for your convenience. - -- Component Methods: - - `view()` -> `view(vnode)` - - Lifecycle - - `init()` -> `oninit(vnode)` - - `config()` -> Lifecycle hooks `oncreate(vnode)` / `onupdate(vnode)` - - `context.onunload()` -> `onremove()` - - `SubtreeRetainer` -> `onbeforeupdate()` - - if present, `attrs()` method needs to be renamed -> convention `elementAttrs()` - - building component with `MyComponent.component()` -> `children` is now second parameter instead of a named prop/attr (first argument) -> JSX preferred -- Routing - - `m.route()` -> `m.route.get()` - - `m.route(name)` -> `m.route.set(name)` - - register routes with page class, not instance - - special case when passing props - - `` -> `` -- AJAX - - `m.request({...})` -> `data:` key split up into `body:` and `params:` - - `m.deferred` -> native `Promise` -- Redrawing - - `m.redraw(true)` -> `m.redraw.sync()` - - `m.redraw.strategy('none')` -> `e.redraw = false` in event handler - - `m.lazyRedraw()` -> `m.redraw()` - -#### Deprecated changes - -For the following changes, we currently provide a backwards-compatibility layer. -This will be removed in time for the stable release. -The idea is to let you release a new version that's compatible with Beta 14 to your users as quickly as possible. -When you have taken care of the changes above, you should be good to go. -For the following changes, we have bought you time until the stable release. -Considering you have to make changes anyway, why not do them now? - -- `this.props` -> `this.attrs` -- static `initProps()` -> static `initAttrs()` -- `m.prop` -> `flarum/utils/Stream` -- `m.withAttr` -> `flarum/utils/withAttr` -- `moment` -> `dayjs` - -## Backend (PHP) - -### New Features - -#### Extension Dependencies - -Some extensions are based on, or add features to, other extensions. -Prior to this release, there was no way to ensure that those dependencies were enabled before the extension that builds on them. -Now, you cannot enable an extension unless all of its dependencies are enabled, and you cannot disable an extension if there are other enabled extensions depending on it. - -So, how do we specify dependencies for an extension? Well, all you need to do is add them as composer dependencies to your extension's `composer.json`! For instance, if we have an extension that depends on Tags and Mentions, our `composer.json` will look like this: - -```json -{ - "name": "my/extension", - "description": "Cool New Extension", - "type": "flarum-extension", - "license": "MIT", - "require": { - "flarum/core": "^0.1.0-beta.14", - "flarum/tags": "^0.1.0-beta.14", // Will mark tags as a dependency - "flarum/mentions": "^0.1.0-beta.14", // Will mark mentions as a dependency - } - // other config -} -``` - -#### View Extender - -Previously, when extensions needed to register Laravel Blade views, they could inject a view factory in `extend.php` and call it's `addNamespace` method. For instance, - -```php -// extend.php -use Illuminate\Contracts\View\Factory; - -return [ - function (Factory $view) { - $view->addNamespace(NAME, RELATIVE PATH); - } -] -``` - -This should NOT be used, as it will break views for all extensions that boot after yours. Instead, the `View` extender should be used: - -```php -// extend.php -use Flarum\Extend\View; - -return [ - (new View)->namespace(NAME, RELATIVE PATH); -] -``` - -#### Application and Container - -Although Flarum uses multiple components of the Laravel framework, it is not a pure Laravel system. In beta 14, the `Flarum\Foundation\Application` class no longer implements `Illuminate\Contracts\Foundation\Application`, and no longer inherits `Illuminate\Container\Container`. Several things to note: - -- The `app` helper now points to an instance of `Illuminate\Container\Container`, not `Flarum\Foundation\Application`. You might need to resolve things through the container before using them: for instance, `app()->url()` will no longer work; you'll need to resolve or inject an instance of `Flarum\Foundation\Config` and use that. -- Injected or resolved instances of `Flarum\Foundation\Application` can no longer resolve things through container methods. `Illuminate\Container\Container` should be used instead. -- Not all public members of `Illuminate\Contracts\Foundation\Application` are available through `Flarum\Foundation\Application`. Please refer to our [API docs on `Flarum\Foundation\Application`](https://api.docs.flarum.org/php/master/flarum/foundation/application) for more information. - -#### Other Changes - -- We are now using Laravel 6. Please see [Laravel's upgrade guide](https://laravel.com/docs/6.x/upgrade) for more information. Please note that we do not use all of Laravel. -- Optional params in url generator now work. For instance, the url generator can now properly generate links to posts in discussions. -- A User Extender has been added, which replaces the deprecated `PrepareUserGroups` and `GetDisplayName` events. -- Error handler middleware can now be manipulated by the middleware extender through the `add`, `remove`, `replace`, etc methods, just like any other middleware. -- `Flarum/Foundation/Config` and `Flarum/Foundation/Paths` can now be injected where needed; previously their data was accessible through `Flarum/Foundation/Application`. - -### Deprecations - -- `url` provided in `config.php` is now an array, accessible via `$config->url()`, for an instance of `Config` - [PR](https://github.com/flarum/core/pull/2271#discussion_r475930358) -- AssertPermissionTrait has been deprecated - [Issue](https://github.com/flarum/core/issues/1320) -- Global path helpers and path methods of `Application` have been deprecated, the injectable `Paths` class should be used instead - [PR](https://github.com/flarum/core/pull/2155) -- `Flarum\User\Event\GetDisplayName` has been deprecated, the `displayNameDriver` method of the `User` extender should be used instead - [PR](https://github.com/flarum/core/pull/2174) - -### Removals - -- Do NOT use the old closure notation for configuring view namespaces. This will break all extensions that boot after your extension. The `View` extender MUST be used instead. -- app()->url() will no longer work: [`Flarum\Http\UrlGenerator`](routes.md) should be injected and used instead. An instance of `Flarum\Http\UrlGenerator` is available in `blade.php` templates via `$url`. -- As a part of the Laravel 6 upgrade, the [`array_` and `str_` helpers](https://laravel.com/docs/6.x/upgrade#helpers) have been removed. -- The Laravel translator interface has been removed; the Symfony translator interface should be used instead: `Symfony\Component\Translation\TranslatorInterface` -- The Mandrill mail driver is no longer provided in Laravel 6, and has been removed. -- The following events deprecated in Beta 13 [have been removed](https://github.com/flarum/core/commit/7d1ef9d89161363d1c8dea19cf8aebb30136e9e3#diff-238957b67e42d4e977398cd048c51c73): - - `AbstractConfigureRoutes` - - `ConfigureApiRoutes` - Use the `Routes` extender instead - - `ConfigureForumRoutes` - Use the `Frontend` or `Routes` extenders instead - - `ConfigureLocales` - Use the `LanguagePack` extender instead - - `ConfigureModelDates` - Use the `Model` extender instead - - `ConfigureModelDefaultAttributes` - Use the `Model` extender instead - - `GetModelRelationship` - Use the `Model` extender instead - - `Console\Event\Configuring` - Use the `Console` extender instead - - `BioChanged` - User bio has not been a core feature for several releases diff --git a/docs/extend/update-b15.md b/docs/extend/update-b15.md deleted file mode 100644 index c815ca9c2..000000000 --- a/docs/extend/update-b15.md +++ /dev/null @@ -1,60 +0,0 @@ -# Updating For Beta 15 - -Beta 15 features multiple new extenders, a total redesign of the admin dashboard, and several other interesting new features for extensions. As before, we have done our best to provide backwards compatibility layers, and we recommend switching away from deprecated systems as soon as possible to make your extensions more stable. - -:::tip - -If you need help applying these changes or using new features, please start a discussion on the [community forum](https://discuss.flarum.org/t/extensibility) or [Discord chat](https://flarum.org/discord/). - -::: - -## New Features / Deprecations - -### Extenders - -- `Flarum\Api\Event\WillGetData` and `Flarum\Api\Event\WillSerializeData` have been deprecated, the `ApiController` extender should be used instead -- `Flarum\Api\Event\Serializing` and `Flarum\Event\GetApiRelationship` have been deprecated, the `ApiSerializer` extender should be used instead -- `Flarum\Formatter\Event\Parsing` has been deprecated, the `parse` method of the `Formatter` extender should be used instead -- `Flarum\Formatter\Event\Rendering` has been deprecated, the `render` method of the `Formatter` extender should be used instead -- `Flarum\Notification\Event\Sending` has been deprecated, the `driver` method of the `Notification` extender should be used instead - - Please note that the new notification driver system is not an exact analogue of the old `Sending` event, as it can only add new drivers, not change the functionality of the default notification bell alert driver. If your extension needs to modify **how** or **to whom** notifications are sent, you may need to replace `Flarum\Notification\NotificationSyncer` on the service provider level -- `Flarum\Event\ConfigureNotificationTypes` has been deprecated, the `type` method of the `Notification` extender should be used instead -- `Flarum\Event\ConfigurePostTypes` has been deprecated, the `type` method of the `Post` extender should be used instead -- `Flarum\Post\Event\CheckingForFlooding` has been deprecated, as well as `Flarum\Post\Floodgate`. They have been replaced with a middleware-based throttling system that applies to ALL requests to /api/*, and can be configured via the `ThrottleApi` extender. Please see our [api-throttling](api-throttling.md) documentation for more information. -- `Flarum\Event\ConfigureUserPreferences` has been deprecated, the `registerPreference` method of the `User` extender should be used instead -- `Flarum\Foundation\Event\Validating` has been deprecated, the `configure` method of the `Validator` extender should be used instead - -- The Policy system has been reworked a bit to be more intuitive. Previously, policies contained both actual policies, which determine whether a user can perform some ability, and model visibility scopers, which allowed efficient restriction of queries to only items that users have access to. See the [authorization documentation](authorization.md) for more information on how to use the new systems. Now: - - `Flarum\Event\ScopeModelVisibility` has been deprecated. New scopers can be registered via the `ModelVisibility` extender, and any `Eloquent\Builder` query can be scoped by calling the `whereVisibleTo` method on it, with the ability in question as an optional 2nd argument (defaults to `view`). - - `Flarum\Event\GetPermission` has been deprecated. Policies can be registered via the `Policy` extender. `Flarum\User\User::can` has not changed. Please note that the new policies must return one of `$this->allow()`, `$this->deny()`, `$this->forceAllow()`, `$this->forceDeny()`, not a boolean. - -- A `ModelUrl` extender has been added, allowing new slug drivers to be registered. This accompanies Flarum's new slug driving system, which allows for extensions to define custom slugging strategies for sluggable models. The extender supports sluggable models outside of Flarum core. Please see our [model slugging](slugging.md) documentation for more information. -- A `Settings` extender has been added, whose `serializeToForum` method makes it easy to serialize a setting to the forum. -- A `ServiceProvider` extender has been added. This should be used with extreme caution for advanced use cases only, where there is no alternative. Please note that the service provider layer is not considered public API, and is liable to change at any time, without notice. - -### Admin UX Redesign - -The admin dashboard has been completely redesigned, with a focus on providing navbar pages for each extension. The API for extensions to register settings, permissions, and custom pages has also been greatly simplified. You can also now update your extension's `composer.json` to provide links for funding, support, website, etc that will show up on your extension's admin page. Please see [our Admin JS documentation](admin.md) for more information on how to use the new system. - -### Other New Features - -- On the backend, the route name is now available via `$request->getAttribute('routeName')` for controllers, and for middleware that run after `Flarum\Http\Middleware\ResolveRoute.php`. -- `Flarum\Api\Controller\UploadImageController.php` can now be used as a base class for controllers that upload images (like for the logo and favicon). -- Automatic browser scroll restoration can now be disabled for individual pages [see our frontend page documentation for more info](frontend-pages.md). - -## Breaking Changes - -- The following deprecated frontend BC layers were removed: - - `momentjs` no longer works as an alias for `dayjs` - - `this.props` and `this.initProps` no longer alias `this.attrs` and `this.initAttrs` for the `Component` base class - - `m.withAttr` and `m.stream` no longer alias `flarum/utils/withAttr` and `flarum/utils/Stream` - - `app.cache.discussionList` has been removed - - `this.content` and `this.editor` have been removed from `ComposerBody` - - `this.component`, `this.content`, and `this.value` have been removed from `ComposerState` -- The following deprecated backend BC layers were removed: - - The `publicPath`, `storagePath`, and `vendorPath` methods of `Flarum\Foundation\Application` have been removed - - The `base_path`, `public_path`, and `storage_path` global helpers have been removed - - The `getEmailSubject` method of `Flarum\Notification\MailableInterface` MUST now take a translator instance as an argument - - `Flarum\User\AssertPermissionTrait` has been removed, the analogous methods on `Flarum\User\User` should be used instead - - The `Flarum\Event\PrepareUserGroups` event has been removed, use the `User` extender instead - - The `Flarum\User\Event\GetDisplayName` event has been removed, use the display name driver feature of the `User` extender instead diff --git a/docs/extend/update-b16.md b/docs/extend/update-b16.md deleted file mode 100644 index fb63e6b42..000000000 --- a/docs/extend/update-b16.md +++ /dev/null @@ -1,141 +0,0 @@ -# Updating For Beta 16 - -Beta 16 finalizes the PHP extender API, introduces a testing library and JS typings, switches to using namespaces for JS imports, increases extension dependency robustness, and allows overriding routes, among other features. - -:::tip - -If you need help applying these changes or using new features, please start a discussion on the [community forum](https://discuss.flarum.org/t/extensibility) or [Discord chat](https://flarum.org/discord/). - -::: - -## Frontend - -- A new editor driver abstraction has been introduced, which allows extensions to override the default textarea-based editor with more advanced solutions. -- The `TextEditor` and `TextEditorButton` components, as well as the `BasicEditorDriver` util (which replaces `SuperTextarea`) have been moved from `forum` to `common`. -- The `forum`, `admin`, and `common` namespaces should be used when importing. So instead of `import Component from 'flarum/Component'`, use `import Component from 'flarum/common/Component`. Support for the old import styles will be deprecated through the stable release, and removed with Flarum 2.0. -- A typing library has been released to support editor autocomplete for frontend development, and can be installed in your dev environment via `npm install --save-dev flarum@0.1.0-beta.16`. -- Extension categories have been simplified down to `feature`, `theme`, and `language`. - -## Backend - -### Extenders - -- All extenders that support callbacks/closures now support global functions like `'boolval'` and array-type functions like `[ClassName::class, 'methodName']`. -- The `Settings` extender's `serializeToFrontend` method now supports a default value as the 4th argument. -- The `Event` extender now supports registering subscribers for multiple events at once via a `subscribe` method. -- The `Notification` extender now has a `beforeSending` method, which allows you to adjust the list of recipients before a notification is sent. -- The `mutate` method of `ApiSerializer` has been deprecated, and renamed to `attributes`. -- `remove` methods on the `Route` and `Frontend` extenders can be used to remove (and then replace) routes. -- A `ModelPrivate` extender replaces the `GetModelIsPrivate` event, which has been deprecated. -- Methods on the `Auth` extender replace the `CheckingPassword` event, which has been deprecated. -- All search-related events are now deprecated in favor of the `SimpleFlarumSearch` and `Filter` extenders; this is explained in more detail below. - -### Laravel and Symfony - -Beta 16 upgrades from v6.x to v8.x of Laravel components and v4 to v5 of Symfony components. Please see the respective upgrade guides of each for changes you might need to make to your extensions. -The most applicable change is the deprecation of `Symfony\Component\Translation\TranslatorInterface` in favor of `Symfony\Contracts\Translation\TranslatorInterface`. The former will be removed in beta 17. - -### Helper Functions - -The remaining `app` and `event` global helper functions have been deprecated. `app` has been replaced with `resolve`, which takes the name of a container binding and resolves it through the container. - -Since some Flarum extensions use Laravel libraries that assume some global helpers exist, we've recreated some commonly used helpers in the [flarum/laravel-helpers](https://github.com/flarum/laravel-helpers) package. These helpers should NOT be used directly in Flarum extension code; they are available so that Laravel-based libraries that expect them to exist don't malfunction. - -### Search Changes - -As part of our ongoing efforts to make Flarum's search system more flexible, we've made several refactors in beta 16. -Most notably, filtering and searching are now treated as different mechanisms, and have separate pipelines and extenders. -Essentially, if a query has a `filter[q]` query param, it will be treated as a search, and all other filter params will be ignored. Otherwise, it will be handled by the filtering system. This will eventually allow searches to be handled by alternative drivers (provided by extensions), such as ElasticSearch, without impacting filtering (e.g. loading recent discussions). Classes common to both systems have been moved to a `Query` namespace. - -Core's filtering and default search (named SimpleFlarumSearch) implementations are quite similar, as both are powered by the database. `List` API controllers call the `search` / `filter` methods on a resource-specific subclass of `Flarum\Search\AbstractSearcher` or `Flarum\Filter\AbstractFilterer`. Arguments are an instance of `Flarum\Query\QueryCriteria`, as well as sort, offset, and limit information. Both systems return an instance of `Flarum\Query\QueryResults`, which is effectively a wrapper around a collection of Eloquent models. - -The default systems are also somewhat similar in their implementation. `Filterer`s apply Filters (implementing `Flarum\Filter\FilterInterface`) based on query params in the form `filter[FILTER_KEY] = FILTER_VALUE` (or `filter[-FILTER_KEY] = FILTER_VALUE` for negated filters). SimpleFlarumSearch's `Searcher`s split the `filter[q]` param by spaces into "terms", apply Gambits (implementing `Flarum\Search\GambitInterface`) that match the terms, and then apply a "Fulltext Gambit" to search based on any "terms" that don't match an auxiliary gambit. Both systems then apply sorting, an offset, and a result count limit, and allow extensions to modify the query result via `searchMutators` or `filterMutators`. - -Extensions add gambits and search mutators and set fulltext gambits for `Searcher` classes via the `SimpleFlarumSearch` extender. They can add filters and filter mutators to `Filterer` classes via the `Filter` extender. - -With regards to upgrading, please note the following: - -- Search mutations registered by listening to the `Searching` events for discussions and users will be applied as to searches during the search mutation step via a temporary BC layer. They WILL NOT be applied to filters. This is a breaking change. These events have been deprecated. -- Search gambits registered by listening to the `ConfigureUserGambits` and `ConfigureDiscussionGambits` events will be applied to searcher via a temporary BC layer. They WILL NOT be applied to filters. This is a breaking change. These events have been deprecated. -- Post filters registered by listening to the `ConfigurePostsQuery` events will be applied to post filters via a temporary BC layer. That event has been deprecated. - -### Testing Library - -The `flarum/testing` package provides utils for PHPUnit-powered automated backend tests. See the [testing documentation](testing.md) for more info. - -### Optional Dependencies - -Beta 15 introduced "extension dependencies", which require any extensions listed in your extension's `composer.json`'s `require` section to be enabled before your extension can be enabled. - -With beta 16, you can specify "optional dependencies" by listing their composer package names as an array in your extension's `extra.flarum-extension.optional-dependencies`. Any enabled optional dependencies will be booted before your extension, but aren't required for your extension to be enabled. - -### Access Token and Authentication Changes - -#### Extension API changes - -The signature to various method related to authentication have been changed to take `$token` as parameter instead of `$userId`. Other changes are the result of the move from `$lifetime` to `$type` - -- `Flarum\Http\AccessToken::generate($userId)` no longer accepts `$lifetime` as a second parameter. Parameter has been kept for backward compatibility but has no effect. It will be removed in beta 17. -- `Flarum\Http\RememberAccessToken::generate($userId)` should be used to create remember access tokens. -- `Flarum\Http\DeveloperAccessToken::generate($userId)` should be used to create developer access tokens (don't expire). -- `Flarum\Http\SessionAccessToken::generate()` can be used as an alias to `Flarum\Http\AccessToken::generate()`. We might deprecate `AccessToken::generate()` in the future. -- `Flarum\Http\Rememberer::remember(ResponseInterface $response, AccessToken $token)`: passing an `AccessToken` has been deprecated. Pass an instance of `RememberAccessToken` instead. As a temporary compatibility layer, passing any other type of token will convert it into a remember token. In beta 17 the method signature will change to accept only `RememberAccessToken`. -- `Flarum\Http\Rememberer::rememberUser()` has been deprecated. Instead you should create/retrieve a token manually with `RememberAccessToken::generate()` and pass it to `Rememberer::remember()` -- `Flarum\Http\SessionAuthenticator::logIn(Session $session, $userId)` second parameter has been deprecated and is replaced with `$token`. Backward compatibility is kept. In beta 17, the second parameter method signature will change to `AccessToken $token`. -- `AccessToken::generate()` now saves the model to the database before returning it. -- `AccessToken::find($id)` or `::findOrFail($id)` can no longer be used to find a token, because the primary key was changed from `token` to `id`. Instead you can use `AccessToken::findValid($tokenString)` -- It's recommended to use `AccessToken::findValid($tokenString): AccessToken` or `AccessToken::whereValid(): Illuminate\Database\Eloquent\Builder` to find a token. This will automatically scope the request to only return valid tokens. On forums with low activity this increases the security since the automatic deletion of outdated tokens only happens every 50 requests on average. - -#### Symfony session changes - -If you are directly accessing or manipulating the Symfony session object, the following changes have been made: - -- `user_id` attribute is no longer used. `access_token` has been added as a replacement. It's a string that maps to the `token` column of the `access_tokens` database table. - -To retrieve the current user from inside a Flarum extension, the ideal solution which was already present in Flarum is to use `$request->getAttribute('actor')` which returns a `User` instance (which might be `Guest`) - -To retrieve the token instance from Flarum, you can use `Flarum\Http\AccessToken::findValid($tokenString)` - -To retrieve the user data from a non-Flarum application, you'll need to make an additional database request to retrieve the token. The user ID is present as `user_id` on the `access_tokens` table. - -#### Token creation changes - -The `lifetime` property of access tokens has been removed. Tokens are now either `session` tokens with 1h lifetime after last activity, or `session_remember` tokens with 5 years lifetime after last activity. - -The `remember` parameter that was previously available on the `POST /login` endpoint has been made available on `POST /api/token`. It doesn't return the remember cookie itself, but the token returned can be used as a remember cookie. - -The `lifetime` parameter of `POST /api/token` has been deprecated and will be removed in Flarum beta 17. Partial backward compatibility has been provided where a `lifetime` value longer than 3600 seconds is interpreted like `remember=1`. Values lower than 3600 seconds result in a normal non-remember token. - -New `developer` tokens that don't expire have been introduced, however they cannot be currently created through the REST API. Developers can create developer tokens from an extension using `Flarum\Http\DeveloperAccessToken::generate($userId)`. - -If you manually created tokens in the database from outside Flarum, the `type` column is now required and must contain `session`, `session_remember` or `developer`. Tokens of unrecognized type cannot be used to authenticate, but won't be deleted by the garbage collector either. In a future version extensions will be able to register custom access token types. - -#### Token usage changes - -A [security issue in Flarum](https://github.com/flarum/core/issues/2075) previously caused all tokens to never expire. This had limited security impact due to tokens being long unique characters. However custom integrations that saved a token in an external database for later use might find the tokens no longer working if they were not used recently. - -If you use short-lived access tokens for any purpose, take note of the expiration time of 1h. The expiration is based on the time of last usage, so it will remain valid as long as it continues to be used. - -Due to the large amount of expired tokens accumulated in the database and the fact most tokens weren't ever used more than once during the login process, we have made the choice to delete all access tokens a lifetime of 3600 seconds as part of the migration, All remaining tokens have been converted to `session_remember` tokens. - -#### Remember cookie - -The remember cookie still works like before, but a few changes have been made that could break unusual implementations. - -Now only access tokens created with `remember` option can be used as remember cookie. Any other type of token will be ignored. This means if you create a token with `POST /api/token` and then place it in the cookie manually, make sure you set `remember=1` when creating the token. - -#### Web session expiration - -In previous versions of Flarum, a session could be kept alive forever until the Symfony session files were deleted from disk. - -Now sessions are linked to access tokens. A token being deleted or expiring will automatically end the linked web session. - -A token linked to a web session will now be automatically deleted from the database when the user clicks logout. This prevents any stolen token from being re-used, but it could break custom integration that previously used a single access token in both a web session and something else. - -### Miscellaneous - -- The IP address is now available in requests via `$request->getAttribute('ipAddress')` -- Policies can now return `true` and `false` as aliases for `$this->allow()` and `$this->deny()`, respectively. -- The `user.edit` permission has been split into `user.editGroups`, `user.editCredentials` (for email, username, and password), and `user.edit` (for other attributes). -- There are now permissions (`bypassTagCounts`) that allow users to bypass tag count requirements. -- Flarum now supports PHP 7.3 - PHP 8.0, with support for PHP 7.2 officially dropped. diff --git a/docs/extend/update-b8.md b/docs/extend/update-b8.md deleted file mode 100644 index eaf3b874f..000000000 --- a/docs/extend/update-b8.md +++ /dev/null @@ -1,114 +0,0 @@ -# Updating For Beta 8 - -All extensions will need to be refactored in order to work with beta 8. Here are the main things you will need to do in order to make your extension compatible. - -:::caution - -This guide is not comprehensive. You may encounter some changes we haven't documented. If you need help, start a discussion on the [community forum](https://discuss.flarum.org/t/extensibility) or [Discord chat](https://flarum.org/discord/). - -::: - -## PHP Namespaces - -Beta 8 comes with large changes to the overall structure of the PHP backend. You will need to look through [this list](https://discuss.flarum.org/d/6572-help-us-namespace-changes) of namespace changes and make changes to your extension accordingly. - -[This script](https://gist.github.com/tobyzerner/55e7c05c95404e5efab3a9e43799d375) can help you to automate most of the namespace changes. Of course, you should still test your extension after running the script as it may miss something. - -## Database Naming - -Many database columns and JSON:API attributes have been renamed to conform to a [convention](/contributing.md#database). You will need to update any instances where your extension interacts with core data. You can see the changes in [#1344](https://github.com/flarum/core/pull/1344/files). - -## Extenders - -Beta 8 introduces a new concept called **extenders** that replace the most common event listeners. You can learn more about how they work in the [updated extension docs](start.md#extenders). - -`bootstrap.php` has been renamed to `extend.php` and returns an array of extender instances and functions: - -```php -use Flarum\Extend; - -return [ - (new Extend\Frontend('forum')) - ->js(__DIR__.'/js/dist/forum.js') - ->css(__DIR__.'/less/forum.less') - ->route('/t/{slug}', 'tag') - ->route('/tags', 'tags'), - - function (Dispatcher $events) { - $events->subscribe(Listener\AddForumTagsRelationship::class); - } -] -``` - -If you're listening for any of the following events, you'll need to update your code to use an extender instead. See the relevant docs for more information. - -| Event | Extender | -| ----------------------------------- | ------------------------- | -| `Flarum\Event\ConfigureFormatter`* | `Flarum\Extend\Formatter` | -| `Flarum\Event\ConfigureWebApp`* | `Flarum\Extend\Frontend` | -| `Flarum\Event\ConfigureClientView`* | `Flarum\Extend\Frontend` | -| `Flarum\Event\ConfigureLocales` | `Flarum\Extend\Locales` | -| `Flarum\Event\ConfigureApiRoutes` | `Flarum\Extend\Routes` | -| `Flarum\Event\ConfigureForumRoutes` | `Flarum\Extend\Routes` | - -_\* class no longer exists_ - -## JavaScript Tooling - -Previously Flarum and its extensions used a custom Gulp workflow to compile ES6 source code into something that browsers could understand. Beta 8 switches to a more conventional approach with Webpack. - -You will need to tweak the structure of your extension's `js` directory. Currently, your JS file hierarchy looks something like the following: - -``` -js -├── admin -│ ├── src -│ │ └── main.js -│ ├── dist -│ │ └── extension.js -│ ├── Gulpfile.js -│ └── package.json -└── forum - ├── src - │ └── main.js - ├── dist - │ └── extension.js - ├── Gulpfile.js - └── package.json -``` - -You'll need to make the following changes: - -1. Update `package.json` and create `webpack.config.js`, `forum.js`, and `admin.js` files using [these templates](frontend.md#transpilation). - -2. Inside your `admin` and `forum` *folders*, delete `Gulpfile.js`, `package.json`, and `dist`. Then inside each `src` folder, rename `main.js` to `index.js`. Now move all of the `src` files outside of `src` folder and delete it. - -3. In the root `js` folder create a folder called `src` and move your `admin` and `forum` *folders* into it. - -4. While still in your root `js` folder, run `npm install` and then `npm run build` to build the new JS dist files. - -If everything went right, your folder structure should look something like this: - -``` -js -├── src -│ ├── admin -│ │ └── index.js -│ └── forum -│ └── index.js -├── dist -│ ├── admin.js -│ ├── admin.js.map -│ ├── forum.js -│ └── forum.js.map -├── admin.js -├── forum.js -├── package.json -└── webpack.config.js -``` - -Take a look at the [bundled extensions](https://github.com/flarum) for more examples. - -## Font Awesome Icons - -Beta 8 upgrades to Font Awesome 5, in which icon class names have changed. The `flarum/helpers/icon` helper now requires the **full Font Awesome icon class names** to be passed, eg. `fas fa-bolt`. diff --git a/docusaurus.config.js b/docusaurus.config.js index 0908609e9..0f798c8d9 100644 --- a/docusaurus.config.js +++ b/docusaurus.config.js @@ -150,9 +150,27 @@ const darkCodeTheme = require('prism-react-renderer/themes/dracula'); copyright: `Copyright © ${new Date().getFullYear()} Flarum. Built with Docusaurus.`, }, prism: { - additionalLanguages: ['php'], + additionalLanguages: ['php','bash'], theme: lightCodeTheme, darkTheme: darkCodeTheme, + magicComments: [ + // Remember to extend the default highlight class name as well! + { + className: 'theme-code-block-highlighted-line', + line: 'highlight-next-line', + block: {start: 'highlight-start', end: 'highlight-end'}, + }, + { + className: 'code-block-remove-line', + line: 'remove-next-line', + block: {start: 'remove-start', end: 'remove-end'}, + }, + { + className: 'code-block-insert-line', + line: 'insert-next-line', + block: {start: 'insert-start', end: 'insert-end'}, + }, + ], }, algolia: { appId: 'QHP1YG60G0', diff --git a/sidebars.js b/sidebars.js index 796cbc746..00229305d 100644 --- a/sidebars.js +++ b/sidebars.js @@ -79,6 +79,16 @@ module.exports = { 'extend/cli' ] }, + { + type: 'category', + label: 'Update Guides', + collapsible: false, + items: [ + // 'extend/update-2_x', + 'extend/update-2_0', + 'extend/update-2_0-api', + ] + }, { type: 'category', label: 'Reference Guides', @@ -126,23 +136,6 @@ module.exports = { 'extend/code-splitting', ] }, - { - type: 'category', - label: 'Update Guides', - collapsible: false, - items: [ - 'extend/update-2_0', - 'extend/update-1_x', - 'extend/update-1_0', - 'extend/update-b16', - 'extend/update-b15', - 'extend/update-b14', - 'extend/update-b13', - 'extend/update-b12', - 'extend/update-b10', - 'extend/update-b8', - ] - }, ], internalSidebar: [ diff --git a/src/css/custom.css b/src/css/custom.css index 7300e1e8c..69867bd39 100644 --- a/src/css/custom.css +++ b/src/css/custom.css @@ -32,6 +32,8 @@ --ifm-container-width-xl: 1125px; --ifm-menu-link-padding-horizontal: 6px; + + --docusaurus-highlighted-code-line-bg: rgba(72, 77, 91, 0.35); } html[data-theme='light'] { @@ -264,3 +266,17 @@ img[alt="Flarum Home Screenshot"] { position: relative; border: 1px solid #eee; } + +.code-block-remove-line { + background-color: #ff000020; + display: block; + margin: 0 calc(-1 * var(--ifm-pre-padding)); + padding: 0 var(--ifm-pre-padding); +} + +.code-block-insert-line { + background-color: rgba(0, 255, 0, 0.05); + display: block; + margin: 0 calc(-1 * var(--ifm-pre-padding)); + padding: 0 var(--ifm-pre-padding); +}