mirror of
https://github.com/flarum/framework.git
synced 2024-12-04 00:03:37 +08:00
feat: refactor tags extension
This commit is contained in:
parent
cd958797f5
commit
82b9c54969
|
@ -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));
|
||||
});
|
||||
}),
|
||||
];
|
||||
|
|
|
@ -30,7 +30,7 @@ export default class EditTagModal extends FormModal<EditTagModalAttrs> {
|
|||
color!: Stream<string>;
|
||||
icon!: Stream<string>;
|
||||
isHidden!: Stream<boolean>;
|
||||
primary!: Stream<boolean>;
|
||||
isPrimary!: Stream<boolean>;
|
||||
|
||||
oninit(vnode: Mithril.Vnode<EditTagModalAttrs, this>) {
|
||||
super.oninit(vnode);
|
||||
|
@ -43,7 +43,7 @@ export default class EditTagModal extends FormModal<EditTagModalAttrs> {
|
|||
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<EditTagModalAttrs> {
|
|||
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<EditTagModalAttrs> {
|
|||
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();
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -44,6 +44,9 @@ export default class Tag extends Model {
|
|||
isHidden() {
|
||||
return Model.attribute<boolean>('isHidden').call(this);
|
||||
}
|
||||
isPrimary() {
|
||||
return Model.attribute<boolean>('isPrimary').call(this);
|
||||
}
|
||||
|
||||
discussionCount() {
|
||||
return Model.attribute<number>('discussionCount').call(this);
|
||||
|
@ -65,7 +68,7 @@ export default class Tag extends Model {
|
|||
return Model.attribute<boolean>('canAddToDiscussion').call(this);
|
||||
}
|
||||
|
||||
isPrimary() {
|
||||
isPrimaryParent() {
|
||||
return computed<boolean, this>('position', 'parent', (position, parent) => position !== null && parent === false).call(this);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Database\Schema\Builder;
|
||||
|
||||
return [
|
||||
'up' => 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');
|
||||
});
|
||||
}
|
||||
];
|
|
@ -1,39 +0,0 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Tags\Api\Controller;
|
||||
|
||||
use Flarum\Api\Controller\AbstractCreateController;
|
||||
use Flarum\Http\RequestUtil;
|
||||
use Flarum\Tags\Api\Serializer\TagSerializer;
|
||||
use Flarum\Tags\Command\CreateTag;
|
||||
use Flarum\Tags\Tag;
|
||||
use Illuminate\Contracts\Bus\Dispatcher;
|
||||
use Illuminate\Support\Arr;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Tobscure\JsonApi\Document;
|
||||
|
||||
class CreateTagController extends AbstractCreateController
|
||||
{
|
||||
public ?string $serializer = TagSerializer::class;
|
||||
|
||||
public array $include = ['parent'];
|
||||
|
||||
public function __construct(
|
||||
protected Dispatcher $bus
|
||||
) {
|
||||
}
|
||||
|
||||
protected function data(ServerRequestInterface $request, Document $document): Tag
|
||||
{
|
||||
return $this->bus->dispatch(
|
||||
new CreateTag(RequestUtil::getActor($request), Arr::get($request->getParsedBody(), 'data', []))
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,32 +0,0 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Tags\Api\Controller;
|
||||
|
||||
use Flarum\Api\Controller\AbstractDeleteController;
|
||||
use Flarum\Http\RequestUtil;
|
||||
use Flarum\Tags\Command\DeleteTag;
|
||||
use Illuminate\Contracts\Bus\Dispatcher;
|
||||
use Illuminate\Support\Arr;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
|
||||
class DeleteTagController extends AbstractDeleteController
|
||||
{
|
||||
public function __construct(
|
||||
protected Dispatcher $bus
|
||||
) {
|
||||
}
|
||||
|
||||
protected function delete(ServerRequestInterface $request): void
|
||||
{
|
||||
$this->bus->dispatch(
|
||||
new DeleteTag(Arr::get($request->getQueryParams(), 'id'), RequestUtil::getActor($request))
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,78 +0,0 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Tags\Api\Controller;
|
||||
|
||||
use Flarum\Api\Controller\AbstractListController;
|
||||
use Flarum\Http\RequestUtil;
|
||||
use Flarum\Http\UrlGenerator;
|
||||
use Flarum\Search\SearchCriteria;
|
||||
use Flarum\Search\SearchManager;
|
||||
use Flarum\Tags\Api\Serializer\TagSerializer;
|
||||
use Flarum\Tags\Tag;
|
||||
use Flarum\Tags\TagRepository;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Tobscure\JsonApi\Document;
|
||||
|
||||
class ListTagsController extends AbstractListController
|
||||
{
|
||||
public ?string $serializer = TagSerializer::class;
|
||||
|
||||
public array $include = [
|
||||
'parent'
|
||||
];
|
||||
|
||||
public array $optionalInclude = [
|
||||
'children',
|
||||
'lastPostedDiscussion',
|
||||
'state'
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
protected TagRepository $tags,
|
||||
protected SearchManager $search,
|
||||
protected UrlGenerator $url
|
||||
) {
|
||||
}
|
||||
|
||||
protected function data(ServerRequestInterface $request, Document $document): iterable
|
||||
{
|
||||
$actor = RequestUtil::getActor($request);
|
||||
$include = $this->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;
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,69 +0,0 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Tags\Api\Controller;
|
||||
|
||||
use Flarum\Api\Controller\AbstractShowController;
|
||||
use Flarum\Http\RequestUtil;
|
||||
use Flarum\Http\SlugManager;
|
||||
use Flarum\Tags\Api\Serializer\TagSerializer;
|
||||
use Flarum\Tags\Tag;
|
||||
use Flarum\Tags\TagRepository;
|
||||
use Illuminate\Support\Arr;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Tobscure\JsonApi\Document;
|
||||
|
||||
class ShowTagController extends AbstractShowController
|
||||
{
|
||||
public ?string $serializer = TagSerializer::class;
|
||||
|
||||
public array $optionalInclude = [
|
||||
'children',
|
||||
'children.parent',
|
||||
'lastPostedDiscussion',
|
||||
'parent',
|
||||
'parent.children',
|
||||
'parent.children.parent',
|
||||
'state'
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
protected TagRepository $tags,
|
||||
protected SlugManager $slugger
|
||||
) {
|
||||
}
|
||||
|
||||
protected function data(ServerRequestInterface $request, Document $document): Tag
|
||||
{
|
||||
$slug = Arr::get($request->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;
|
||||
}
|
||||
}
|
|
@ -1,41 +0,0 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Tags\Api\Controller;
|
||||
|
||||
use Flarum\Api\Controller\AbstractShowController;
|
||||
use Flarum\Http\RequestUtil;
|
||||
use Flarum\Tags\Api\Serializer\TagSerializer;
|
||||
use Flarum\Tags\Command\EditTag;
|
||||
use Flarum\Tags\Tag;
|
||||
use Illuminate\Contracts\Bus\Dispatcher;
|
||||
use Illuminate\Support\Arr;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Tobscure\JsonApi\Document;
|
||||
|
||||
class UpdateTagController extends AbstractShowController
|
||||
{
|
||||
public ?string $serializer = TagSerializer::class;
|
||||
|
||||
public function __construct(
|
||||
protected Dispatcher $bus
|
||||
) {
|
||||
}
|
||||
|
||||
protected function data(ServerRequestInterface $request, Document $document): Tag
|
||||
{
|
||||
$id = Arr::get($request->getQueryParams(), 'id');
|
||||
$actor = RequestUtil::getActor($request);
|
||||
$data = Arr::get($request->getParsedBody(), 'data', []);
|
||||
|
||||
return $this->bus->dispatch(
|
||||
new EditTag($id, $actor, $data)
|
||||
);
|
||||
}
|
||||
}
|
107
extensions/tags/src/Api/DiscussionResourceFields.php
Normal file
107
extensions/tags/src/Api/DiscussionResourceFields.php
Normal file
|
@ -0,0 +1,107 @@
|
|||
<?php
|
||||
|
||||
namespace Flarum\Tags\Api;
|
||||
|
||||
use Flarum\Api\Context;
|
||||
use Flarum\Api\Schema;
|
||||
use Flarum\Discussion\Discussion;
|
||||
use Flarum\Foundation\ValidationException;
|
||||
use Flarum\Locale\TranslatorInterface;
|
||||
use Flarum\Settings\SettingsRepositoryInterface;
|
||||
use Flarum\Tags\Event\DiscussionWasTagged;
|
||||
use Flarum\Tags\Tag;
|
||||
use Flarum\User\Exception\PermissionDeniedException;
|
||||
use Illuminate\Contracts\Validation\Factory;
|
||||
|
||||
class DiscussionResourceFields
|
||||
{
|
||||
public function __construct(
|
||||
protected SettingsRepositoryInterface $settings,
|
||||
protected Factory $validator,
|
||||
protected TranslatorInterface $translator
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(): array
|
||||
{
|
||||
return [
|
||||
Schema\Boolean::make('canTag')
|
||||
->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)]);
|
||||
}
|
||||
}
|
||||
}
|
146
extensions/tags/src/Api/Resource/TagResource.php
Normal file
146
extensions/tags/src/Api/Resource/TagResource.php
Normal file
|
@ -0,0 +1,146 @@
|
|||
<?php
|
||||
|
||||
namespace Flarum\Tags\Api\Resource;
|
||||
|
||||
use Flarum\Api\Endpoint;
|
||||
use Flarum\Api\Resource\AbstractDatabaseResource;
|
||||
use Flarum\Api\Schema;
|
||||
use Flarum\Http\SlugManager;
|
||||
use Flarum\Tags\Event\Creating;
|
||||
use Flarum\Tags\Event\Deleting;
|
||||
use Flarum\Tags\Event\Saving;
|
||||
use Flarum\Tags\Tag;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Arr;
|
||||
use Tobyz\JsonApiServer\Context;
|
||||
|
||||
class TagResource extends AbstractDatabaseResource
|
||||
{
|
||||
public function __construct(
|
||||
protected SlugManager $slugManager
|
||||
) {
|
||||
}
|
||||
|
||||
public function type(): string
|
||||
{
|
||||
return 'tags';
|
||||
}
|
||||
|
||||
public function model(): string
|
||||
{
|
||||
return Tag::class;
|
||||
}
|
||||
|
||||
public function scope(Builder $query, Context $context): void
|
||||
{
|
||||
$query->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()));
|
||||
}
|
||||
}
|
|
@ -1,75 +0,0 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Tags\Api\Serializer;
|
||||
|
||||
use Flarum\Api\Serializer\AbstractSerializer;
|
||||
use Flarum\Api\Serializer\DiscussionSerializer;
|
||||
use Flarum\Http\SlugManager;
|
||||
use Flarum\Tags\Tag;
|
||||
use InvalidArgumentException;
|
||||
use Tobscure\JsonApi\Relationship;
|
||||
|
||||
class TagSerializer extends AbstractSerializer
|
||||
{
|
||||
protected $type = 'tags';
|
||||
|
||||
public function __construct(
|
||||
protected SlugManager $slugManager
|
||||
) {
|
||||
}
|
||||
|
||||
protected function getDefaultAttributes(object|array $model): array
|
||||
{
|
||||
if (! ($model instanceof Tag)) {
|
||||
throw new InvalidArgumentException(
|
||||
$this::class.' can only serialize instances of '.Tag::class
|
||||
);
|
||||
}
|
||||
|
||||
$attributes = [
|
||||
'name' => $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);
|
||||
}
|
||||
}
|
|
@ -1,21 +0,0 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Tags\Command;
|
||||
|
||||
use Flarum\User\User;
|
||||
|
||||
class CreateTag
|
||||
{
|
||||
public function __construct(
|
||||
public User $actor,
|
||||
public array $data
|
||||
) {
|
||||
}
|
||||
}
|
|
@ -1,66 +0,0 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Tags\Command;
|
||||
|
||||
use Flarum\Tags\Event\Creating;
|
||||
use Flarum\Tags\Tag;
|
||||
use Flarum\Tags\TagValidator;
|
||||
use Illuminate\Contracts\Events\Dispatcher;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class CreateTagHandler
|
||||
{
|
||||
public function __construct(
|
||||
protected TagValidator $validator,
|
||||
protected Dispatcher $events
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(CreateTag $command): Tag
|
||||
{
|
||||
$actor = $command->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;
|
||||
}
|
||||
}
|
|
@ -1,22 +0,0 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Tags\Command;
|
||||
|
||||
use Flarum\User\User;
|
||||
|
||||
class DeleteTag
|
||||
{
|
||||
public function __construct(
|
||||
public int $tagId,
|
||||
public User $actor,
|
||||
public array $data = []
|
||||
) {
|
||||
}
|
||||
}
|
|
@ -1,39 +0,0 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Tags\Command;
|
||||
|
||||
use Flarum\Tags\Event\Deleting;
|
||||
use Flarum\Tags\Tag;
|
||||
use Flarum\Tags\TagRepository;
|
||||
use Illuminate\Contracts\Events\Dispatcher;
|
||||
|
||||
class DeleteTagHandler
|
||||
{
|
||||
public function __construct(
|
||||
protected TagRepository $tags,
|
||||
protected Dispatcher $events
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(DeleteTag $command): Tag
|
||||
{
|
||||
$actor = $command->actor;
|
||||
|
||||
$tag = $this->tags->findOrFail($command->tagId, $actor);
|
||||
|
||||
$actor->assertCan('delete', $tag);
|
||||
|
||||
$this->events->dispatch(new Deleting($tag, $actor));
|
||||
|
||||
$tag->delete();
|
||||
|
||||
return $tag;
|
||||
}
|
||||
}
|
|
@ -1,22 +0,0 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Tags\Command;
|
||||
|
||||
use Flarum\User\User;
|
||||
|
||||
class EditTag
|
||||
{
|
||||
public function __construct(
|
||||
public int $tagId,
|
||||
public User $actor,
|
||||
public array $data
|
||||
) {
|
||||
}
|
||||
}
|
|
@ -1,75 +0,0 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Tags\Command;
|
||||
|
||||
use Flarum\Tags\Event\Saving;
|
||||
use Flarum\Tags\Tag;
|
||||
use Flarum\Tags\TagRepository;
|
||||
use Flarum\Tags\TagValidator;
|
||||
use Illuminate\Contracts\Events\Dispatcher;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class EditTagHandler
|
||||
{
|
||||
public function __construct(
|
||||
protected TagRepository $tags,
|
||||
protected TagValidator $validator,
|
||||
protected Dispatcher $events
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(EditTag $command): Tag
|
||||
{
|
||||
$actor = $command->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;
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
|
|
|
@ -1,116 +0,0 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Tags\Listener;
|
||||
|
||||
use Flarum\Discussion\Event\Saving;
|
||||
use Flarum\Foundation\ValidationException;
|
||||
use Flarum\Locale\TranslatorInterface;
|
||||
use Flarum\Settings\SettingsRepositoryInterface;
|
||||
use Flarum\Tags\Event\DiscussionWasTagged;
|
||||
use Flarum\Tags\Tag;
|
||||
use Flarum\User\Exception\PermissionDeniedException;
|
||||
use Illuminate\Contracts\Validation\Factory;
|
||||
|
||||
class SaveTagsToDatabase
|
||||
{
|
||||
public function __construct(
|
||||
protected SettingsRepositoryInterface $settings,
|
||||
protected Factory $validator,
|
||||
protected TranslatorInterface $translator
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(Saving $event): void
|
||||
{
|
||||
$discussion = $event->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)]);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,43 +0,0 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Tags;
|
||||
|
||||
use Flarum\Api\Controller\ShowForumController;
|
||||
use Flarum\Http\RequestUtil;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
|
||||
class LoadForumTagsRelationship
|
||||
{
|
||||
public function __invoke(ShowForumController $controller, array &$data, ServerRequestInterface $request): void
|
||||
{
|
||||
$actor = RequestUtil::getActor($request);
|
||||
|
||||
// Expose the complete tag list to clients by adding it as a
|
||||
// relationship to the /api endpoint. Since the Forum model
|
||||
// doesn't actually have a tags relationship, we will manually load and
|
||||
// assign the tags data to it using an event listener.
|
||||
$data['tags'] = 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();
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
|
|
|
@ -15,8 +15,6 @@ use Illuminate\Database\Eloquent\Collection;
|
|||
|
||||
class TagRepository
|
||||
{
|
||||
private const TAG_RELATIONS = ['children', 'parent', 'parent.children'];
|
||||
|
||||
/**
|
||||
* @return Builder<Tag>
|
||||
*/
|
||||
|
@ -30,32 +28,6 @@ class TagRepository
|
|||
return $this->scopeVisibleTo($this->query(), $actor);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Builder<Tag>
|
||||
*/
|
||||
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.
|
||||
|
|
|
@ -1,23 +0,0 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Tags;
|
||||
|
||||
use Flarum\Foundation\AbstractValidator;
|
||||
|
||||
class TagValidator extends AbstractValidator
|
||||
{
|
||||
protected array $rules = [
|
||||
'name' => ['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'],
|
||||
];
|
||||
}
|
|
@ -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],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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'));
|
||||
|
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
Loading…
Reference in New Issue
Block a user