Aller au contenu

omnibase/forum

Discussion forum for base-bundle: categories, topics built on Thread (tags, followers, likes), markdown posts, moderation and admin CRUD

Étage
Structure préparée · Omnibase
Version
main
Vérifié en réel
Son README ne le dit pas
Licence
MIT
composer require omnibase/forum:dev-main

omnibase/forum is a discussion forum for a Symfony site of the omnibase family: groups holding boards, topics, Markdown posts, tags, polls, pins, locks, scheduled topics and moderation. Its namespace is Base\Forum\; it is built on omnisense/omnibase and omnibase/admin.

What it adds

  • A Topic that is omnisense/omnibase's Thread (a JOINED subclass, table forum_topic): it inherits the thread's tags, owners, followers, likes, mentions, slug, publication state, soft delete and translations, and adds its board, posts, pin, lock, views, replies, last activity and poll.
  • A Category (forum_category), a two-level tree: a category without a parent is a group, a category with one is a board.
  • A Post (forum_post), Markdown rendered with league/commonmark, raw HTML escaped; a soft delete that keeps the numbering; the message it answers.
  • A Poll (forum_poll) and its PollVotes (forum_poll_vote): one answer or several, once per member.
  • A voter, ForumVoter, with seven attributes (FORUM_READ, FORUM_POST, FORUM_REPLY, FORUM_EDIT, FORUM_MODERATE, FORUM_SCHEDULE, FORUM_ADMIN) and the moderation rule of a role hierarchy.
  • Fifteen routes under /bbs and the order page /admin/bbs/ordre; the board index in two views, a topic in two styles, all remembered per browser.
  • Two back office screens (categories, topics) and an order page for the groups, boards and tags.
  • A sitemap listener that lists the public boards and their topics.
  • Twig filters and functions for the Markdown, the excerpts, the quotes and the folder badges.
  • Its stylesheets and scripts under public/, English and French messages (domain forum).

Pages

Proposer une modification docs/index.md

Installation

Requirements

  • PHP 8.2 or later;
  • doctrine/orm 2.15 or 3;
  • omnisense/omnibase 3.0 or 4.0, omnibridge/symfony 1.x, omnibase/admin;
  • league/commonmark 2.4 or later;
  • Symfony 6, 7 or 8 (config, dependency-injection, form, http-kernel, security-bundle);
  • an App\Entity\User class: authors, last posters, deleters and voters are that entity.

omnibase/admin is required, not optional: omnisense/omnibase walks every class of a bundle's src/ when it builds its App\ aliases, so CRUD controllers extending an admin class that is not installed would break the application.

The bundle

composer require omnibase/forum:dev-main
// config/bundles.php
Base\Forum\ForumBundle::class => ['all' => true],

The routes

# config/routes.yaml
forum_controller:
    resource: "@ForumBundle/src/Controller/Client"
    type: attribute
    prefix: /

The order page of the back office, /admin/bbs/ordre, is in src/Controller/Admin; a site that wants it imports that directory too (the CRUD controllers beside it carry no route attribute and add nothing):

forum_admin_controller:
    resource: "@ForumBundle/src/Controller/Admin"
    type: attribute
    prefix: /

The configuration

Every key is optional (Configuration reference):

# config/packages/forum.yaml
forum:
    topics_per_page: 20
    posts_per_page: 10
    flood_interval: 15
    hot_threshold: 25
    active_hours: 72
    moderator_role: ROLE_ADMIN
    admin_role: ROLE_SUPER_ADMIN

The moderation rule reads the role hierarchy of security.yaml (Who may do what).

The schema and the assets

bin/console doctrine:migrations:diff
bin/console doctrine:migrations:migrate   # forum_category, forum_topic, forum_post, forum_poll, forum_poll_vote
bin/console assets:install                # public/bundles/forum/css, public/bundles/forum/js

The layout

Every forum page extends @Forum/client/layout.html.twig, which extends the site's layout1.html.twig and fills its title, stylesheets and content blocks (Templates and Twig).

Overriding the entities

The bundle maps Base\Forum\Entity\* to App\Entity\Forum\* and Base\Forum\Repository\* to App\Repository\Forum\* (omnisense/omnibase's App-wins aliasing): an App\Entity\Forum\Topic extending Base\Forum\Entity\Topic takes over.

The demo

The repository holds a demo application under example/app/ (bundles, routes, Doctrine, security, forum.yaml, a layout1.html.twig) and a Dockerfile that builds it with SQLite; its first page seeds two members, a group with a public and a staff-only board, three tags and a topic with a reply.

Proposer une modification docs/installation.md

Concepts

Groups and boards

Base\Forum\Entity\Category is a two-level tree. A category without a parent is a group (isGroup()): it holds boards and no topics. A category with a parent is a board (isBoard()): it holds topics.

Field Column Meaning
title string(128) required
slug string(128), unique from the title
description text, nullable under the title
icon, color string(64), string(9), nullable how the board is drawn
position integer its order among its siblings (the order page writes 10, 20, 30...)
locked boolean no new topic and no reply, except by its moderators
announcement boolean an announcement board: its topics are the admin role's to write
polls boolean, default true whether a topic opened there may carry a poll (allowsPolls())
requiredRole string(64), nullable the role a reader needs; null, everyone reads
parent, children self-reference, SET NULL the tree

CategoryRepository::findTree() returns the groups with their boards, findBoards() the boards, findOneBySlug() one category.

Topics

Base\Forum\Entity\Topic extends omnisense/omnibase's Base\Entity\Thread (discriminator forum_topic). From the thread it has its owners (the author), title (translatable), slug, tags, followers, likes, state and publication date. It adds:

Field Meaning
category its board (required)
posts its messages, the first one being the opening post
pinned, locked a post-it on top of its board; no reply but its moderators'
views readers, counted once per session, never its author, never a page loaded by the stream
replies the posts after the first
lastPostAt, lastPoster its last activity, by which the lists sort
poll its poll, if it has one

addPost() updates replies, lastPostAt and lastPoster. setAuthor() changes the owner and the opening post's author together. Views are counted with a direct UPDATE, so a reader does not touch the thread's updatedAt.

Scheduled topics

A topic is out the moment it is written. schedule($at) gives it an hour to come out at: the publication date becomes that hour when it is in the future, now otherwise, and a topic nobody has answered moves its last activity there too, so it comes into the lists at its hour. isUpcoming() is true until then.

No command has to run: every list goes through TopicRepository::whereOut(), which keeps the topics whose publication date is null or past. Until its hour, an upcoming topic is its authors' and its board's moderators' only; nobody replies to it; anyone else gets a 404 on its address (Who may do what).

Posts

Base\Forum\Entity\Post holds the Markdown content, its author, the message it answers (replyTo), its edit count and the hour of its last edit (markEdited(), when its author edits it), and a soft delete (deletedAt, deletedBy): delete() keeps the post's place and number, restore() brings it back. getNumber() is its 1-based position in the topic, isFirst() says whether it is the opening post, getShownAt() is the hour shown on it (for the opening post of a scheduled topic, the hour the topic came out).

Deleting the opening post deletes the topic.

Polls

Base\Forum\Entity\Poll belongs to one topic: a question (200 characters), 2 to 10 answers (MIN_OPTIONS, MAX_OPTIONS, each cut at 120 characters) and maxChoices (1, a single answer; more, up to that many, never more than there are answers). PollVote records one answer of one member; a unique key on poll, member and answer keeps a member from counting twice. A poll is open while its topic is not locked (isOpen()); getCounts() gives the votes per answer, getTotal() the voters, getChoicesOf($user) what a member chose.

Tags

The tags are omnisense/omnibase's Tag, shared with every thread. TopicRepository::findTagUsage() returns the tag cloud: the tags in use on topics that are out, highest priority first (the order page sets it), then the most used.

Proposer une modification docs/concepts.md

Who may do what

The voter

Base\Forum\Security\ForumVoter answers seven attributes. Its subject is a category, a topic or a post; a topic and a post are judged by their board.

Attribute Subject Granted when
FORUM_READ category, topic, post the board has no requiredRole, or the reader has it
FORUM_POST board signed in, the board readable and a board (not a group); on an announcement board, the admin role only, locked or not; on a locked board, its moderators only
FORUM_REPLY topic signed in, readable, and neither the topic nor its board locked; its moderators always
FORUM_EDIT topic, post signed in and readable: its moderators, or its author while the topic is not locked
FORUM_MODERATE category, topic, post the reader moderates its board; with no subject, the reader moderates any board
FORUM_SCHEDULE board the reader moderates it and may post there; with no subject, moderates any board
FORUM_ADMIN none the reader has forum.admin_role

An upcoming topic (scheduled, not out yet) changes three answers: nobody replies to it, and anyone who is neither one of its owners nor a moderator of its board is refused everything on it and on its posts, FORUM_READ included.

Who moderates a board

ForumVoter::moderates($roles, $category) reads the role hierarchy of the firewall (security.role_hierarchy):

  • the admin role (forum.admin_role, ROLE_SUPER_ADMIN by default) moderates every board;
  • the moderator role (forum.moderator_role, ROLE_ADMIN by default) moderates the open boards, and the boards kept for a role it stands above;
  • a board kept for the moderator role's own rank is the admins' to moderate: its moderators read it, the admins keep order there;
  • a board kept for a role outside the moderator's branch, the moderator cannot even read;
  • announcement boards are the admin role's alone.

With this hierarchy:

# config/packages/security.yaml
security:
    role_hierarchy:
        ROLE_HELPER: ROLE_USER
        ROLE_ADMIN: ROLE_HELPER
        ROLE_SUPER_ADMIN: ROLE_ADMIN

a ROLE_ADMIN member moderates the open boards and a board kept for ROLE_HELPER; a board kept for ROLE_ADMIN is moderated by ROLE_SUPER_ADMIN only.

What the controllers check

Action Check
reading a board, a topic, a post's address FORUM_READ (403; an upcoming topic one may not see: 404)
opening a topic ROLE_USER, then FORUM_POST on the board chosen; the board list offers only those the writer may post in
replying ROLE_USER, FORUM_REPLY
voting ROLE_USER, FORUM_READ, a CSRF token
editing a topic or a post ROLE_USER, FORUM_EDIT; moving a topic to another board needs FORUM_MODERATE on both
pin, unpin, lock, unlock ROLE_USER, FORUM_MODERATE, a CSRF token
deleting a topic ROLE_USER, FORUM_EDIT (an author deletes their own), a CSRF token
deleting a post ROLE_USER, FORUM_EDIT, a CSRF token; restoring a deleted post, FORUM_MODERATE
scheduling, posting in another member's name FORUM_SCHEDULE, FORUM_ADMIN
opening a board or a group from the site FORUM_ADMIN
the order page ROLE_ADMIN

The moderators are exempt from the flood interval.

In templates

{% if is_granted('FORUM_POST', category) %}
    <a href="{{ path('forum_topic_new', {category: category.slug}) }}">{{ '@forum.topic.new'|trans }}</a>
{% endif %}

Lists outside a board

The index's feed, the tag page (/bbs/t/{slug}), the search (/bbs/recherche) and the sitemap list only the topics of the boards the reader may read (FORUM_READ): a board kept for a role never shows its titles, its name or its last posters to someone without it. TopicRepository::createLatestQuery(), createTrendingQuery(), createTagQuery() and createSearchQuery() take the readable boards' ids (null: every board), the controller passing those FORUM_READ grants.

Proposer une modification docs/permissions.md

The pages

Routes

Name Path Methods Access
forum_index /bbs any everyone; in the sitemap (0.7, daily)
forum_category /bbs/c/{slug} any FORUM_READ; in the sitemap (0.6, daily)
forum_category_new /bbs/nouveau-forum any FORUM_ADMIN; ?dans=<group slug>
forum_tag /bbs/t/{slug} any everyone
forum_search /bbs/recherche any everyone; ?q=
forum_display /bbs/display POST everyone
forum_topic_new /bbs/nouveau/{category} any ROLE_USER; {category} optional; ?depuis=<post id>
forum_topic /bbs/{slug} any FORUM_READ; in the sitemap (0.5, weekly); ?page, ?quote=<post id>, ?reply=<post id>
forum_topic_reply /bbs/{slug}/repondre POST ROLE_USER, FORUM_REPLY
forum_topic_vote /bbs/{slug}/voter POST ROLE_USER, FORUM_READ
forum_topic_edit /bbs/{slug}/modifier any ROLE_USER, FORUM_EDIT
forum_topic_moderate /bbs/{slug}/moderer/{action} POST action: pin, unpin, lock, unlock, delete
forum_post /bbs/message/{id} GET FORUM_READ; redirects to the post in its topic, on its page
forum_post_edit /bbs/message/{id}/modifier any ROLE_USER, FORUM_EDIT
forum_post_delete /bbs/message/{id}/supprimer POST ROLE_USER, FORUM_EDIT
forum_admin_order /admin/bbs/ordre GET, POST ROLE_ADMIN

forum_category_new and forum_topic_new carry a route priority of 5, so /bbs/{slug} does not take nouveau-forum or nouveau for a topic. Likes and follows go through omnisense/omnibase's thread API (/api/thread/{slug}/like, unlike, follow, unfollow).

The index

/bbs has two views:

  • classic, the board list grouped under its groups, with each board's topics and replies, its last message (who, when, in which topic), its folder badge, and a list of the latest topics below (@Forum/client/index.html.twig);
  • feed, a flat list of the latest topics with the two latest announcements and up to six pinned topics on top (@Forum/client/index_fil.html.twig).

The feed has two orders: recent (the latest activity) and popular (among the topics that moved in the last 30 days, the most replies, then the most views). Both list only the boards the reader may read, and topics that are out.

Display choices

The reader's choices are kept in a cookie for a year, never in the address:

Choice Cookie Values
view (the index) FORUM/VIEW classic, feed
sort (the feed) FORUM/SORT recent, popular
style (a topic) FORUM/TOPIC retro (one box of rows), cards (a card per message)

Each button is a small POST form to forum_display with the choice and back, the page to return to. An unknown name or value is ignored; back must be a path on the site (it starts with /, not //), otherwise the index.

A group's page lists its boards. A board's page lists its topics, pinned first, then by last activity; its moderators also see its upcoming topics, marked. A tag's page lists the topics carrying the tag. The search needs at least three characters and matches, without case, a topic's title or the content of any of its posts (a LIKE; no index).

A topic

A topic's page shows its posts, posts_per_page at a time, the reply form for those who may reply, the poll, the previous and next topics of its board, and a "jump to" list of the boards the reader may read. An outline of the whole topic is built from one query: each post's parent (the message it answers, or the trunk), its branch, level and depth, its number and page, and how many posts answer it; the timeline script and the "in reply to" lines read it.

Infinite lists

A board, a tag, the search, the feed and a topic load their next page as the reader scrolls (forum-feed.js, forum-thread.js): the script asks the same address with the header X-Forum-Stream and receives the rows alone. The responses carry Vary: X-Forum-Stream, and a page loaded that way does not count a view.

The sitemap

Base\Forum\EventListener\SitemapListener adds, on omnisense/omnibase's SitemapEvent::BUILD, every board without a requiredRole (its update date) and the latest topics of those boards that are out, up to 5,000 (MAX_TOPICS), each with the date of its last activity.

Proposer une modification docs/pages.md

Writing, replying, voting

Opening a topic

/bbs/nouveau/{category} opens a topic (Base\Forum\Form\Type\TopicType, model Base\Forum\Form\Model\TopicModel):

Field Rule
category required; left out when the page was opened from a board the writer may post in
title 3 to 120 characters
tags omnisense/omnibase's tags, any number
content the opening post, 2 to 20,000 characters, Markdown
pollQuestion, pollOptions, pollMax an optional poll: a question of at most 200 characters, 2 to 10 answers (one per line), at most pollMax choices (1 to 10, not more than the answers); only on a board that takes polls
publishedAt for FORUM_SCHEDULE only: an hour to come out at; an hour already gone means now
author for FORUM_ADMIN only: the member it goes out under, searched by name

When the page is not opened from a board, the board list offers only the boards the writer may post in (FORUM_POST); with none, the page flashes @forum.aside.cannot_post and returns to the index.

?depuis=<post id> opens a topic out of a message: the new topic starts on the same board, its content prefilled with a link back to that message (@forum.topic.linked_opening).

On submit, in order: a scheduling refused for that board puts an error on publishedAt; a board the writer may not post in flashes @forum.flash.board_locked; a post sent within the flood interval flashes @forum.flash.flood with the seconds to wait. Otherwise the topic, its opening post and its poll are saved, and the page redirects to the topic, flashing @forum.flash.topic_created or, for an upcoming topic, @forum.flash.topic_scheduled with its hour.

Replying

The reply form (Base\Forum\Form\Type\PostType, 2 to 20,000 characters) is on the topic's page for those who may reply, and posts to /bbs/{slug}/repondre. ?quote=<post id> prefills it with that post quoted; ?reply=<post id> links the reply to that post without quoting it. The link is sent as reply_to and kept when the post belongs to the same topic. After the reply, the page redirects to the new post, on its page; a refusal or an invalid form flashes its message and returns to the topic.

The flood interval

A member waits forum.flood_interval seconds (15 by default) after their last post before posting again, a topic or a reply. 0 turns it off; moderators are never held.

Editing

/bbs/{slug}/modifier edits a topic's title and tags (the opening post is edited as a post). A moderator of the topic's board can also move it to another board they moderate; while the topic is upcoming, those who may schedule there can move its hour; the forum admin can change whose name it is under.

/bbs/message/{id}/modifier edits a post. When its author edits it, the post records the hour and counts the edit; a moderator's edit does not.

Moderating and deleting

The moderation buttons post to /bbs/{slug}/moderer/{action} with a CSRF token (forum_moderate_<topic id>): pin, unpin, lock, unlock, and delete, which an author may also do on their own topic. Deleting a topic returns to its board.

/bbs/message/{id}/supprimer (CSRF token forum_post_<post id>) deletes a post softly: it keeps its place and number, shown as deleted. The same button on a deleted post restores it, for a moderator. Deleting the opening post deletes the topic.

Voting

A poll's form posts to /bbs/{slug}/voter with a CSRF token (forum_vote_<poll id>) and choice (one answer) or choice[] (several). The vote is refused, with a flash, when the topic is locked (@forum.poll.flash.closed), when no valid answer is sent (@forum.poll.flash.no_choice), when more answers than allowed are sent (@forum.poll.flash.too_many), or when the member has voted (@forum.poll.flash.already). A vote is final, and its answers are saved together: a second click arriving with the first meets the unique key and is answered as already voted.

Opening a board from the site

/bbs/nouveau-forum (FORUM_ADMIN) opens a board in the group named by ?dans=<group slug>, or a new group without it (Base\Forum\Form\Type\CategoryType: title, group, description, announcement, polls). It is placed last among its siblings (ten past the highest position), and the page redirects to it, flashing @forum.flash.board_created or @forum.flash.group_created. The order page moves it; the back office edits the rest.

Proposer une modification docs/writing.md

Markdown

The renderer

Base\Forum\Service\MarkdownRenderer renders a post with league/commonmark: CommonMark, GitHub flavouring (tables, task lists, strikethrough), autolinks. Raw HTML is escaped (html_input: escape, and the disallowed raw HTML extension), javascript: links are dropped (allow_unsafe_links: false), nesting stops at 20 levels, and a single line break becomes <br>.

Two forum habits are added:

  • >>12, at a word boundary, becomes a link to #post-12, the anchor of post 12 of the same topic; inside code it stays as typed;
  • @name (2 to 32 letters, digits, _ or -) becomes a link to the member's profile when the site has a route named user_profileByUsername (with a username parameter); inside code, a link or a tag it stays as typed.
Method Returns
render(?string $markdown) the HTML; an empty string for empty input
excerpt(?string $markdown, int $length = 160) the rendered text without tags, on one line, cut at $length characters with an ellipsis
quote(?string $markdown, ?string $author = null) the text with > before every line, under a line naming the author, for a reply box

An example

use Base\Forum\Service\MarkdownRenderer;

$markdown = new MarkdownRenderer();
$post = "As >>2 said, see [the docs](javascript:alert(1)) and <script>x()</script>.\n\n- [x] done\n- ~~old~~";
echo $markdown->render($post);
echo "---\n", $markdown->excerpt($post, 40), "\n---\n";
echo $markdown->quote("First line\nSecond line", 'anna');

Its output, run in omnisense/omnibase-harness (no router, so no mention links):

<p>As <a href="#post-2">&gt;&gt;2</a> said, see <a>the docs</a> and &lt;script&gt;x()&lt;/script&gt;.</p>
<ul>
<li><input checked="" disabled="" type="checkbox"> done</li>
<li><del>old</del></li>
</ul>
---
As >>2 said, see the docs and <script>x(…
---
> **@anna** a écrit :
> First line
> Second line

The excerpt decodes the entities after removing the tags, so the escaped <script> comes back as text: it is plain text, to be printed escaped (the forum_excerpt filter is not marked safe, so Twig escapes it).

quote() writes the author's line in the reader's language (reply.quote_wrote of the forum domain: "@author wrote:", "@author a écrit :"); built without a translator, in English.

In Twig

{{ post.content|forum_markdown }}       {# safe HTML #}
{{ topic.firstPost.content|forum_excerpt(120) }}
{{ forum_quote(post.content, post.author) }}

Proposer une modification docs/markdown.md

Templates and Twig

The layout

@Forum/client/layout.html.twig extends the site's layout1.html.twig. It fills title (the page's forum_title block, else the index's title), stylesheets (bundles/forum/css/forum.css) and content, in which it draws the forum's header, its pages and its side column. A page of the forum fills its own blocks:

Block Holds
forum_header the header: the banner, the breadcrumb, the view or style switch, the search
forum_banner includes @Forum/client/_banner.html.twig
forum_breadcrumb, forum_crumbs the trail; a site with a breadcrumb of its own empties forum_breadcrumb
forum_flashes the flash messages (includes the site's flashbag.html.twig if it has one)
forum_lead what a page wants read first, across both columns
forum_body the page itself
forum_side the side column (_aside.html.twig: new topic, new board, sign in, the tag cloud); a page adds to it with parent()
forum_footer the foot, empty by default

The host's layout needs the title, stylesheets and content blocks.

Override points

A site overrides any template in templates/bundles/ForumBundle/. The two meant for it:

  • client/_banner.html.twig, the banner above the forum;
  • client/_avatar.html.twig, how a member is pictured.

The other partials (_topic_row, _post, _member, _folder, _badge, _legend, _pager, topic/_poll...) can be overridden the same way.

Scripts and stylesheets

After assets:install, under bundles/forum/:

File Loaded by Does
css/forum.css every page the forum
css/forum-thread.css a topic the post header and its tools, the timeline
js/forum.js a topic the like and follow buttons, a confirmation on the destructive moderation buttons, the Markdown toolbar of every editor, the board jump, a post edited in place
js/forum-thread.js a topic the timeline and the stream of pages, from the topic's outline
js/forum-feed.js the feed, a board, a tag, the search the stream of rows
js/forum-index.js the index folding a group's boards, remembered per group in the browser
js/forum-poll.js the new topic page, a poll allowing several answers the poll builder; the several-answers vote

Twig filters and functions

Name Kind Returns
forum_markdown filter a post's HTML (Markdown), marked safe
forum_excerpt(length = 160) filter a one-line plain-text excerpt
forum_quote(markdown, author) function the text quoted for a reply box
forum_is_hot(topic or replies) function whether a topic has forum.hot_threshold replies or more
forum_is_active(topic or date) function whether its last activity is within forum.active_hours
forum_active_hours() function forum.active_hours
forum_topic_folder(topic) function the topic's badge, {state, title}
forum_board_folder(board, last = null, hot = false) function the board's badge, {state, title}

A topic's badge follows this precedence: an announcement (a topic of an announcement board, announce), a post-it (sticky), a locked topic (lock), a popular one (hot), then the plain folder (default); the last three take their awake art (new_lock, new_hot, new) when the topic has moved within active_hours. A board's badge reads the same way after its latest message: announce, lock, hot when it holds a popular topic, default; a board kept for a role says so in its title. "Awake" means recent activity, not unread: the forum keeps no read marker per member.

Translations

The domain forum, in English and French (ICU format), holds every text of the pages, the forms, the flashes and the back office; the templates call its keys as '@forum.<key>'|trans. A site overrides a message in its own translations/forum+intl-icu.<locale>.yaml.

Proposer une modification docs/templates.md

The back office

The screens are loaded when omnibase/admin is installed (its Base\Admin\Controller\AbstractCrudController exists).

Categories

Base\Forum\Controller\Admin\Crud\CategoryCrudController edits groups and boards: title, slug (following the title), parent (none for a group), position, locked, announcement, polls, icon, colour, the role a reader needs (requiredRole, chosen among the site's roles; empty, everyone reads) and description. Filters: parent, locked.

Topics

Base\Forum\Controller\Admin\Crud\TopicCrudController edits a topic's title (a translation field), slug, board, tags, pin and lock; the index shows its views and replies. Filters: board, pinned, locked. Posts are edited on the site.

The two CRUD screens carry no #[OpenToAdmins]: their writes follow omnibase/admin's default.

The order page

/admin/bbs/ordre (forum_admin_order, ROLE_ADMIN; Base\Forum\Controller\Admin\OrderController) orders the whole forum on one screen: the groups, the boards in each group (a board can change group there), and the tags of the tag cloud. It posts order[groups][], order[boards][<group id>][] and order[tags][] with a CSRF token (forum_admin_order) and flashes admin.order.saved (domain forum).

Groups and boards are numbered 10, 20, 30..., so a board added later can be slotted between two without renumbering. A board stays a board and a group a group: only a board's parent changes. Tags go the other way: the first one gets the highest priority, which the tag cloud reads first.

Its route lives beside the CRUD controllers, in src/Controller/Admin, which a site imports (Installation).

The dashboard

yield MenuItem::linkToCrud(\Base\Forum\Entity\Category::class, 'Forums', 'fa-solid fa-comments');
yield MenuItem::linkToCrud(\Base\Forum\Entity\Topic::class, 'Topics', 'fa-solid fa-comment-dots');
yield MenuItem::linkToRoute('forum_admin_order', [], 'Order', 'fa-solid fa-sort');

The categories screen labels the polls field in the reader's language (form.board_polls).

Proposer une modification docs/back-office.md

Configuration reference

The forum tree

Dumped with Symfony's YamlReferenceDumper from Base\Forum\DependencyInjection\ForumConfiguration:

forum:

    # Topics listed per page in a category, a tag or the search.
    topics_per_page:      20

    # Posts shown per page inside a topic.
    posts_per_page:       10

    # Seconds a member must wait between two posts (phpBB called it flood_interval).
    flood_interval:       15

    # Replies from which a topic is flagged "hot".
    hot_threshold:        25

    # A board or topic with a message this recent is awake: its folder badge shows the "new" art instead of the sleeping "zzz".
    active_hours:         72
    title_max_length:     120
    content_max_length:   20000

    # Role allowed to pin, lock, move and delete any topic or post.
    moderator_role:       ROLE_ADMIN

    # Role allowed to open new boards and groups from the site itself (the back office has its own CRUD).
    admin_role:           ROLE_SUPER_ADMIN
Key Type Default Read by
topics_per_page integer, at least 1 20 the index's lists, a board, a tag, the search
posts_per_page integer, at least 1 10 a topic, a post's page, the outline
flood_interval integer, at least 0 15 opening a topic, replying; 0 turns it off
hot_threshold integer, at least 1 25 forum_is_hot(), the badges, the boards holding a popular topic
active_hours integer, at least 1 72 forum_is_active(), the badges, the legend
title_max_length integer, at least 10 120 TopicType: the title's constraint and maxlength
content_max_length integer, at least 100 20000 TopicType and PostType: the post's constraint
moderator_role string ROLE_ADMIN ForumVoter
admin_role string ROLE_SUPER_ADMIN ForumVoter (FORUM_ADMIN, the moderation of everything, the announcement boards)

Parameters

Each key is a container parameter: %forum.topics_per_page%, %forum.posts_per_page%, %forum.flood_interval%, %forum.hot_threshold%, %forum.active_hours%, %forum.title_max_length%, %forum.content_max_length%, %forum.moderator_role%, %forum.admin_role%.

Services

config/services.php autowires and autoconfigures everything under Base\Forum\ except the DI classes, the entities and the bundle class; the client controllers are tagged as controllers; the admin controllers are loaded when omnibase/admin is installed.

Service Role
Base\Forum\Security\ForumVoter the seven FORUM_* attributes, moderates()
Base\Forum\Service\MarkdownRenderer render(), excerpt(), quote()
Base\Forum\Twig\ForumTwigExtension the filters and functions
Base\Forum\EventListener\SitemapListener the boards and topics of /sitemap.xml
Base\Forum\Repository\CategoryRepository findTree(), findBoards(), findOneBySlug()
Base\Forum\Repository\TopicRepository the list queries, whereOut(), findTagUsage(), incrementViews(), the counters
Base\Forum\Repository\PostRepository createTopicQuery(), findOutline(), findLastByAuthor(), findPageOf(), countByAuthor()

TopicRepository extends omnisense/omnibase's ThreadRepository (a Thread subclass needs its hierarchy trait), not ServiceEntityRepository.

Proposer une modification docs/configuration.md

Testing

Running the suite

From the package's checkout, with its dev dependencies:

vendor/bin/phpunit

phpunit.xml.dist boots tests/bootstrap.php, which registers the test namespace itself, so the same suite runs from inside a host application:

php vendor/bin/phpunit -c vendor/omnibase/forum/phpunit.xml.dist

In omnisense/omnibase-harness, mounting the checkout in place of the installed package:

docker run --rm --network none \
    -v "$PWD":/srv/demo/vendor/omnibase/forum:ro \
    omnisense/omnibase-harness:latest test forum

On this checkout, in the harness:

OK (28 tests, 59 assertions)

What the tests check

Test Checks
Controller\CrudRequiredFieldsTest omnibase/admin's CrudFieldsTestCase on the two screens: a field is required exactly when the record needs it, an optional field takes an empty value, one field per property, the slug follows a field
Entity\PollTest a single-answer poll; maxChoices never more than the answers; three picks of one member are one voter
Entity\TopicScheduleTest a new topic is out at once; an hour to come keeps it upcoming and moves its activity there; an hour gone, or none, means now; the opening post is dated the hour its topic comes out; a reply keeps its own date
Service\MarkdownRendererTest Markdown renders; raw HTML is escaped; javascript: links are dropped; >>n becomes an anchor, not inside code; empty input renders nothing; the excerpt is plain text and cut; a quote prefixes every line and names the author
Twig\FolderStateTest a topic sleeps or talks after its last message; popular and locked topics have their awake art; announcement, then post-it, come first; the boards' badges; a reserved board is not an announcement

The voter, the controllers and the repositories have no test of their own.

Proposer une modification docs/testing.md