diff --git a/extensions/tags/extend.php b/extensions/tags/extend.php index b4fffbec4..560767639 100644 --- a/extensions/tags/extend.php +++ b/extensions/tags/extend.php @@ -7,10 +7,10 @@ * LICENSE file that was distributed with this source code. */ -use Flarum\Api\Controller as FlarumController; -use Flarum\Api\Serializer\BasicPostSerializer; -use Flarum\Api\Serializer\DiscussionSerializer; -use Flarum\Api\Serializer\ForumSerializer; +use Flarum\Api\Context; +use Flarum\Api\Endpoint; +use Flarum\Api\Resource; +use Flarum\Api\Schema; use Flarum\Discussion\Discussion; use Flarum\Discussion\Event\Saving; use Flarum\Discussion\Search\DiscussionSearcher; @@ -21,12 +21,10 @@ use Flarum\Post\Filter\PostSearcher; use Flarum\Post\Post; use Flarum\Search\Database\DatabaseSearchDriver; use Flarum\Tags\Access; -use Flarum\Tags\Api\Controller; -use Flarum\Tags\Api\Serializer\TagSerializer; +use Flarum\Tags\Api; use Flarum\Tags\Content; use Flarum\Tags\Event\DiscussionWasTagged; use Flarum\Tags\Listener; -use Flarum\Tags\LoadForumTagsRelationship; use Flarum\Tags\Post\DiscussionTaggedPost; use Flarum\Tags\Search\Filter\PostTagFilter; use Flarum\Tags\Search\Filter\TagFilter; @@ -39,10 +37,8 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Relations\Relation; use Psr\Http\Message\ServerRequestInterface; -$eagerLoadTagState = function ($query, ?ServerRequestInterface $request, array $relations) { - if ($request && in_array('tags.state', $relations, true)) { - $query->withStateFor(RequestUtil::getActor($request)); - } +$eagerLoadTagState = function ($query, ServerRequestInterface $request, array $relations) { + $query->withStateFor(RequestUtil::getActor($request)); }; return [ @@ -61,49 +57,65 @@ return [ ->css(__DIR__.'/less/admin.less'), (new Extend\Routes('api')) - ->get('/tags', 'tags.index', Controller\ListTagsController::class) - ->post('/tags', 'tags.create', Controller\CreateTagController::class) - ->post('/tags/order', 'tags.order', Controller\OrderTagsController::class) - ->get('/tags/{slug}', 'tags.show', Controller\ShowTagController::class) - ->patch('/tags/{id}', 'tags.update', Controller\UpdateTagController::class) - ->delete('/tags/{id}', 'tags.delete', Controller\DeleteTagController::class), + ->post('/tags/order', 'tags.order', Api\Controller\OrderTagsController::class), (new Extend\Model(Discussion::class)) ->belongsToMany('tags', Tag::class, 'discussion_tag'), - (new Extend\ApiSerializer(ForumSerializer::class)) - ->hasMany('tags', TagSerializer::class) - ->attribute('canBypassTagCounts', function (ForumSerializer $serializer) { - return $serializer->getActor()->can('bypassTagCounts'); + (new Extend\ApiResource(Api\Resource\TagResource::class)), + + (new Extend\ApiResource(Resource\ForumResource::class)) + ->fields(fn () => [ + Schema\Relationship\ToMany::make('tags') + ->includable() + ->get(function ($model, Context $context) { + $actor = $context->getActor(); + + return Tag::query() + ->where(function ($query) { + $query + ->whereNull('parent_id') + ->whereNotNull('position'); + }) + ->union( + Tag::whereVisibleTo($actor) + ->whereNull('parent_id') + ->whereNull('position') + ->orderBy('discussion_count', 'desc') + ->limit(4) // We get one more than we need so the "more" link can be shown. + ) + ->whereVisibleTo($actor) + ->withStateFor($actor) + ->get() + ->all(); + }), + Schema\Boolean::make('canBypassTagCounts') + ->get(fn ($model, Context $context) => $context->getActor()->can('bypassTagCounts')), + ]) + ->endpoint(Endpoint\Show::class, function (Endpoint\Show $endpoint) { + return $endpoint->addDefaultInclude(['tags', 'tags.parent']); }), - (new Extend\ApiSerializer(DiscussionSerializer::class)) - ->hasMany('tags', TagSerializer::class) - ->attribute('canTag', function (DiscussionSerializer $serializer, $model) { - return $serializer->getActor()->can('tag', $model); + (new Extend\ApiResource(Resource\DiscussionResource::class)) + ->fields(Api\DiscussionResourceFields::class), + + (new Extend\ApiResource(Resource\PostResource::class)) + ->endpoint(Endpoint\Index::class, function (Endpoint\Index $endpoint) { + return $endpoint->eagerLoad('discussion.tags'); }), - (new Extend\ApiController(FlarumController\ListPostsController::class)) - ->load('discussion.tags'), +// (new Extend\ApiController(ListFlagsController::class)) +// ->load('post.discussion.tags'), - (new Extend\ApiController(ListFlagsController::class)) - ->load('post.discussion.tags'), - - (new Extend\ApiController(FlarumController\ListDiscussionsController::class)) - ->addInclude(['tags', 'tags.state', 'tags.parent']) - ->loadWhere('tags', $eagerLoadTagState), - - (new Extend\ApiController(FlarumController\ShowDiscussionController::class)) - ->addInclude(['tags', 'tags.state', 'tags.parent']) - ->loadWhere('tags', $eagerLoadTagState), - - (new Extend\ApiController(FlarumController\CreateDiscussionController::class)) - ->addInclude(['tags', 'tags.state', 'tags.parent']) - ->loadWhere('tags', $eagerLoadTagState), - - (new Extend\ApiController(FlarumController\ShowForumController::class)) - ->addInclude(['tags', 'tags.parent']) - ->prepareDataForSerialization(LoadForumTagsRelationship::class), + (new Extend\ApiResource(Resource\DiscussionResource::class)) + ->endpoint( + [Endpoint\Index::class, Endpoint\Show::class, Endpoint\Create::class], + function (Endpoint\Index|Endpoint\Show|Endpoint\Create $endpoint) use ($eagerLoadTagState) { + return $endpoint + ->addDefaultInclude(['tags', 'tags.parent']) + ->eagerLoadWhere('tags', $eagerLoadTagState); + } + ), (new Extend\Settings()) ->serializeToForum('minPrimaryTags', 'flarum-tags.min_primary_tags') @@ -131,7 +143,6 @@ return [ ->type(DiscussionTaggedPost::class), (new Extend\Event()) - ->listen(Saving::class, Listener\SaveTagsToDatabase::class) ->listen(DiscussionWasTagged::class, Listener\CreatePostWhenTagsAreChanged::class) ->subscribe(Listener\UpdateTagMetadata::class), @@ -158,27 +169,26 @@ return [ return $model->mentionsTags(); }), - (new Extend\ApiSerializer(BasicPostSerializer::class)) - ->relationship('eventPostMentionsTags', function (BasicPostSerializer $serializer, Post $model) { - if ($model instanceof DiscussionTaggedPost) { - return $serializer->hasMany($model, TagSerializer::class, 'eventPostMentionsTags'); - } - - return null; - }) - ->hasMany('eventPostMentionsTags', TagSerializer::class), - - (new Extend\ApiController(FlarumController\ListPostsController::class)) - ->addInclude('eventPostMentionsTags') - // Restricted tags should still appear as `deleted` to unauthorized users. - ->loadWhere('eventPostMentionsTags', $restrictMentionedTags = function (Relation|Builder $query, ?ServerRequestInterface $request) { - if ($request) { - $actor = RequestUtil::getActor($request); - $query->whereVisibleTo($actor); - } + (new Extend\ApiResource(Resource\PostResource::class)) + ->fields(fn () => [ + Schema\Relationship\ToMany::make('eventPostMentionsTags') + ->type('tags') + ->includable(), + ]) + ->endpoint(Endpoint\Index::class, function (Endpoint\Index $endpoint) { + return $endpoint + ->addDefaultInclude(['eventPostMentionsTags']) + ->eagerLoadWhere('eventPostMentionsTags', function (Relation|Builder $query, ServerRequestInterface $request) { + $query->whereVisibleTo(RequestUtil::getActor($request)); + }); }), - (new Extend\ApiController(FlarumController\ShowDiscussionController::class)) - ->addInclude('posts.eventPostMentionsTags') - ->loadWhere('posts.eventPostMentionsTags', $restrictMentionedTags), + (new Extend\ApiResource(Resource\DiscussionResource::class)) + ->endpoint(Endpoint\Show::class, function (Endpoint\Show $endpoint) { + return $endpoint + ->addDefaultInclude(['posts.eventPostMentionsTags']) + ->eagerLoadWhere('posts.eventPostMentionsTags', function (Relation|Builder $query, ServerRequestInterface $request) { + $query->whereVisibleTo(RequestUtil::getActor($request)); + }); + }), ]; diff --git a/extensions/tags/js/src/admin/components/EditTagModal.tsx b/extensions/tags/js/src/admin/components/EditTagModal.tsx index ffc76c288..d53ba2fa1 100644 --- a/extensions/tags/js/src/admin/components/EditTagModal.tsx +++ b/extensions/tags/js/src/admin/components/EditTagModal.tsx @@ -30,7 +30,7 @@ export default class EditTagModal extends FormModal { color!: Stream; icon!: Stream; isHidden!: Stream; - primary!: Stream; + isPrimary!: Stream; oninit(vnode: Mithril.Vnode) { super.oninit(vnode); @@ -43,7 +43,7 @@ export default class EditTagModal extends FormModal { this.color = Stream(this.tag.color() || ''); this.icon = Stream(this.tag.icon() || ''); this.isHidden = Stream(this.tag.isHidden() || false); - this.primary = Stream(this.attrs.primary || false); + this.isPrimary = Stream(this.attrs.primary || false); } className() { @@ -164,7 +164,7 @@ export default class EditTagModal extends FormModal { color: this.color(), icon: this.icon(), isHidden: this.isHidden(), - primary: this.primary(), + isPrimary: this.isPrimary(), }; } @@ -189,8 +189,6 @@ export default class EditTagModal extends FormModal { children.forEach((tag) => tag.pushData({ attributes: { isChild: false }, - // @deprecated. Temporary hack for type safety, remove before v1.3. - relationships: { parent: null as any as [] }, }) ); m.redraw(); diff --git a/extensions/tags/js/src/common/components/TagSelectionModal.tsx b/extensions/tags/js/src/common/components/TagSelectionModal.tsx index 68e8affb4..d8b47ab1d 100644 --- a/extensions/tags/js/src/common/components/TagSelectionModal.tsx +++ b/extensions/tags/js/src/common/components/TagSelectionModal.tsx @@ -252,10 +252,10 @@ export default class TagSelectionModal< // we'll filter out all other tags of that type. else { if (primaryCount >= this.attrs.limits!.max!.primary!) { - tags = tags.filter((tag) => !tag.isPrimary() || this.selected.includes(tag)); + tags = tags.filter((tag) => !tag.isPrimaryParent() || this.selected.includes(tag)); } if (secondaryCount >= this.attrs.limits!.max!.secondary!) { - tags = tags.filter((tag) => tag.isPrimary() || this.selected.includes(tag)); + tags = tags.filter((tag) => tag.isPrimaryParent() || this.selected.includes(tag)); } } } @@ -275,14 +275,14 @@ export default class TagSelectionModal< * Counts the number of selected primary tags. */ protected primaryCount(): number { - return this.selected.filter((tag) => tag.isPrimary()).length; + return this.selected.filter((tag) => tag.isPrimaryParent()).length; } /** * Counts the number of selected secondary tags. */ protected secondaryCount(): number { - return this.selected.filter((tag) => !tag.isPrimary()).length; + return this.selected.filter((tag) => !tag.isPrimaryParent()).length; } /** diff --git a/extensions/tags/js/src/common/models/Tag.ts b/extensions/tags/js/src/common/models/Tag.ts index d68c8698e..46a023bec 100644 --- a/extensions/tags/js/src/common/models/Tag.ts +++ b/extensions/tags/js/src/common/models/Tag.ts @@ -44,6 +44,9 @@ export default class Tag extends Model { isHidden() { return Model.attribute('isHidden').call(this); } + isPrimary() { + return Model.attribute('isPrimary').call(this); + } discussionCount() { return Model.attribute('discussionCount').call(this); @@ -65,7 +68,7 @@ export default class Tag extends Model { return Model.attribute('canAddToDiscussion').call(this); } - isPrimary() { + isPrimaryParent() { return computed('position', 'parent', (position, parent) => position !== null && parent === false).call(this); } } diff --git a/extensions/tags/js/src/forum/addTagFilter.tsx b/extensions/tags/js/src/forum/addTagFilter.tsx index 34fee4628..13875b15e 100644 --- a/extensions/tags/js/src/forum/addTagFilter.tsx +++ b/extensions/tags/js/src/forum/addTagFilter.tsx @@ -39,7 +39,7 @@ export default function addTagFilter() { // - We loaded in that child tag (and its siblings) in the API document // - We first navigated to the current tag's parent, which would have loaded in the current tag's siblings. this.store - .find('tags', slug, { include: 'children,children.parent,parent,state' }) + .find('tags', slug, { include: 'children,children.parent,parent' }) .then(() => { this.currentActiveTag = findTag(slug); diff --git a/extensions/tags/migrations/2024_02_23_000000_add_is_primary_column_to_tags.php b/extensions/tags/migrations/2024_02_23_000000_add_is_primary_column_to_tags.php new file mode 100644 index 000000000..51e188204 --- /dev/null +++ b/extensions/tags/migrations/2024_02_23_000000_add_is_primary_column_to_tags.php @@ -0,0 +1,22 @@ + function (Builder $schema) { + $schema->table('tags', function (Blueprint $table) { + $table->boolean('is_primary')->default(false)->after('background_mode'); + }); + + $schema->getConnection() + ->table('tags') + ->whereNotNull('position') + ->update(['is_primary' => true]); + }, + 'down' => function (Builder $schema) { + $schema->table('tags', function (Blueprint $table) { + $table->dropColumn('is_primary'); + }); + } +]; diff --git a/extensions/tags/src/Api/Controller/CreateTagController.php b/extensions/tags/src/Api/Controller/CreateTagController.php deleted file mode 100644 index 7a5fcd6f7..000000000 --- a/extensions/tags/src/Api/Controller/CreateTagController.php +++ /dev/null @@ -1,39 +0,0 @@ -bus->dispatch( - new CreateTag(RequestUtil::getActor($request), Arr::get($request->getParsedBody(), 'data', [])) - ); - } -} diff --git a/extensions/tags/src/Api/Controller/DeleteTagController.php b/extensions/tags/src/Api/Controller/DeleteTagController.php deleted file mode 100644 index db75abeb8..000000000 --- a/extensions/tags/src/Api/Controller/DeleteTagController.php +++ /dev/null @@ -1,32 +0,0 @@ -bus->dispatch( - new DeleteTag(Arr::get($request->getQueryParams(), 'id'), RequestUtil::getActor($request)) - ); - } -} diff --git a/extensions/tags/src/Api/Controller/ListTagsController.php b/extensions/tags/src/Api/Controller/ListTagsController.php deleted file mode 100644 index 2f23267a6..000000000 --- a/extensions/tags/src/Api/Controller/ListTagsController.php +++ /dev/null @@ -1,78 +0,0 @@ -extractInclude($request); - $filters = $this->extractFilter($request); - $limit = $this->extractLimit($request); - $offset = $this->extractOffset($request); - - if (in_array('lastPostedDiscussion', $include)) { - $include = array_merge($include, ['lastPostedDiscussion.tags', 'lastPostedDiscussion.state']); - } - - if (array_key_exists('q', $filters)) { - $results = $this->search->query(Tag::class, new SearchCriteria($actor, $filters, $limit, $offset)); - - $tags = $results->getResults(); - - $document->addPaginationLinks( - $this->url->to('api')->route('tags.index'), - $request->getQueryParams(), - $offset, - $limit, - $results->areMoreResults() ? null : 0 - ); - } else { - $tags = $this->tags - ->with($include, $actor) - ->whereVisibleTo($actor) - ->withStateFor($actor) - ->get(); - } - - return $tags; - } -} diff --git a/extensions/tags/src/Api/Controller/OrderTagsController.php b/extensions/tags/src/Api/Controller/OrderTagsController.php index 9cb4e125e..4dbfd1370 100644 --- a/extensions/tags/src/Api/Controller/OrderTagsController.php +++ b/extensions/tags/src/Api/Controller/OrderTagsController.php @@ -31,19 +31,21 @@ class OrderTagsController implements RequestHandlerInterface Tag::query()->update([ 'position' => null, - 'parent_id' => null + 'parent_id' => null, + 'is_primary' => false, ]); foreach ($order as $i => $parent) { $parentId = Arr::get($parent, 'id'); - Tag::where('id', $parentId)->update(['position' => $i]); + Tag::where('id', $parentId)->update(['position' => $i, 'is_primary' => true]); if (isset($parent['children']) && is_array($parent['children'])) { foreach ($parent['children'] as $j => $childId) { Tag::where('id', $childId)->update([ 'position' => $j, - 'parent_id' => $parentId + 'parent_id' => $parentId, + 'is_primary' => true, ]); } } diff --git a/extensions/tags/src/Api/Controller/ShowTagController.php b/extensions/tags/src/Api/Controller/ShowTagController.php deleted file mode 100644 index ae34d51de..000000000 --- a/extensions/tags/src/Api/Controller/ShowTagController.php +++ /dev/null @@ -1,69 +0,0 @@ -getQueryParams(), 'slug'); - $actor = RequestUtil::getActor($request); - $include = $this->extractInclude($request); - $setParentOnChildren = false; - - if (in_array('parent.children.parent', $include, true)) { - $setParentOnChildren = true; - $include[] = 'parent.children'; - $include = array_unique(array_diff($include, ['parent.children.parent'])); - } - - $tag = $this->slugger - ->forResource(Tag::class) - ->fromSlug($slug, $actor); - - $tag->load($this->tags->getAuthorizedRelations($include, $actor)); - - if ($setParentOnChildren && $tag->parent) { - foreach ($tag->parent->children as $child) { - $child->parent = $tag->parent; - } - } - - return $tag; - } -} diff --git a/extensions/tags/src/Api/Controller/UpdateTagController.php b/extensions/tags/src/Api/Controller/UpdateTagController.php deleted file mode 100644 index 2f5776446..000000000 --- a/extensions/tags/src/Api/Controller/UpdateTagController.php +++ /dev/null @@ -1,41 +0,0 @@ -getQueryParams(), 'id'); - $actor = RequestUtil::getActor($request); - $data = Arr::get($request->getParsedBody(), 'data', []); - - return $this->bus->dispatch( - new EditTag($id, $actor, $data) - ); - } -} diff --git a/extensions/tags/src/Api/DiscussionResourceFields.php b/extensions/tags/src/Api/DiscussionResourceFields.php new file mode 100644 index 000000000..90dfe8de8 --- /dev/null +++ b/extensions/tags/src/Api/DiscussionResourceFields.php @@ -0,0 +1,107 @@ +get(fn (Discussion $discussion, Context $context) => $context->getActor()->can('tag', $discussion)), + Schema\Relationship\ToMany::make('tags') + ->includable() + ->writable() + ->required(fn (Discussion $discussion, Context $context) => ! $context->getActor()->can('bypassTagCounts', $discussion)) + ->set(function (Discussion $discussion, array $newTags, Context $context) { + $actor = $context->getActor(); + + $newTagIds = array_map(fn (Tag $tag) => $tag->id, $newTags); + + $primaryParentCount = 0; + $secondaryOrPrimaryChildCount = 0; + + if ($discussion->exists) { + $actor->assertCan('tag', $discussion); + + $oldTags = $discussion->tags()->get(); + $oldTagIds = $oldTags->pluck('id')->all(); + + if ($oldTagIds == $newTagIds) { + return; + } + + foreach ($newTags as $tag) { + if (! in_array($tag->id, $oldTagIds) && $actor->cannot('addToDiscussion', $tag)) { + throw new PermissionDeniedException; + } + } + + $discussion->raise( + new DiscussionWasTagged($discussion, $actor, $oldTags->all()) + ); + } + + foreach ($newTags as $tag) { + if (!$discussion->exists && $actor->cannot('startDiscussion', $tag)) { + throw new PermissionDeniedException; + } + + if ($tag->position !== null && $tag->parent_id === null) { + $primaryParentCount++; + } else { + $secondaryOrPrimaryChildCount++; + } + } + + if (!$discussion->exists && $primaryParentCount === 0 && $secondaryOrPrimaryChildCount === 0 && ! $actor->hasPermission('startDiscussion')) { + throw new PermissionDeniedException; + } + + if (! $actor->can('bypassTagCounts', $discussion)) { + $this->validateTagCount('primary', $primaryParentCount); + $this->validateTagCount('secondary', $secondaryOrPrimaryChildCount); + } + + $discussion->afterSave(function ($discussion) use ($newTagIds) { + $discussion->tags()->sync($newTagIds); + $discussion->unsetRelation('tags'); + }); + }), + ]; + } + + protected function validateTagCount(string $type, int $count): void + { + $min = $this->settings->get('flarum-tags.min_'.$type.'_tags'); + $max = $this->settings->get('flarum-tags.max_'.$type.'_tags'); + $key = 'tag_count_'.$type; + + $validator = $this->validator->make( + [$key => $count], + [$key => ['numeric', $min === $max ? "size:$min" : "between:$min,$max"]] + ); + + if ($validator->fails()) { + throw new ValidationException([], ['tags' => $validator->getMessageBag()->first($key)]); + } + } +} diff --git a/extensions/tags/src/Api/Resource/TagResource.php b/extensions/tags/src/Api/Resource/TagResource.php new file mode 100644 index 000000000..07a304d1a --- /dev/null +++ b/extensions/tags/src/Api/Resource/TagResource.php @@ -0,0 +1,146 @@ +whereVisibleTo($context->getActor()); + + if ($context->collection instanceof self && ( + $context->endpoint instanceof Endpoint\Index + || $context->endpoint instanceof Endpoint\Show + )) { + $query->withStateFor($context->getActor()); + } + } + + public function find(string $id, \Tobyz\JsonApiServer\Context $context): ?object + { + $actor = $context->getActor(); + + if (is_numeric($id) && $tag = $this->query($context)->find($id)) { + return $tag; + } + + return $this->slugManager->forResource(Tag::class)->fromSlug($id, $actor); + } + + public function endpoints(): array + { + return [ + Endpoint\Show::make(), + Endpoint\Create::make() + ->authenticated() + ->can('createTag'), + Endpoint\Update::make() + ->authenticated() + ->can('edit'), + Endpoint\Delete::make() + ->authenticated() + ->can('delete'), + Endpoint\Index::make() + ->defaultInclude(['parent']), + ]; + } + + public function fields(): array + { + return [ + Schema\Str::make('name') + ->requiredOnCreate() + ->writable(), + Schema\Str::make('description') + ->writable() + ->maxLength(700) + ->nullable(), + Schema\Str::make('slug') + ->requiredOnCreate() + ->writable() + ->unique('tags', 'slug', true) + ->regex('/^[^\/\\ ]*$/i') + ->get(function (Tag $tag) { + return $this->slugManager->forResource($tag::class)->toSlug($tag); + }), + Schema\Str::make('color') + ->writable() + ->nullable() + ->regex('/^#([a-f0-9]{6}|[a-f0-9]{3})$/i'), + Schema\Str::make('icon') + ->writable() + ->nullable(), + Schema\Boolean::make('isHidden') + ->writable(), + Schema\Boolean::make('isPrimary') + ->writable(), + Schema\Boolean::make('isRestricted') + ->writableOnUpdate() + ->visible(fn (Tag $tag, Context $context) => $context->getActor()->isAdmin()), + Schema\Str::make('backgroundUrl') + ->get(fn (Tag $tag) => $tag->background_path), + Schema\Str::make('backgroundMode'), + Schema\Integer::make('discussionCount'), + Schema\Integer::make('position') + ->nullable(), + Schema\Str::make('defaultSort'), + Schema\Boolean::make('isChild') + ->get(fn (Tag $tag) => (bool) $tag->parent_id), + Schema\DateTime::make('lastPostedAt'), + Schema\Boolean::make('canStartDiscussion') + ->get(fn (Tag $tag, Context $context) => $context->getActor()->can('startDiscussion', $tag)), + Schema\Boolean::make('canAddToDiscussion') + ->get(fn (Tag $tag, Context $context) => $context->getActor()->can('addToDiscussion', $tag)), + + Schema\Relationship\ToOne::make('parent') + ->type('tags') + ->includable() + ->writable(fn (Tag $tag, Context $context) => (bool) Arr::get($context->body(), 'attributes.isPrimary')), + Schema\Relationship\ToMany::make('children') + ->type('tags') + ->includable(), + Schema\Relationship\ToOne::make('lastPostedDiscussion') + ->type('discussions') + ->includable(), + ]; + } + + protected function newSavingEvent(Context $context, array $data): ?object + { + return $context->endpoint instanceof Endpoint\Create + ? new Creating($context->model, $context->getActor(), $data) + : new Saving($context->model, $context->getActor(), $data); + } + + public function deleting(object $model, Context $context): void + { + $this->events->dispatch(new Deleting($model, $context->getActor())); + } +} diff --git a/extensions/tags/src/Api/Serializer/TagSerializer.php b/extensions/tags/src/Api/Serializer/TagSerializer.php deleted file mode 100644 index f3f14bce1..000000000 --- a/extensions/tags/src/Api/Serializer/TagSerializer.php +++ /dev/null @@ -1,75 +0,0 @@ - $model->name, - 'description' => $model->description, - 'slug' => $this->slugManager->forResource(Tag::class)->toSlug($model), - 'color' => $model->color, - 'backgroundUrl' => $model->background_path, - 'backgroundMode' => $model->background_mode, - 'icon' => $model->icon, - 'discussionCount' => (int) $model->discussion_count, - 'position' => $model->position === null ? null : (int) $model->position, - 'defaultSort' => $model->default_sort, - 'isChild' => (bool) $model->parent_id, - 'isHidden' => (bool) $model->is_hidden, - 'lastPostedAt' => $this->formatDate($model->last_posted_at), - 'canStartDiscussion' => $this->actor->can('startDiscussion', $model), - 'canAddToDiscussion' => $this->actor->can('addToDiscussion', $model) - ]; - - if ($this->actor->isAdmin()) { - $attributes['isRestricted'] = (bool) $model->is_restricted; - } - - return $attributes; - } - - protected function parent(Tag $tag): ?Relationship - { - return $this->hasOne($tag, self::class); - } - - protected function children(Tag $tag): ?Relationship - { - return $this->hasMany($tag, self::class); - } - - protected function lastPostedDiscussion(Tag $tag): ?Relationship - { - return $this->hasOne($tag, DiscussionSerializer::class); - } -} diff --git a/extensions/tags/src/Command/CreateTag.php b/extensions/tags/src/Command/CreateTag.php deleted file mode 100644 index 2d3d4c99d..000000000 --- a/extensions/tags/src/Command/CreateTag.php +++ /dev/null @@ -1,21 +0,0 @@ -actor; - $data = $command->data; - - $actor->assertCan('createTag'); - - $tag = Tag::build( - Arr::get($data, 'attributes.name'), - Arr::get($data, 'attributes.slug'), - Arr::get($data, 'attributes.description'), - Arr::get($data, 'attributes.color'), - Arr::get($data, 'attributes.icon'), - Arr::get($data, 'attributes.isHidden') - ); - - $parentId = Arr::get($data, 'relationships.parent.data.id'); - $primary = Arr::get($data, 'attributes.primary'); - - if ($parentId !== null || $primary) { - $rootTags = Tag::whereNull('parent_id')->whereNotNull('position'); - - if ($parentId === 0 || $primary) { - $tag->position = $rootTags->max('position') + 1; - } elseif ($rootTags->find($parentId)) { - $position = Tag::where('parent_id', $parentId)->max('position'); - - $tag->parent()->associate($parentId); - $tag->position = $position === null ? 0 : $position + 1; - } - } - - $this->events->dispatch(new Creating($tag, $actor, $data)); - - $this->validator->assertValid($tag->getAttributes()); - - $tag->save(); - - return $tag; - } -} diff --git a/extensions/tags/src/Command/DeleteTag.php b/extensions/tags/src/Command/DeleteTag.php deleted file mode 100644 index 48d239088..000000000 --- a/extensions/tags/src/Command/DeleteTag.php +++ /dev/null @@ -1,22 +0,0 @@ -actor; - - $tag = $this->tags->findOrFail($command->tagId, $actor); - - $actor->assertCan('delete', $tag); - - $this->events->dispatch(new Deleting($tag, $actor)); - - $tag->delete(); - - return $tag; - } -} diff --git a/extensions/tags/src/Command/EditTag.php b/extensions/tags/src/Command/EditTag.php deleted file mode 100644 index a52d43586..000000000 --- a/extensions/tags/src/Command/EditTag.php +++ /dev/null @@ -1,22 +0,0 @@ -actor; - $data = $command->data; - - $tag = $this->tags->findOrFail($command->tagId, $actor); - - $actor->assertCan('edit', $tag); - - $attributes = Arr::get($data, 'attributes', []); - - if (isset($attributes['name'])) { - $tag->name = $attributes['name']; - } - - if (isset($attributes['slug'])) { - $tag->slug = $attributes['slug']; - } - - if (isset($attributes['description'])) { - $tag->description = $attributes['description']; - } - - if (isset($attributes['color'])) { - $tag->color = $attributes['color']; - } - - if (isset($attributes['icon'])) { - $tag->icon = $attributes['icon']; - } - - if (isset($attributes['isHidden'])) { - $tag->is_hidden = (bool) $attributes['isHidden']; - } - - if (isset($attributes['isRestricted'])) { - $tag->is_restricted = (bool) $attributes['isRestricted']; - } - - $this->events->dispatch(new Saving($tag, $actor, $data)); - - $this->validator->assertValid($tag->getDirty()); - - $tag->save(); - - return $tag; - } -} diff --git a/extensions/tags/src/Content/Tag.php b/extensions/tags/src/Content/Tag.php index 0d2afa426..1b683e763 100644 --- a/extensions/tags/src/Content/Tag.php +++ b/extensions/tags/src/Content/Tag.php @@ -101,7 +101,7 @@ class Tag ->withoutErrorHandling() ->withParentRequest($request) ->withQueryParams([ - 'include' => 'children,children.parent,parent,parent.children.parent,state' + 'include' => 'children,children.parent,parent,parent.children.parent' ]) ->get("/tags/$slug") ->getBody() diff --git a/extensions/tags/src/Listener/SaveTagsToDatabase.php b/extensions/tags/src/Listener/SaveTagsToDatabase.php deleted file mode 100755 index c1eba2ae8..000000000 --- a/extensions/tags/src/Listener/SaveTagsToDatabase.php +++ /dev/null @@ -1,116 +0,0 @@ -discussion; - $actor = $event->actor; - - $newTagIds = []; - $newTags = []; - - $primaryCount = 0; - $secondaryCount = 0; - - if (isset($event->data['relationships']['tags']['data'])) { - $linkage = (array) $event->data['relationships']['tags']['data']; - - foreach ($linkage as $link) { - $newTagIds[] = (int) $link['id']; - } - - $newTags = Tag::whereIn('id', $newTagIds)->get(); - } - - if ($discussion->exists && isset($event->data['relationships']['tags']['data'])) { - $actor->assertCan('tag', $discussion); - - $oldTags = $discussion->tags()->get(); - $oldTagIds = $oldTags->pluck('id')->all(); - - if ($oldTagIds == $newTagIds) { - return; - } - - foreach ($newTags as $tag) { - if (! in_array($tag->id, $oldTagIds) && $actor->cannot('addToDiscussion', $tag)) { - throw new PermissionDeniedException; - } - } - - $discussion->raise( - new DiscussionWasTagged($discussion, $actor, $oldTags->all()) - ); - } - - if (! $discussion->exists || isset($event->data['relationships']['tags']['data'])) { - foreach ($newTags as $tag) { - if (! $discussion->exists && $actor->cannot('startDiscussion', $tag)) { - throw new PermissionDeniedException; - } - - if ($tag->position !== null && $tag->parent_id === null) { - $primaryCount++; - } else { - $secondaryCount++; - } - } - - if (! $discussion->exists && $primaryCount === 0 && $secondaryCount === 0 && ! $actor->hasPermission('startDiscussion')) { - throw new PermissionDeniedException; - } - - if (! $actor->can('bypassTagCounts', $discussion)) { - $this->validateTagCount('primary', $primaryCount); - $this->validateTagCount('secondary', $secondaryCount); - } - - $discussion->afterSave(function ($discussion) use ($newTagIds) { - $discussion->tags()->sync($newTagIds); - $discussion->unsetRelation('tags'); - }); - } - } - - protected function validateTagCount(string $type, int $count): void - { - $min = $this->settings->get('flarum-tags.min_'.$type.'_tags'); - $max = $this->settings->get('flarum-tags.max_'.$type.'_tags'); - $key = 'tag_count_'.$type; - - $validator = $this->validator->make( - [$key => $count], - [$key => ['numeric', $min === $max ? "size:$min" : "between:$min,$max"]] - ); - - if ($validator->fails()) { - throw new ValidationException([], ['tags' => $validator->getMessageBag()->first($key)]); - } - } -} diff --git a/extensions/tags/src/LoadForumTagsRelationship.php b/extensions/tags/src/LoadForumTagsRelationship.php deleted file mode 100755 index e0d8c7453..000000000 --- a/extensions/tags/src/LoadForumTagsRelationship.php +++ /dev/null @@ -1,43 +0,0 @@ -where(function ($query) { - $query - ->whereNull('parent_id') - ->whereNotNull('position'); - }) - ->union( - Tag::whereVisibleTo($actor) - ->whereNull('parent_id') - ->whereNull('position') - ->orderBy('discussion_count', 'desc') - ->limit(4) // We get one more than we need so the "more" link can be shown. - ) - ->whereVisibleTo($actor) - ->withStateFor($actor) - ->get(); - } -} diff --git a/extensions/tags/src/Tag.php b/extensions/tags/src/Tag.php index f9b54f3ed..39bf37da2 100644 --- a/extensions/tags/src/Tag.php +++ b/extensions/tags/src/Tag.php @@ -30,6 +30,7 @@ use Illuminate\Database\Query\Builder as QueryBuilder; * @property string $color * @property string $background_path * @property string $background_mode + * @property bool $is_primary * @property int $position * @property int $parent_id * @property string $default_sort @@ -57,6 +58,7 @@ class Tag extends AbstractModel protected $casts = [ 'is_hidden' => 'bool', 'is_restricted' => 'bool', + 'is_primary' => 'bool', 'last_posted_at' => 'datetime', 'created_at' => 'datetime', 'updated_at' => 'datetime', @@ -72,6 +74,15 @@ class Tag extends AbstractModel } }); + static::creating(function (self $tag) { + if ($tag->is_primary) { + $tag->position = static::query() + ->when($tag->parent_id, fn ($query) => $query->where('parent_id', $tag->parent_id)) + ->where('is_primary', true) + ->max('position') + 1; + } + }); + static::deleted(function (self $tag) { $tag->deletePermissions(); }); diff --git a/extensions/tags/src/TagRepository.php b/extensions/tags/src/TagRepository.php index 6c74d0d91..063941ed2 100644 --- a/extensions/tags/src/TagRepository.php +++ b/extensions/tags/src/TagRepository.php @@ -15,8 +15,6 @@ use Illuminate\Database\Eloquent\Collection; class TagRepository { - private const TAG_RELATIONS = ['children', 'parent', 'parent.children']; - /** * @return Builder */ @@ -30,32 +28,6 @@ class TagRepository return $this->scopeVisibleTo($this->query(), $actor); } - /** - * @return Builder - */ - public function with(array|string $relations, User $actor): Builder - { - return $this->query()->with($this->getAuthorizedRelations($relations, $actor)); - } - - public function getAuthorizedRelations(array|string $relations, User $actor): array - { - $relations = is_string($relations) ? explode(',', $relations) : $relations; - $relationsArray = []; - - foreach ($relations as $relation) { - if (in_array($relation, self::TAG_RELATIONS, true)) { - $relationsArray[$relation] = function ($query) use ($actor) { - $query->whereVisibleTo($actor); - }; - } else { - $relationsArray[] = $relation; - } - } - - return $relationsArray; - } - /** * Find a tag by ID, optionally making sure it is visible to a certain * user, or throw an exception. diff --git a/extensions/tags/src/TagValidator.php b/extensions/tags/src/TagValidator.php deleted file mode 100644 index d4cdbb435..000000000 --- a/extensions/tags/src/TagValidator.php +++ /dev/null @@ -1,23 +0,0 @@ - ['required'], - 'slug' => ['required', 'unique:tags', 'regex:/^[^\/\\ ]*$/i'], - 'is_hidden' => ['bool'], - 'description' => ['string', 'max:700'], - 'color' => ['regex:/^#([a-f0-9]{6}|[a-f0-9]{3})$/i'], - ]; -} diff --git a/extensions/tags/tests/integration/RetrievesRepresentativeTags.php b/extensions/tags/tests/integration/RetrievesRepresentativeTags.php index 778ab040f..6c666064a 100644 --- a/extensions/tags/tests/integration/RetrievesRepresentativeTags.php +++ b/extensions/tags/tests/integration/RetrievesRepresentativeTags.php @@ -14,20 +14,20 @@ trait RetrievesRepresentativeTags protected function tags() { return [ - ['id' => 1, 'name' => 'Primary 1', 'slug' => 'primary-1', 'position' => 0, 'parent_id' => null], - ['id' => 2, 'name' => 'Primary 2', 'slug' => 'primary-2', 'position' => 1, 'parent_id' => null], - ['id' => 3, 'name' => 'Primary 2 Child 1', 'slug' => 'primary-2-child-1', 'position' => 2, 'parent_id' => 2], - ['id' => 4, 'name' => 'Primary 2 Child 2', 'slug' => 'primary-2-child-2', 'position' => 3, 'parent_id' => 2], - ['id' => 5, 'name' => 'Primary 2 Child Restricted', 'slug' => 'primary-2-child-restricted', 'position' => 4, 'parent_id' => 2, 'is_restricted' => true], - ['id' => 6, 'name' => 'Primary Restricted', 'slug' => 'primary-restricted', 'position' => 5, 'parent_id' => null, 'is_restricted' => true], - ['id' => 7, 'name' => 'Primary Restricted Child 1', 'slug' => 'primary-restricted-child-1', 'position' => 6, 'parent_id' => 6], - ['id' => 8, 'name' => 'Primary Restricted Child Restricted', 'slug' => 'primary-restricted-child-restricted', 'position' => 7, 'parent_id' => 6, 'is_restricted' => true], - ['id' => 9, 'name' => 'Secondary 1', 'slug' => 'secondary-1', 'position' => null, 'parent_id' => null], - ['id' => 10, 'name' => 'Secondary 2', 'slug' => 'secondary-2', 'position' => null, 'parent_id' => null], - ['id' => 11, 'name' => 'Secondary Restricted', 'slug' => 'secondary-restricted', 'position' => null, 'parent_id' => null, 'is_restricted' => true], - ['id' => 12, 'name' => 'Primary Restricted 2', 'slug' => 'primary-2-restricted', 'position' => 100, 'parent_id' => null, 'is_restricted' => true], - ['id' => 13, 'name' => 'Primary Restricted 2 Child 1', 'slug' => 'primary-2-restricted-child-1', 'position' => 101, 'parent_id' => 12], - ['id' => 14, 'name' => 'Primary Restricted 3', 'slug' => 'primary-3-restricted', 'position' => 102, 'parent_id' => null, 'is_restricted' => true], + ['id' => 1, 'name' => 'Primary 1', 'slug' => 'primary-1', 'is_primary' => true, 'position' => 0, 'parent_id' => null], + ['id' => 2, 'name' => 'Primary 2', 'slug' => 'primary-2', 'is_primary' => true, 'position' => 1, 'parent_id' => null], + ['id' => 3, 'name' => 'Primary 2 Child 1', 'slug' => 'primary-2-child-1', 'is_primary' => true, 'position' => 2, 'parent_id' => 2], + ['id' => 4, 'name' => 'Primary 2 Child 2', 'slug' => 'primary-2-child-2', 'is_primary' => true, 'position' => 3, 'parent_id' => 2], + ['id' => 5, 'name' => 'Primary 2 Child Restricted', 'slug' => 'primary-2-child-restricted', 'is_primary' => true, 'position' => 4, 'parent_id' => 2, 'is_restricted' => true], + ['id' => 6, 'name' => 'Primary Restricted', 'slug' => 'primary-restricted', 'is_primary' => true, 'position' => 5, 'parent_id' => null, 'is_restricted' => true], + ['id' => 7, 'name' => 'Primary Restricted Child 1', 'slug' => 'primary-restricted-child-1', 'is_primary' => true, 'position' => 6, 'parent_id' => 6], + ['id' => 8, 'name' => 'Primary Restricted Child Restricted', 'slug' => 'primary-restricted-child-restricted', 'is_primary' => true, 'position' => 7, 'parent_id' => 6, 'is_restricted' => true], + ['id' => 9, 'name' => 'Secondary 1', 'slug' => 'secondary-1', 'is_primary' => false, 'position' => null, 'parent_id' => null], + ['id' => 10, 'name' => 'Secondary 2', 'slug' => 'secondary-2', 'is_primary' => false, 'position' => null, 'parent_id' => null], + ['id' => 11, 'name' => 'Secondary Restricted', 'slug' => 'secondary-restricted', 'is_primary' => false, 'position' => null, 'parent_id' => null, 'is_restricted' => true], + ['id' => 12, 'name' => 'Primary Restricted 2', 'slug' => 'primary-2-restricted', 'is_primary' => true, 'position' => 100, 'parent_id' => null, 'is_restricted' => true], + ['id' => 13, 'name' => 'Primary Restricted 2 Child 1', 'slug' => 'primary-2-restricted-child-1', 'is_primary' => true, 'position' => 101, 'parent_id' => 12], + ['id' => 14, 'name' => 'Primary Restricted 3', 'slug' => 'primary-3-restricted', 'is_primary' => true, 'position' => 102, 'parent_id' => null, 'is_restricted' => true], ]; } } diff --git a/extensions/tags/tests/integration/api/discussions/CreateTest.php b/extensions/tags/tests/integration/api/discussions/CreateTest.php index d529a716f..5c5664137 100644 --- a/extensions/tags/tests/integration/api/discussions/CreateTest.php +++ b/extensions/tags/tests/integration/api/discussions/CreateTest.php @@ -87,6 +87,28 @@ class CreateTest extends TestCase ); $this->assertEquals(422, $response->getStatusCode()); + + $response = $this->send( + $this->request('POST', '/api/discussions', [ + 'authenticatedAs' => 2, + 'json' => [ + 'data' => [ + 'type' => 'discussions', + 'attributes' => [ + 'title' => 'test - too-obscure', + 'content' => 'predetermined content for automated testing - too-obscure', + ], + 'relationships' => [ + 'tags' => [ + 'data' => [] + ] + ] + ] + ], + ]) + ); + + $this->assertEquals(422, $response->getStatusCode()); } /** @@ -145,7 +167,7 @@ class CreateTest extends TestCase ]) ); - $this->assertEquals(201, $response->getStatusCode()); + $this->assertEquals(201, $response->getStatusCode(), (string) $response->getBody()); } /** diff --git a/extensions/tags/tests/integration/api/posts/ListTest.php b/extensions/tags/tests/integration/api/posts/ListTest.php index efd331bf2..4f8845bb0 100644 --- a/extensions/tags/tests/integration/api/posts/ListTest.php +++ b/extensions/tags/tests/integration/api/posts/ListTest.php @@ -81,9 +81,11 @@ class ListTest extends TestCase ]) ); - $this->assertEquals(200, $response->getStatusCode()); + $body = $response->getBody()->getContents(); - $data = json_decode($response->getBody()->getContents(), true); + $this->assertEquals(200, $response->getStatusCode(), $body); + + $data = json_decode($body, true); $tagIds = array_map(function ($tag) { return $tag['id']; @@ -91,7 +93,7 @@ class ListTest extends TestCase return $item['type'] === 'tags'; })); - $this->assertEqualsCanonicalizing([1, 5], $tagIds); + $this->assertEqualsCanonicalizing([1, 5], $tagIds, $body); } /** diff --git a/extensions/tags/tests/integration/api/tags/CreateTest.php b/extensions/tags/tests/integration/api/tags/CreateTest.php index 8c5fb7aec..dd7870a3a 100644 --- a/extensions/tags/tests/integration/api/tags/CreateTest.php +++ b/extensions/tags/tests/integration/api/tags/CreateTest.php @@ -58,11 +58,13 @@ class CreateTest extends TestCase $response = $this->send( $this->request('POST', '/api/tags', [ 'authenticatedAs' => 1, - 'json' => [], + 'json' => [ + 'data' => [] + ], ]) ); - $this->assertEquals(422, $response->getStatusCode()); + $this->assertEquals(422, $response->getStatusCode(), (string) $response->getBody()); } /** @@ -87,10 +89,10 @@ class CreateTest extends TestCase ]) ); - $this->assertEquals(201, $response->getStatusCode()); + $this->assertEquals(201, $response->getStatusCode(), $body = (string) $response->getBody()); // Verify API response body - $data = json_decode($response->getBody(), true); + $data = json_decode($body, true); $this->assertEquals('Dev Blog', Arr::get($data, 'data.attributes.name')); $this->assertEquals('dev-blog', Arr::get($data, 'data.attributes.slug')); $this->assertEquals('Follow Flarum development!', Arr::get($data, 'data.attributes.description')); diff --git a/extensions/tags/tests/integration/api/tags/ListTest.php b/extensions/tags/tests/integration/api/tags/ListTest.php index cbe4c1f8f..8f3497d24 100644 --- a/extensions/tags/tests/integration/api/tags/ListTest.php +++ b/extensions/tags/tests/integration/api/tags/ListTest.php @@ -101,13 +101,20 @@ class ListTest extends TestCase $responseBody = json_decode($response->getBody()->getContents(), true); $data = $responseBody['data']; - $included = $responseBody['included']; // 5 isnt included because parent access doesnt necessarily give child access // 6, 7, 8 aren't included because child access shouldnt work unless parent // access is also given. $this->assertEquals(['1', '2', '3', '4', '9', '10', '11'], Arr::pluck($data, 'id')); - $this->assertEquals($expectedIncludes, Arr::pluck($included, 'id')); + $this->assertEquals($expectedIncludes, collect($data) + ->pluck('relationships.' . $include . '.data') + ->filter(fn ($data) => ! empty($data)) + ->values() + ->flatMap(fn (array $data) => isset($data['type']) ? [$data] : $data) + ->pluck('id') + ->unique() + ->all() + ); } /**