diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index 2435a082..6d12ae42 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -4,9 +4,9 @@ on: [workflow_dispatch, push, pull_request] jobs: run: - uses: flarum/framework/.github/workflows/REUSABLE_backend.yml@main + uses: flarum/framework/.github/workflows/REUSABLE_backend.yml@1.x with: - enable_backend_testing: false + enable_backend_testing: true enable_phpstan: true backend_directory: . diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml index 1b67573c..6c834f63 100644 --- a/.github/workflows/frontend.yml +++ b/.github/workflows/frontend.yml @@ -4,7 +4,7 @@ on: [workflow_dispatch, push, pull_request] jobs: run: - uses: flarum/framework/.github/workflows/REUSABLE_frontend.yml@main + uses: flarum/framework/.github/workflows/REUSABLE_frontend.yml@1.x with: enable_bundlewatch: false enable_prettier: true diff --git a/.gitignore b/.gitignore index d34e123e..528d5861 100755 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ node_modules vendor composer.lock js/dist +.phpunit.result.cache diff --git a/README.md b/README.md index 4ac2daa1..8b6c680e 100755 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ A [Flarum](http://flarum.org) extension. A Flarum extension that adds polls to y ### Installation ```sh -composer require fof/polls +composer require fof/polls:"*" ``` #### Migrating from ReFlar Polls @@ -48,5 +48,6 @@ You can only run the command when the extension is enabled in the admin panel. - [Packagist](https://packagist.org/packages/fof/polls) - [GitHub](https://github.com/packages/FriendsOfFlarum/polls) +- [Discuss](https://discuss.flarum.org/d/20586) An extension by [FriendsOfFlarum](https://github.com/FriendsOfFlarum). \ No newline at end of file diff --git a/composer.json b/composer.json index 88f48a2d..6578f369 100755 --- a/composer.json +++ b/composer.json @@ -52,18 +52,36 @@ }, "flarum-cli": { "modules": { - "githubActions": true + "githubActions": true, + "backendTesting": true } } }, "require-dev": { - "flarum/phpstan": "*" + "flarum/phpstan": "*", + "flarum/testing": "^1.0.0" }, "scripts": { "analyse:phpstan": "phpstan analyse", - "clear-cache:phpstan": "phpstan clear-result-cache" + "clear-cache:phpstan": "phpstan clear-result-cache", + "test": [ + "@test:unit", + "@test:integration" + ], + "test:unit": "phpunit -c tests/phpunit.unit.xml", + "test:integration": "phpunit -c tests/phpunit.integration.xml", + "test:setup": "@php tests/integration/setup.php" }, "scripts-descriptions": { - "analyse:phpstan": "Run static analysis" + "analyse:phpstan": "Run static analysis", + "test": "Runs all tests.", + "test:unit": "Runs all unit tests.", + "test:integration": "Runs all integration tests.", + "test:setup": "Sets up a database for use with integration tests. Execute this only once." + }, + "autoload-dev": { + "psr-4": { + "FoF\\Polls\\Tests\\": "tests/" + } } } diff --git a/extend.php b/extend.php index 7c8dfdac..c6da828b 100755 --- a/extend.php +++ b/extend.php @@ -25,18 +25,19 @@ return [ (new Extend\Frontend('forum')) - ->js(__DIR__.'/js/dist/forum.js') - ->css(__DIR__.'/resources/less/forum.less') - ->route('/polls', 'polls'), + ->js(__DIR__ . '/js/dist/forum.js') + ->css(__DIR__ . '/resources/less/forum.less') + ->route('/polls', 'fof_polls_directory', Content\PollsDirectory::class), (new Extend\Frontend('admin')) - ->js(__DIR__.'/js/dist/admin.js') - ->css(__DIR__.'/resources/less/admin.less'), + ->js(__DIR__ . '/js/dist/admin.js') + ->css(__DIR__ . '/resources/less/admin.less'), - new Extend\Locales(__DIR__.'/resources/locale'), + new Extend\Locales(__DIR__ . '/resources/locale'), (new Extend\Routes('api')) ->post('/fof/polls', 'fof.polls.create', Controllers\CreatePollController::class) + ->get('/fof/polls', 'fof.polls.index', Controllers\ListGlobalPollsController::class) ->get('/fof/polls/{id}', 'fof.polls.show', Controllers\ShowPollController::class) ->patch('/fof/polls/{id}', 'fof.polls.edit', Controllers\EditPollController::class) ->delete('/fof/polls/{id}', 'fof.polls.delete', Controllers\DeletePollController::class) @@ -50,34 +51,17 @@ (new Extend\Event()) ->listen(PostSaving::class, Listeners\SavePollsToDatabase::class) - ->listen(SettingsSaved::class, function (SettingsSaved $event) { - foreach ($event->settings as $key => $value) { - if ($key === 'fof-polls.optionsColorBlend') { - resolve('fof-user-bio.formatter')->flush(); - - return; - } - } - }), + ->listen(SettingsSaved::class, Listeners\ClearFormatterCache::class), (new Extend\ApiSerializer(DiscussionSerializer::class)) - ->attribute('hasPoll', function (DiscussionSerializer $serializer, Discussion $discussion): bool { - return $discussion->polls()->exists(); - }) - ->attribute('canStartPoll', function (DiscussionSerializer $serializer, Discussion $discussion): bool { - return $serializer->getActor()->can('polls.start', $discussion); - }), + ->attributes(Api\AddDiscussionAttributes::class), (new Extend\ApiSerializer(PostSerializer::class)) ->hasMany('polls', PollSerializer::class) - ->attribute('canStartPoll', function (PostSerializer $serializer, Post $post): bool { - return $serializer->getActor()->can('startPoll', $post); - }), + ->attributes(Api\AddPostAttributes::class), (new Extend\ApiSerializer(ForumSerializer::class)) - ->attribute('canStartPolls', function (ForumSerializer $serializer): bool { - return $serializer->getActor()->can('discussion.polls.start'); - }), + ->attributes(Api\AddForumAttributes::class), (new Extend\ApiController(Controller\ListDiscussionsController::class)) ->addOptionalInclude(['firstPost.polls']), @@ -112,6 +96,7 @@ (new Extend\Settings()) ->default('fof-polls.maxOptions', 10) ->default('fof-polls.optionsColorBlend', true) + ->default('fof-polls.directory-default-sort', 'default') ->serializeToForum('allowPollOptionImage', 'fof-polls.allowOptionImage', 'boolval') ->serializeToForum('pollMaxOptions', 'fof-polls.maxOptions', 'intval') ->registerLessConfigVar('fof-polls-options-color-blend', 'fof-polls.optionsColorBlend', function ($value) { @@ -120,4 +105,7 @@ (new Extend\ModelVisibility(Poll::class)) ->scope(Access\ScopePollVisibility::class), + + (new Extend\View()) + ->namespace('fof-polls', __DIR__ . '/resources/views'), ]; diff --git a/js/admin.js b/js/admin.ts similarity index 100% rename from js/admin.js rename to js/admin.ts diff --git a/js/forum.js b/js/forum.ts similarity index 100% rename from js/forum.js rename to js/forum.ts diff --git a/js/src/admin/index.ts b/js/src/admin/index.ts index e45965af..6e8f58f9 100755 --- a/js/src/admin/index.ts +++ b/js/src/admin/index.ts @@ -37,6 +37,14 @@ app.initializers.add('fof/polls', () => { }, 'start' ) + .registerPermission( + { + icon: 'fas fa-signal', + label: app.translator.trans('fof-polls.admin.permissions.start_global'), + permission: 'startGlobalPoll', + }, + 'start' + ) .registerPermission( { icon: 'fas fa-pencil-alt', diff --git a/js/src/forum/addNavItem.ts b/js/src/forum/addNavItem.ts new file mode 100644 index 00000000..c9bebe74 --- /dev/null +++ b/js/src/forum/addNavItem.ts @@ -0,0 +1,20 @@ +import app from 'flarum/forum/app'; +import { extend } from 'flarum/common/extend'; +import IndexPage from 'flarum/forum/components/IndexPage'; +import LinkButton from 'flarum/common/components/LinkButton'; + +export default function addNavItem() { + extend(IndexPage.prototype, 'navItems', (items) => { + items.add( + 'fof-polls-directory', + LinkButton.component( + { + href: app.route('fof_polls_directory'), + icon: 'fas fa-poll', + }, + app.translator.trans('fof-polls.forum.page.nav') + ), + 35 + ); + }); +} diff --git a/js/src/forum/components/PollsDirectory.tsx b/js/src/forum/components/PollsDirectory.tsx new file mode 100644 index 00000000..295fe982 --- /dev/null +++ b/js/src/forum/components/PollsDirectory.tsx @@ -0,0 +1,148 @@ +import app from 'flarum/forum/app'; +import Page from 'flarum/common/components/Page'; +import extractText from 'flarum/common/utils/extractText'; +import IndexPage from 'flarum/forum/components/IndexPage'; +import ItemList from 'flarum/common/utils/ItemList'; +import listItems from 'flarum/common/helpers/listItems'; +import SelectDropdown from 'flarum/common/components/SelectDropdown'; +import LinkButton from 'flarum/common/components/LinkButton'; +import Select from 'flarum/common/components/Select'; +import Button from 'flarum/common/components/Button'; +import Mithril from 'mithril'; + +export default class PollsDirectory extends Page { + oncreate(vnode: Mithril.Vnode) { + super.oncreate(vnode); + + app.setTitle(extractText(app.translator.trans('fof-polls.forum.page.nav'))); + } + + view() { + return ( +
+ {IndexPage.prototype.hero()} +
+
+ +
+
+
    {listItems(this.viewItems().toArray())}
+
    {listItems(this.actionItems().toArray())}
+
+ {/* */} +
+
+
+
+ ); + } + + /** + * Our own sidebar. Re-uses Index.sidebarItems as the base + * Elements added here will only show up on the user directory page + */ + sidebarItems(): ItemList { + const items = IndexPage.prototype.sidebarItems(); + + items.setContent( + 'nav', + SelectDropdown.component( + { + buttonClassName: 'Button', + className: 'App-titleControl', + }, + this.navItems().toArray() + ) + ); + + return items; + } + + /** + * Our own sidebar navigation. Re-uses Index.navItems as the base + * Elements added here will only show up on the user directory page + */ + navItems(): ItemList { + const items = IndexPage.prototype.navItems(); + const params = this.stickyParams(); + + items.setContent( + 'fof-polls-directory', + LinkButton.component( + { + href: app.route('fof_polls_directory', params), + icon: 'fas fa-poll', + }, + app.translator.trans('fof-polls.forum.page.nav') + ) + ); + + return items; + } + + stickyParams() { + return { + sort: m.route.param('sort'), + q: m.route.param('q'), + }; + } + + changeParams(sort: string) { + const params = this.params(); + + if (sort === app.forum.attribute('pollsDirectoryDefaultSort')) { + delete params.sort; + } else { + params.sort = sort; + } + + this.state.refreshParams(params); + + const routeParams = { ...params }; + delete routeParams.qBuilder; + + m.route.set(app.route('fof_polls_directory', routeParams)); + } + + viewItems() { + const items = new ItemList(); + const sortMap = this.state.sortMap(); + + const sortOptions = {}; + for (const i in sortMap) { + sortOptions[i] = app.translator.trans('fof-polls.lib.sort.' + i); + } + + items.add( + 'sort', + Select.component({ + options: sortOptions, + value: this.state.getParams().sort || app.forum.attribute('pollsDirectoryDefaultSort'), + onchange: this.changeParams.bind(this), + }), + 100 + ); + + return items; + } + + actionItems(): ItemList { + const items = new ItemList(); + + items.add( + 'refresh', + Button.component({ + title: app.translator.trans('fof-polls.forum.page.refresh_tooltip'), + icon: 'fas fa-sync', + className: 'Button Button--icon', + onclick: () => { + this.state.refresh(); + }, + }) + ); + + return items; + } +} diff --git a/js/src/forum/components/index.js b/js/src/forum/components/index.ts similarity index 100% rename from js/src/forum/components/index.js rename to js/src/forum/components/index.ts diff --git a/js/src/forum/extend.js b/js/src/forum/extend.js deleted file mode 100644 index b9d1ffc2..00000000 --- a/js/src/forum/extend.js +++ /dev/null @@ -1,21 +0,0 @@ -import Extend from 'flarum/common/extenders'; -import Post from 'flarum/common/models/Post'; -import Forum from 'flarum/common/models/Forum'; -import Discussion from 'flarum/common/models/Discussion'; -import Poll from './models/Poll'; -import PollsPage from './components/PollsPage'; -import PollOption from './models/PollOption'; -import PollVote from './models/PollVote'; - -export default [ - new Extend.Store().add('polls', Poll).add('poll_options', PollOption).add('poll_votes', PollVote), - - new Extend.Model(Post).hasMany('polls').attribute('canStartPoll'), - - new Extend.Model(Forum).attribute('canStartPolls'), - - new Extend.Model(Discussion).attribute('hasPoll').attribute('canStartPoll'), - - // new Extend.Routes().add('polls', '/polls', ), - // new Extend.Routes().add('polls', '/polls'), -]; diff --git a/js/src/forum/extend.ts b/js/src/forum/extend.ts new file mode 100644 index 00000000..2f99a386 --- /dev/null +++ b/js/src/forum/extend.ts @@ -0,0 +1,29 @@ +import Extend from 'flarum/common/extenders'; +import Post from 'flarum/common/models/Post'; +import Forum from 'flarum/common/models/Forum'; +import Discussion from 'flarum/common/models/Discussion'; +import Poll from './models/Poll'; +import PollOption from './models/PollOption'; +import PollVote from './models/PollVote'; +import PollsDirectory from './components/PollsDirectory'; + +export default [ + new Extend.Routes() // + .add('fof_polls_directory', '/polls', PollsDirectory), + + new Extend.Store() // + .add('polls', Poll) + .add('poll_options', PollOption) + .add('poll_votes', PollVote), + + new Extend.Model(Post) // + .hasMany('polls') + .attribute('canStartPoll'), + + new Extend.Model(Forum) // + .attribute('canStartPolls'), + + new Extend.Model(Discussion) // + .attribute('hasPoll') + .attribute('canStartPoll'), +]; diff --git a/js/src/forum/index.js b/js/src/forum/index.ts similarity index 87% rename from js/src/forum/index.js rename to js/src/forum/index.ts index 8b0e9f56..e18f1d38 100755 --- a/js/src/forum/index.js +++ b/js/src/forum/index.ts @@ -5,6 +5,7 @@ import addComposerItems from './addComposerItems'; import addPollsLink from './addPollsLink'; import addPollsToPost from './addPollsToPost'; import addPostControls from './addPostControls'; +import addNavItem from './addNavItem'; export * from './components'; export * from './models'; @@ -17,12 +18,7 @@ app.initializers.add('fof/polls', () => { addPollsLink(); addPollsToPost(); addPostControls(); - - // TMP - app.routes.polls = { - path: '/polls', - component: PollsPage, - }; + addNavItem(); }); export { default as extend } from './extend'; diff --git a/js/src/forum/models/Poll.js b/js/src/forum/models/Poll.js deleted file mode 100755 index 0f4da4c5..00000000 --- a/js/src/forum/models/Poll.js +++ /dev/null @@ -1,29 +0,0 @@ -import Model from 'flarum/common/Model'; - -export default class Poll extends Model { - question = Model.attribute('question'); - hasEnded = Model.attribute('hasEnded'); - endDate = Model.attribute('endDate'); - - publicPoll = Model.attribute('publicPoll'); - hideVotes = Model.attribute('hideVotes'); - allowChangeVote = Model.attribute('allowChangeVote'); - allowMultipleVotes = Model.attribute('allowMultipleVotes'); - maxVotes = Model.attribute('maxVotes'); - - voteCount = Model.attribute('voteCount'); - - canVote = Model.attribute('canVote'); - canEdit = Model.attribute('canEdit'); - canDelete = Model.attribute('canDelete'); - canSeeVoters = Model.attribute('canSeeVoters'); - canChangeVote = Model.attribute('canChangeVote'); - - options = Model.hasMany('options'); - votes = Model.hasMany('votes'); - myVotes = Model.hasMany('myVotes'); - - apiEndpoint() { - return `/fof/polls${this.exists ? `/${this.data.id}` : ''}`; - } -} diff --git a/js/src/forum/models/Poll.ts b/js/src/forum/models/Poll.ts new file mode 100755 index 00000000..583c4d64 --- /dev/null +++ b/js/src/forum/models/Poll.ts @@ -0,0 +1,82 @@ +import Model from 'flarum/common/Model'; +import PollOption from './PollOption'; +import PollVote from './PollVote'; + +export default class Poll extends Model { + question() { + return Model.attribute('question').call(this); + } + + hasEnded() { + return Model.attribute('hasEnded').call(this); + } + + endDate() { + return Model.attribute('endDate', Model.transformDate).call(this); + } + + publicPoll() { + return Model.attribute('publicPoll').call(this); + } + + hideVotes() { + return Model.attribute('hideVotes').call(this); + } + + allowChangeVote() { + return Model.attribute('allowChangeVote').call(this); + } + + allowMultipleVotes() { + return Model.attribute('allowMultipleVotes').call(this); + } + + maxVotes() { + return Model.attribute('maxVotes').call(this); + } + + voteCount() { + return Model.attribute('voteCount').call(this); + } + + canVote() { + return Model.attribute('canVote').call(this); + } + + canEdit() { + return Model.attribute('canEdit').call(this); + } + + canDelete() { + return Model.attribute('canDelete').call(this); + } + + canSeeVoters() { + return Model.attribute('canSeeVoters').call(this); + } + + canChangeVote() { + return Model.attribute('canChangeVote').call(this); + } + + options() { + return Model.hasMany('options').call(this); + } + + votes() { + return Model.hasMany('votes').call(this); + } + + myVotes() { + return Model.hasMany('myVotes').call(this); + } + + isGlobal() { + return Model.attribute('isGlobal').call(this); + } + + apiEndpoint() { + /** @ts-ignore */ + return `/fof/polls${this.exists ? `/${this.data.id}` : ''}`; + } +} diff --git a/js/src/forum/models/PollOption.js b/js/src/forum/models/PollOption.js deleted file mode 100755 index c0f60ad6..00000000 --- a/js/src/forum/models/PollOption.js +++ /dev/null @@ -1,14 +0,0 @@ -import Model from 'flarum/common/Model'; - -export default class PollOption extends Model { - answer = Model.attribute('answer'); - imageUrl = Model.attribute('imageUrl'); - voteCount = Model.attribute('voteCount'); - - poll = Model.hasOne('polls'); - votes = Model.hasMany('votes'); - - apiEndpoint() { - return `/fof/polls/answers${this.exists ? `/${this.data.id}` : ''}`; - } -} diff --git a/js/src/forum/models/PollOption.ts b/js/src/forum/models/PollOption.ts new file mode 100755 index 00000000..d83bf08e --- /dev/null +++ b/js/src/forum/models/PollOption.ts @@ -0,0 +1,30 @@ +import Model from 'flarum/common/Model'; +import Poll from './Poll'; +import PollVote from './PollVote'; + +export default class PollOption extends Model { + answer() { + return Model.attribute('answer').call(this); + } + + imageUrl() { + return Model.attribute('imageUrl').call(this); + } + + voteCount() { + return Model.attribute('voteCount').call(this); + } + + poll() { + return Model.hasOne('polls').call(this); + } + + votes() { + return Model.hasMany('votes').call(this); + } + + apiEndpoint() { + /** @ts-ignore */ + return `/fof/polls/answers${this.exists ? `/${this.data.id}` : ''}`; + } +} diff --git a/js/src/forum/models/PollVote.js b/js/src/forum/models/PollVote.js deleted file mode 100755 index ac8e4a58..00000000 --- a/js/src/forum/models/PollVote.js +++ /dev/null @@ -1,14 +0,0 @@ -import Model from 'flarum/common/Model'; - -export default class PollVote extends Model { - poll = Model.hasOne('poll'); - option = Model.hasOne('option'); - user = Model.hasOne('user'); - - pollId = Model.attribute('pollId'); - optionId = Model.attribute('optionId'); - - apiEndpoint() { - return `/fof/polls/${this.pollId()}/vote`; - } -} diff --git a/js/src/forum/models/PollVote.ts b/js/src/forum/models/PollVote.ts new file mode 100755 index 00000000..1eaa1ee1 --- /dev/null +++ b/js/src/forum/models/PollVote.ts @@ -0,0 +1,30 @@ +import Model from 'flarum/common/Model'; +import Poll from './Poll'; +import PollOption from './PollOption'; +import User from 'flarum/common/models/User'; + +export default class PollVote extends Model { + poll() { + return Model.hasOne('poll').call(this); + } + + option() { + return Model.hasOne('option').call(this); + } + + user() { + return Model.hasOne('user').call(this); + } + + pollId() { + return Model.attribute('pollId').call(this); + } + + optionId() { + return Model.attribute('optionId').call(this); + } + + apiEndpoint() { + return `/fof/polls/${this.pollId()}/vote`; + } +} diff --git a/js/src/forum/models/index.js b/js/src/forum/models/index.ts similarity index 100% rename from js/src/forum/models/index.js rename to js/src/forum/models/index.ts diff --git a/js/src/forum/states/PollDirectoryState.ts b/js/src/forum/states/PollDirectoryState.ts new file mode 100644 index 00000000..28446910 --- /dev/null +++ b/js/src/forum/states/PollDirectoryState.ts @@ -0,0 +1,135 @@ +import app from 'flarum/forum/app'; + +/** + * Based on Flarum's DiscussionListState + */ +import SortMap from '../../common/utils/SortMap'; + +export default class UserDirectoryState { + constructor(params = {}, app = window.app) { + this.params = params; + + this.app = app; + + this.users = []; + + this.moreResults = false; + + this.loading = false; + + this.qBuilder = {}; + } + + requestParams() { + const params = { include: [], filter: {} }; + + const sortKey = this.params.sort || app.forum.attribute('userDirectoryDefaultSort'); + + // sort might be set to null if no sort params has been passed + params.sort = this.sortMap()[sortKey]; + + if (this.params.q) { + params.filter.q = this.params.q; + } + + return params; + } + + sortMap() { + return { + default: '', + ...new SortMap().sortMap(), + }; + } + + getParams() { + return this.params; + } + + clear() { + this.users = []; + m.redraw(); + } + + refreshParams(newParams) { + if (!this.hasUsers() || Object.keys(newParams).some((key) => this.getParams()[key] !== newParams[key])) { + const q = ''; + this.params = newParams; + + if (newParams.qBuilder) { + Object.assign(this.qBuilder, newParams.qBuilder || {}); + this.params.q = Object.values(this.qBuilder).join(' ').trim(); + } + + if (!this.params.q && q) { + this.params.q = q; + } + + this.refresh(); + } + } + + refresh() { + this.loading = true; + + this.clear(); + + return this.loadResults().then( + (results) => { + this.users = []; + this.parseResults(results); + }, + () => { + this.loading = false; + m.redraw(); + } + ); + } + + loadResults(offset) { + const preloadedUsers = this.app.preloadedApiDocument(); + + if (preloadedUsers) { + return Promise.resolve(preloadedUsers); + } + + const params = this.requestParams(); + params.page = { offset }; + params.include = params.include.join(','); + + return this.app.store.find('users', params); + } + + loadMore() { + this.loading = true; + + this.loadResults(this.users.length).then(this.parseResults.bind(this)); + } + + parseResults(results) { + this.users.push(...results); + + this.loading = false; + this.moreResults = !!results.payload.links && !!results.payload.links.next; + + m.redraw(); + + return results; + } + + hasUsers() { + return this.users.length > 0; + } + + isLoading() { + return this.loading; + } + + isSearchResults() { + return !!this.params.q; + } + + empty() { + return !this.hasUsers() && !this.isLoading(); + } +} diff --git a/migrations/2024_01_31_update_polls_set_post_id_nullable.php b/migrations/2024_01_31_update_polls_set_post_id_nullable.php new file mode 100644 index 00000000..8d2410bc --- /dev/null +++ b/migrations/2024_01_31_update_polls_set_post_id_nullable.php @@ -0,0 +1,26 @@ + function (Builder $schema) { + $schema->table('polls', function (Blueprint $table) { + $table->integer('post_id')->unsigned()->nullable()->change(); + }); + }, + 'down' => function (Builder $schema) { + $schema->table('polls', function (Blueprint $table) { + $table->integer('post_id')->unsigned()->nullable(false)->change(); + }); + }, +]; diff --git a/resources/locale/en.yml b/resources/locale/en.yml index 45bdb3cf..8d1181ff 100755 --- a/resources/locale/en.yml +++ b/resources/locale/en.yml @@ -8,6 +8,7 @@ fof-polls: permissions: view_results_without_voting: View results without voting start: Start a poll + start_global: Start a global poll self_edit: Edit created polls (requires post edit permission) self_post_edit: Edit *all* polls on own posts (requires post edit permission) vote: Vote on polls @@ -57,6 +58,9 @@ fof-polls: delete_confirm: Are you sure you want to delete this poll? edit: Edit Poll + page: + nav: Polls + tooltip: badge: Poll votes: "{count, plural, one {# vote} other {# votes}}" diff --git a/resources/views/directory/index.blade.php b/resources/views/directory/index.blade.php new file mode 100644 index 00000000..f40a0dfe --- /dev/null +++ b/resources/views/directory/index.blade.php @@ -0,0 +1,16 @@ + +
+

{{ $translator->trans('fof-polls.forum.page.nav') }}

+ +
    + @foreach ($apiDocument->data as $user) +
  • + {{ $user->attributes->username }} +
  • + @endforeach +
+ + {{ $translator->trans('core.views.index.next_page_button') }} » +
diff --git a/src/Access/PollPolicy.php b/src/Access/PollPolicy.php index 942f416a..b58e9dfc 100644 --- a/src/Access/PollPolicy.php +++ b/src/Access/PollPolicy.php @@ -23,7 +23,7 @@ public function seeVoteCount(User $actor, Poll $poll) return $this->deny(); } - if ($poll->myVotes($actor)->count() || $actor->can('polls.viewResultsWithoutVoting', $poll->post->discussion)) { + if ($poll->myVotes($actor)->count() || $actor->can('polls.viewResultsWithoutVoting', $poll->post !== null ? $poll->post->discussion : null)) { return $this->allow(); } } @@ -48,7 +48,7 @@ public function view(User $actor, Poll $poll) public function vote(User $actor, Poll $poll) { - if ($actor->can('polls.vote', $poll->post->discussion) && !$poll->hasEnded()) { + if ($actor->can('polls.vote', $poll->post !== null ? $poll->post->discussion : null) && !$poll->hasEnded()) { return $this->allow(); } } @@ -62,7 +62,7 @@ public function changeVote(User $actor, Poll $poll) public function edit(User $actor, Poll $poll) { - if ($actor->can('polls.moderate', $poll->post->discussion)) { + if ($actor->can('polls.moderate', $poll->post !== null ? $poll->post->discussion : null)) { return $this->allow(); } diff --git a/src/Access/ScopePollVisibility.php b/src/Access/ScopePollVisibility.php index ea9f2442..a73303f8 100644 --- a/src/Access/ScopePollVisibility.php +++ b/src/Access/ScopePollVisibility.php @@ -22,7 +22,8 @@ public function __invoke(User $actor, Builder $query) $query->whereExists(function ($query) use ($actor) { $query->selectRaw('1') ->from('posts') - ->whereColumn('posts.id', 'polls.post_id'); + ->whereColumn('posts.id', 'polls.post_id') + ->orWhere('polls.post_id', null); Post::query()->setQuery($query)->whereVisibleTo($actor); }); } diff --git a/src/Api/AddDiscussionAttributes.php b/src/Api/AddDiscussionAttributes.php new file mode 100644 index 00000000..543b11bb --- /dev/null +++ b/src/Api/AddDiscussionAttributes.php @@ -0,0 +1,26 @@ +polls()->exists(); + $attributes['canStartPoll'] = $serializer->getActor()->can('polls.start', $discussion); + + return $attributes; + } +} diff --git a/src/Api/AddForumAttributes.php b/src/Api/AddForumAttributes.php new file mode 100644 index 00000000..3160b127 --- /dev/null +++ b/src/Api/AddForumAttributes.php @@ -0,0 +1,24 @@ +getActor()->can('discussion.polls.start'); + + return $attributes; + } +} diff --git a/src/Api/AddPostAttributes.php b/src/Api/AddPostAttributes.php new file mode 100644 index 00000000..bb206dfd --- /dev/null +++ b/src/Api/AddPostAttributes.php @@ -0,0 +1,22 @@ +getActor()->can('startPoll', $post); + + return $attributes; + } +} diff --git a/src/Api/Controllers/CreatePollController.php b/src/Api/Controllers/CreatePollController.php index d313b435..6f2aab7e 100644 --- a/src/Api/Controllers/CreatePollController.php +++ b/src/Api/Controllers/CreatePollController.php @@ -46,11 +46,18 @@ public function __construct(PostRepository $posts, Dispatcher $bus) protected function data(ServerRequestInterface $request, Document $document) { $postId = Arr::get($request->getParsedBody(), 'data.relationships.post.data.id'); + $actor = RequestUtil::getActor($request); + + $post = null; + + if ($postId !== null) { + $post = $this->posts->findOrFail($postId, $actor); + } return $this->bus->dispatch( new CreatePoll( - RequestUtil::getActor($request), - $this->posts->findOrFail($postId), + $actor, + $post, Arr::get($request->getParsedBody(), 'data.attributes') ) ); diff --git a/src/Api/Controllers/ListGlobalPollsController.php b/src/Api/Controllers/ListGlobalPollsController.php new file mode 100644 index 00000000..c68d983a --- /dev/null +++ b/src/Api/Controllers/ListGlobalPollsController.php @@ -0,0 +1,85 @@ +polls = $polls; + $this->url = $url; + } + + public function data(ServerRequestInterface $request, Document $document): Collection + { + $actor = RequestUtil::getActor($request); + + // Not yet needed, but here if/when we do. + // $filters = $this->extractFilter($request); + $sort = $this->extractSort($request); + $sortIsDefault = $this->sortIsDefault($request); + + $limit = $this->extractLimit($request); + $offset = $this->extractOffset($request); + $include = $this->extractInclude($request); + + $results = $this->polls->queryVisibleTo($actor) + ->select('polls.*') + ->whereNull('post_id') + ->orderBy($sortIsDefault ? 'id' : $sort, 'desc') + ->skip($offset) + ->take($limit); + + $totalItems = $results->count(); + $results = $results->get(); + + $document->addPaginationLinks( + $this->url->to('api')->route('fof.polls.index'), + $request->getQueryParams(), + $offset, + $limit, + $totalItems - ($offset + $limit) > 0 ? null : 0 + ); + + $this->loadRelations($results, $include, $request); + + return $results; + } +} diff --git a/src/Api/Serializers/PollSerializer.php b/src/Api/Serializers/PollSerializer.php index 9e99b72e..52a7bcdd 100755 --- a/src/Api/Serializers/PollSerializer.php +++ b/src/Api/Serializers/PollSerializer.php @@ -45,6 +45,7 @@ protected function getDefaultAttributes($poll) 'canDelete' => $this->actor->can('delete', $poll), 'canSeeVoters' => $this->actor->can('seeVoters', $poll), 'canChangeVote' => $this->actor->can('changeVote', $poll), + 'isGlobal' => $poll->isGlobal(), ]; if ($this->actor->can('seeVoteCount', $poll)) { diff --git a/src/Commands/CreatePoll.php b/src/Commands/CreatePoll.php index b17d28c8..822e2e44 100644 --- a/src/Commands/CreatePoll.php +++ b/src/Commands/CreatePoll.php @@ -22,7 +22,7 @@ class CreatePoll public $actor; /** - * @var Post + * @var ?Post */ public $post; @@ -42,7 +42,7 @@ class CreatePoll * @param array $data * @param callable|null $savePollOn */ - public function __construct(User $actor, Post $post, array $data, callable $savePollOn = null) + public function __construct(User $actor, ?Post $post, array $data, callable $savePollOn = null) { $this->actor = $actor; $this->post = $post; diff --git a/src/Commands/CreatePollHandler.php b/src/Commands/CreatePollHandler.php index 56c15f67..3d3ecf8e 100644 --- a/src/Commands/CreatePollHandler.php +++ b/src/Commands/CreatePollHandler.php @@ -61,7 +61,11 @@ public function __construct(PostRepository $posts, PollValidator $validator, Pol public function handle(CreatePoll $command) { - $command->actor->assertCan('startPoll', $command->post); + if ($command->post) { + $command->actor->assertCan('startPoll', $command->post); + } else { + $command->actor->assertCan('startGlobalPoll'); + } $attributes = $command->data; @@ -98,7 +102,7 @@ public function handle(CreatePoll $command) $poll = Poll::build( Arr::get($attributes, 'question'), - $command->post->id, + $command->post ? $command->post->id : null, $command->actor->id, $carbonDate != null ? $carbonDate->utc() : null, Arr::get($attributes, 'publicPoll'), diff --git a/src/Content/PollsDirectory.php b/src/Content/PollsDirectory.php new file mode 100644 index 00000000..d7633233 --- /dev/null +++ b/src/Content/PollsDirectory.php @@ -0,0 +1,92 @@ + 'username', + 'username_za' => '-username', + 'newest' => '-joinedAt', + 'oldest' => 'joinedAt', + 'most_discussions' => '-discussionCount', + 'least_discussions' => 'discussionCount', + ]; + + public function __construct(Client $api, Factory $view, SettingsRepositoryInterface $settings) + { + $this->api = $api; + $this->view = $view; + $this->settings = $settings; + } + + private function getDocument(User $actor, array $params, ServerRequestInterface $request) + { + $actor->assertCan('seePollsList'); + + return json_decode($this->api->withQueryParams($params)->withParentRequest($request)->get('/fof/polls')->getBody()); + } + + public function __invoke(Document $document, ServerRequestInterface $request): Document + { + $queryParams = $request->getQueryParams(); + $actor = RequestUtil::getActor($request); + + $sort = Arr::pull($queryParams, 'sort') ?: $this->settings->get('fof-polls.directory-default-sort'); + $q = Arr::pull($queryParams, 'q'); + $page = Arr::pull($queryParams, 'page', 1); + + $params = [ + // ?? used to prevent null values. null would result in the whole sortMap array being sent in the params + 'sort' => Arr::get($this->sortMap, $sort ?? '', ''), + 'filter' => compact('q'), + 'page' => ['offset' => ($page - 1) * 20, 'limit' => 20], + ]; + + $apiDocument = $this->getDocument($actor, $params, $request); + + $document->content = $this->view->make('fof-polls::directory.index', compact('page', 'apiDocument')); + + $document->payload['apiDocument'] = $apiDocument; + + return $document; + } +} diff --git a/src/Listeners/ClearFormatterCache.php b/src/Listeners/ClearFormatterCache.php new file mode 100644 index 00000000..e5af563c --- /dev/null +++ b/src/Listeners/ClearFormatterCache.php @@ -0,0 +1,28 @@ +settings as $key => $value) { + if ($key === 'fof-polls.optionsColorBlend') { + resolve('fof-user-bio.formatter')->flush(); + + return; + } + } + } +} diff --git a/src/Poll.php b/src/Poll.php index abd9ef39..c448ac82 100755 --- a/src/Poll.php +++ b/src/Poll.php @@ -85,6 +85,11 @@ public static function build($question, $postId, $actorId, $endDate, $publicPoll return $poll; } + public function isGlobal(): bool + { + return $this->post_id === null; + } + /** * @return bool */ diff --git a/src/PollRepository.php b/src/PollRepository.php index 2118e506..cc585d80 100644 --- a/src/PollRepository.php +++ b/src/PollRepository.php @@ -47,4 +47,9 @@ public function findOrFail($id, User $actor = null): Poll { return $this->queryVisibleTo($actor)->findOrFail($id); } + + public function find($id, User $actor = null): ?Poll + { + return $this->queryVisibleTo($actor)->find($id); + } } diff --git a/tests/fixtures/.gitkeep b/tests/fixtures/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/tests/integration/api/CreatePollTest.php b/tests/integration/api/CreatePollTest.php new file mode 100644 index 00000000..edcd1e6a --- /dev/null +++ b/tests/integration/api/CreatePollTest.php @@ -0,0 +1,479 @@ +extension('fof-polls'); + + $this->prepareDatabase([ + 'users' => [ + $this->normalUser(), + ['id' => 3, 'username' => 'polluser', 'email' => 'polluser@machine.local', 'password' => 'too-obscure', 'is_email_confirmed' => true], + ], + 'discussions' => [ + ['id' => 1, 'title' => 'Discussion 1', 'comment_count' => 1, 'participant_count' => 1, 'created_at' => '2021-01-01 00:00:00'], + ], + 'posts' => [ + ['id' => 1, 'user_id' => 1, 'discussion_id' => 1, 'number' => 1, 'created_at' => '2021-01-01 00:00:00', 'content' => 'Post 1', 'type' => 'comment'], + ], + 'group_user' => [ + ['user_id' => 3, 'group_id' => 4], + ], + 'group_permission' => [ + ['permission' => 'discussion.polls.start', 'group_id' => 4], + ['permission' => 'startGlobalPoll', 'group_id' => 4], + ], + ]); + } + + public function authorizedUserProvider(): array + { + return [ + [1], + [3], + ]; + } + + public function unauthorizedUserProvider(): array + { + return [ + [2], + ]; + } + + /** + * @dataProvider authorizedUserProvider + * + * @test + */ + public function authorized_user_can_create_poll_in_post(int $userId) + { + $response = $this->send( + $this->request( + 'POST', + '/api/posts', + [ + 'authenticatedAs' => $userId, + 'json' => [ + 'data' => [ + 'attributes' => [ + 'content' => 'Here is my poll', + 'poll' => [ + 'question' => 'What is your favourite colour?', + 'publicPoll' => false, + 'hideVotes' => false, + 'allowChangeVote' => true, + 'allowMultipleVotes' => false, + 'maxVotes' => 0, + 'endDate' => false, + 'options' => [ + [ + 'answer' => 'Red', + ], + [ + 'answer' => 'Blue', + ], + [ + 'answer' => 'Yellow', + ], + ], + ], + ], + 'relationships' => [ + 'discussion' => [ + 'data' => [ + 'type' => 'discussions', + 'id' => 1, + ], + ], + ], + ], + ], + ] + ) + ); + + $this->assertEquals(201, $response->getStatusCode()); + + $json = json_decode($response->getBody()->getContents(), true); + $data = $json['data']; + + $this->assertArrayHasKey('polls', $data['relationships']); + + $pollId = $data['relationships']['polls']['data'][0]['id']; + $this->assertNotNull($pollId); + + $poll = Poll::find($pollId); + + $this->assertNotNull($poll); + + $this->assertEquals('What is your favourite colour?', $poll->question); + + $response = $this->send( + $this->request( + 'GET', + '/api/fof/polls/'.$pollId, + [ + 'authenticatedAs' => $userId, + ] + ) + ); + + $this->assertEquals(200, $response->getStatusCode()); + + $json = json_decode($response->getBody()->getContents(), true); + + $this->assertFalse($json['data']['attributes']['isGlobal']); + } + + /** + * @dataProvider unauthorizedUserProvider + * + * @test + */ + public function unauthorized_user_cannot_create_poll_in_post(int $userId) + { + $response = $this->send( + $this->request( + 'POST', + '/api/posts', + [ + 'authenticatedAs' => $userId, + 'json' => [ + 'data' => [ + 'attributes' => [ + 'content' => 'Here is my poll', + 'poll' => [ + 'question' => 'What is your favourite colour?', + 'publicPoll' => false, + 'hideVotes' => false, + 'allowChangeVote' => true, + 'allowMultipleVotes' => false, + 'maxVotes' => 0, + 'endDate' => false, + 'options' => [ + [ + 'answer' => 'Red', + ], + [ + 'answer' => 'Blue', + ], + [ + 'answer' => 'Yellow', + ], + ], + ], + ], + 'relationships' => [ + 'discussion' => [ + 'data' => [ + 'type' => 'discussions', + 'id' => 1, + ], + ], + ], + ], + ], + ] + ) + ); + + $this->assertEquals(422, $response->getStatusCode()); + $errors = json_decode($response->getBody()->getContents(), true)['errors']; + + $this->assertEquals('validation_error', $errors[0]['code']); + $this->assertEquals('/data/attributes/poll', $errors[0]['source']['pointer']); + } + + /** + * @dataProvider authorizedUserProvider + * + * @test + */ + public function authorized_user_can_create_post_poll_on_api(int $userId) + { + $response = $this->send( + $this->request( + 'POST', + '/api/fof/polls', + [ + 'authenticatedAs' => $userId, + 'json' => [ + 'data' => [ + 'attributes' => [ + 'question' => 'Add a poll to an existing post', + 'publicPoll' => false, + 'hideVotes' => false, + 'allowChangeVote' => true, + 'allowMultipleVotes' => false, + 'maxVotes' => 0, + 'endDate' => false, + 'options' => [ + [ + 'answer' => 'Yes', + ], + [ + 'answer' => 'No', + ], + ], + ], + 'relationships' => [ + 'post' => [ + 'data' => [ + 'type' => 'posts', + 'id' => 1, + ], + ], + ], + ], + ], + ] + ) + ); + + $this->assertEquals(201, $response->getStatusCode()); + + $json = json_decode($response->getBody()->getContents(), true); + $data = $json['data']; + $attributes = $data['attributes']; + + $this->assertEquals('Add a poll to an existing post', $attributes['question']); + + $pollId = $data['id']; + $this->assertNotNull($pollId); + + $poll = Poll::find($pollId); + $this->assertNotNull($poll); + $this->assertEquals(1, $poll->post_id); + } + + /** + * @dataProvider unauthorizedUserProvider + * + * @test + */ + public function unauthorized_user_cannot_create_post_poll_on_api(int $userId) + { + $response = $this->send( + $this->request( + 'POST', + '/api/fof/polls', + [ + 'authenticatedAs' => $userId, + 'json' => [ + 'data' => [ + 'attributes' => [ + 'question' => 'Add a poll to an existing post', + 'publicPoll' => false, + 'hideVotes' => false, + 'allowChangeVote' => true, + 'allowMultipleVotes' => false, + 'maxVotes' => 0, + 'endDate' => false, + 'options' => [ + [ + 'answer' => 'Yes', + ], + [ + 'answer' => 'No', + ], + ], + ], + 'relationships' => [ + 'post' => [ + 'data' => [ + 'type' => 'posts', + 'id' => 1, + ], + ], + ], + ], + ], + ] + ) + ); + + $this->assertEquals(403, $response->getStatusCode()); + } + + /** + * @dataProvider authorizedUserProvider + * + * @test + */ + public function authorized_user_cannot_create_post_poll_with_invalid_postId(int $userId) + { + $response = $this->send( + $this->request( + 'POST', + '/api/fof/polls', + [ + 'authenticatedAs' => $userId, + 'json' => [ + 'data' => [ + 'attributes' => [ + 'question' => 'Add a poll to an existing post', + 'publicPoll' => false, + 'hideVotes' => false, + 'allowChangeVote' => true, + 'allowMultipleVotes' => false, + 'maxVotes' => 0, + 'endDate' => false, + 'options' => [ + [ + 'answer' => 'Yes', + ], + [ + 'answer' => 'No', + ], + ], + ], + 'relationships' => [ + 'post' => [ + 'data' => [ + 'type' => 'posts', + 'id' => 299, + ], + ], + ], + ], + ], + ] + ) + ); + + $this->assertEquals(404, $response->getStatusCode()); + } + + /** + * @dataProvider authorizedUserProvider + * + * @test + */ + public function authorized_user_can_create_global_poll_on_api(int $userId) + { + $response = $this->send( + $this->request( + 'POST', + '/api/fof/polls', + [ + 'authenticatedAs' => $userId, + 'json' => [ + 'data' => [ + 'attributes' => [ + 'question' => 'Add a global poll', + 'publicPoll' => false, + 'hideVotes' => false, + 'allowChangeVote' => true, + 'allowMultipleVotes' => false, + 'maxVotes' => 0, + 'endDate' => false, + 'options' => [ + [ + 'answer' => 'Yes', + ], + [ + 'answer' => 'No', + ], + ], + ], + ], + ], + ] + ) + ); + + $this->assertEquals(201, $response->getStatusCode()); + + $json = json_decode($response->getBody()->getContents(), true); + + $data = $json['data']; + $attributes = $data['attributes']; + + $this->assertEquals('Add a global poll', $attributes['question']); + + $pollId = $data['id']; + $this->assertNotNull($pollId); + + $poll = Poll::find($pollId); + $this->assertNotNull($poll); + $this->assertNull($poll->post_id); + + $response = $this->send( + $this->request( + 'GET', + '/api/fof/polls/'.$pollId, + [ + 'authenticatedAs' => $userId, + ] + ) + ); + + $this->assertEquals(200, $response->getStatusCode()); + + $json = json_decode($response->getBody()->getContents(), true); + + $this->assertTrue($json['data']['attributes']['isGlobal']); + } + + /** + * @dataProvider unauthorizedUserProvider + * + * @test + */ + public function unauthorized_user_cannot_create_global_poll_on_api(int $userId) + { + $response = $this->send( + $this->request( + 'POST', + '/api/fof/polls', + [ + 'authenticatedAs' => $userId, + 'json' => [ + 'data' => [ + 'attributes' => [ + 'question' => 'Add a global poll', + 'publicPoll' => false, + 'hideVotes' => false, + 'allowChangeVote' => true, + 'allowMultipleVotes' => false, + 'maxVotes' => 0, + 'endDate' => false, + 'options' => [ + [ + 'answer' => 'Yes', + ], + [ + 'answer' => 'No', + ], + ], + ], + ], + ], + ] + ) + ); + + $this->assertEquals(403, $response->getStatusCode()); + } +} diff --git a/tests/integration/api/ForumSerializerTest.php b/tests/integration/api/ForumSerializerTest.php new file mode 100644 index 00000000..2490a969 --- /dev/null +++ b/tests/integration/api/ForumSerializerTest.php @@ -0,0 +1,92 @@ +extension('fof-polls'); + + $this->prepareDatabase([ + 'users' => [ + $this->normalUser(), + ['id' => 3, 'username' => 'pollsuser', 'email' => 'polls@machine.local', 'password' => 'too-obscure', 'is_email_confirmed' => 1], + ], + 'group_user' => [ + ['user_id' => 3, 'group_id' => 4], + ], + 'group_permission' => [ + ['permission' => 'discussion.polls.start', 'group_id' => 4], + ], + ]); + } + + /** + * @test + */ + public function guest_does_not_have_discussion_polls_start_permission() + { + $response = $this->send( + $this->request('GET', '/api') + ); + + $this->assertEquals(200, $response->getStatusCode()); + + $body = json_decode($response->getBody()->getContents()); + + $this->assertFalse($body->data->attributes->canStartPolls); + } + + /** + * @test + */ + public function normal_user_does_not_have_discussion_polls_start_permission() + { + $response = $this->send( + $this->request('GET', '/api', [ + 'authenticatedAs' => 2, + ]) + ); + + $this->assertEquals(200, $response->getStatusCode()); + + $body = json_decode($response->getBody()->getContents()); + + $this->assertFalse($body->data->attributes->canStartPolls); + } + + /** + * @test + */ + public function user_with_discussion_polls_start_permission_has_discussion_polls_start_permission() + { + $response = $this->send( + $this->request('GET', '/api', [ + 'authenticatedAs' => 3, + ]) + ); + + $this->assertEquals(200, $response->getStatusCode()); + + $body = json_decode($response->getBody()->getContents()); + + $this->assertTrue($body->data->attributes->canStartPolls); + } +} diff --git a/tests/integration/setup.php b/tests/integration/setup.php new file mode 100644 index 00000000..d0d1c31d --- /dev/null +++ b/tests/integration/setup.php @@ -0,0 +1,18 @@ +run(); diff --git a/tests/phpunit.integration.xml b/tests/phpunit.integration.xml new file mode 100644 index 00000000..90fbbff3 --- /dev/null +++ b/tests/phpunit.integration.xml @@ -0,0 +1,25 @@ + + + + + ../src/ + + + + + ./integration + ./integration/tmp + + + diff --git a/tests/phpunit.unit.xml b/tests/phpunit.unit.xml new file mode 100644 index 00000000..d3a4a3e3 --- /dev/null +++ b/tests/phpunit.unit.xml @@ -0,0 +1,27 @@ + + + + + ../src/ + + + + + ./unit + + + + + + diff --git a/tests/unit/.gitkeep b/tests/unit/.gitkeep new file mode 100644 index 00000000..e69de29b