Aller au contenu

omnibase/faq

A FAQ for omnibase: questions by section, answered in the back office, and the form to ask another.

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

A FAQ for the sites built on omnisense/omnibase: the questions visitors ask most, sorted into sections, answered in the back office, and a form for the visitor whose question is missing. It is a Symfony bundle (Base\Faq\FaqBundle) on the core 3 or 4, through omnibridge/symfony.

It adds no storage of its own kind: a Question is an omnibase Thread (a JOINED subclass, table faq_question) - the question is the thread's title, the answer its text (EditorJS or HTML), its state says whether it is online, its likes count the readers it helped. A Section is an omnibase Taxon, so sections form a tree and a section may hold sub-sections. What a visitor types in the "A question missing?" form is an Ask, a form model (not an entity) that implements the core's SpamProtectionInterface: Akismet reads it, and a question that passes lands as a draft Question in the back office.

What it gives a site:

  • /faq: every section with its questions folded (<details>), a search, the form to ask a question; /faq/{slug}: one question at its own address with the others of its section. Both pages carry schema.org's FAQPage in JSON-LD.
  • Three Twig functions for a host page (faq_questions(), faq_featured(), faq_sections()) and a partial that draws questions folded.
  • With omnibase/admin: the Question and Section screens, open to ROLE_ADMIN, and the dashboard tile faq_pending_questions; every administrator is told in the app's bell when a visitor asks a question.

Use it when a site needs a handful to a few hundred questions with answers written by its editors. It is not a help desk: there is no conversation with the visitor, who is only told the question arrived (and leaves an e-mail address the editor may write to).

How this manual is organised

  • Installation: requirements, the bundle, the routes, the schema, the layout the templates extend.
  • Questions and sections: the entities and their fields, the form model, the repository's queries.
  • The pages and the form: /faq, the search, a question's page, asking a question (the guard, Akismet, what is kept), the JSON-LD.
  • In a host page: the Twig functions, the partial, the stylesheet and its custom properties.
  • The back office: the screens, who may write, the dashboard tile, the notification.
  • Configuration reference: faq:, the parameters, the services.
  • Testing: the suite and what each test checks.

Proposer une modification docs/index.md

Installation

Requirements

What composer.json requires: PHP 8.2 or later, doctrine/orm 2.15 or 3, omnisense/omnibase 3.0 or 4.0, omnibridge/symfony 1.x, omnibase/admin, and symfony/config, dependency-injection, form, http-kernel and security-bundle 6, 7 or 8.

The core provides what the bundle stands on: Base\Entity\Thread and Base\Entity\Thread\Taxon (the questions and the sections), the forms' guard (Base\Service\FormGuard), Akismet through SpamProtectionInterface, the |wysiwyg Twig filter that renders an answer, the #[Sitemap] attribute, the notifications (Base\Entity\User\Notification).

The bundle

composer require omnibase/faq:dev-main
// config/bundles.php
return [
    // ...
    Base\BaseBundle::class => ['all' => true],
    Base\Admin\AdminBundle::class => ['all' => true],
    Base\Faq\FaqBundle::class => ['all' => true],
];

FaqBundle extends the core's AbstractBaseBundle. Its build() maps the classes of src/Entity to App\Entity\Faq\* and those of src/Repository to App\Repository\Faq\* (setMapping()): an application that declares App\Entity\Faq\Question extending Base\Faq\Entity\Question gets its own class used in place of the bundle's; otherwise the name is an alias of the bundle's class. getPath() is the package's root, so its templates/ is the @Faq namespace and its public/ is installed as bundles/faq/.

The extension (Base\Faq\DependencyInjection\FaqExtension) loads config/services.php and turns the faq: tree into parameters (Configuration reference).

The routes

The public pages are attribute routes in src/Controller/Client:

# config/routes.yaml
faq_controller:
    resource: "@FaqBundle/src/Controller/Client"
    type: attribute
    prefix: /
Route Path Methods Controller
faq_index /faq GET, POST FaqController::index() - in the sitemap (priority 0.6, weekly)
faq_question /faq/{slug} (slug: [a-z0-9\-]+) any FaqController::question()

The back office's screens need no import of their own: omnibase/admin's base_admin loader generates the routes of every registered CRUD (The back office).

The schema

The bundle maps two entities, faq_question (joined to the core's thread table) and the Section discriminator entry of the taxon table. Generate and run a migration:

bin/console doctrine:migrations:diff
bin/console doctrine:migrations:migrate

The public files

bin/console assets:install

copies public/css/faq.css to public/bundles/faq/css/faq.css, which both pages link.

What the host provides

The two pages extend layout1.html.twig and fill its blocks title, description, stylesheets (calling parent()) and content. The site's layout must define them.

An answer is printed through the core's |wysiwyg filter (EditorJS JSON or HTML).

Proposer une modification docs/installation.md

Questions and sections

Question

Base\Faq\Entity\Question extends the core's Base\Entity\Thread (table faq_question, discriminator faq_question, repository QuestionRepository) and implements LinkableInterface. What a question uses of the thread:

Thread field In the FAQ
title the question (translated, as every thread's title)
content the answer: EditorJS JSON or HTML
excerpt for a visitor's question, the details they added (never shown on the site)
slug the question's address, /faq/{slug}
state ThreadState::PUBLISH online, ThreadState::DRAFT waiting in the back office
publishedAt set the first time it is published (publish())
taxa holds its Section
likes the readers it helped (the thread's own)

Its own columns:

Property Column Default Meaning
position integer 0 its place in its section, lower first
featured boolean false on the home page's short FAQ (faq_featured())
askedBy string(180), nullable null the e-mail a visitor left when asking it; setAskedBy() trims and lowercases it, an empty value is null

Methods beyond the accessors:

  • getSection(): ?Section - the first taxon of the question that is a Section. setSection(?Section) removes every Section from its taxa and adds the one given: a question is in one section at most (other kinds of taxa stay).
  • publish(bool $published = true): self - PUBLISH or DRAFT, and publishedAt set to now if it was empty.
  • __toLink() - the faq_question address of its slug; __toString() its title.
  • __iconizeStatic() - fa-solid fa-circle-question.

Section

Base\Faq\Entity\Section extends Base\Entity\Thread\Taxon (discriminator faq_section): a translated label and description, an icon, a slug, a parent and children

  • all the taxon's. It adds:
Property Column Default Meaning
position integer 0 its place in the list, lower first

getPublishedQuestions(): list<Question> returns the published questions among the threads the section classifies, sorted by position, then by id. Its icon is fa-solid fa-folder-tree.

Ask

Base\Faq\Entity\Ask is the form's model, not an entity: nothing of it is stored as such. Public properties and their constraints:

Property Type Constraints
question ?string NotBlank, Length(min: 10, max: 300)
details ?string Length(max: 2000)
email ?string Email, Length(max: 180)
author ?App\Entity\User the signed-in user, when there is one
spamScore ?int set by Akismet's callback

As a SpamProtectionInterface it gives Akismet its author (getSpamBlameable()), the question and details joined (getSpamText()), now (getSpamDate()), and receives the score (getSpamCallback(int $score)). isDoubtful() is true when the score is at least the core's SpamScore::MAYBE_SPAM.

The repository

Base\Faq\Repository\QuestionRepository (a ServiceEntityRepository, aliased App\Repository\Faq\QuestionRepository):

Method Returns
findPublished(?Section $section = null, ?int $limit = null) the published questions, by position then id; of one section (through taxa) when given
findFeatured(int $limit = 4) the published questions, featured ones first, then by position and id
findOnePublished(string $slug) the published question of that slug, or null
search(string $term, int $limit = 20) the published questions whose translated title or text contains the term (LIKE %term%)
findSections() the top-level sections (no parent), by position then id, that have a published question or at least one child section
countDrafts() the number of draft questions
findDrafts(int $limit = 5) the oldest drafts first

A sub-section with no published question is still kept by findSections() through its parent; the page then shows only the children that have questions.

Proposer une modification docs/concepts.md

The pages and the form

/faq

FaqController::index() renders @Faq/client/index.html.twig with:

Variable Content
sections findSections(): the top-level sections, each drawn with its first per_section published questions, then each child section that has questions under an h3
orphans the published questions in no section, under "Other questions" (not cut to per_section)
results the search's results when ?q= holds 3 characters or more, null otherwise
term the search term, trimmed
per_section faq.per_section
ask_form the form's view, or null when faq.ask_enabled is false
jsonld the FAQPage of every published question (see below)

A table of contents of the sections (anchors on each section's slug) shows when there are two sections or more. With no section and no orphan question, the page says "No question published yet.".

GET /faq?q=term: with 3 characters or more, QuestionRepository::search() looks for the term in the translated titles and texts of the published questions, 20 at most. The results come first on the page, unfolded (open), under "N answers for "term"". Shorter terms are ignored.

A question's page

/faq/{slug} renders @Faq/client/question.html.twig: the question as h1, its section as a breadcrumb, the answer, then the other published questions of its section (siblings). A slug that matches no published question is a 404. The page's description block is the question's excerpt.

Asking a question

The form (Base\Faq\Form\AskType, translation domain faq) has three fields: question (300 characters at most), details (optional, 2000) and email (optional, "to tell you once the answer is online"). A signed-in visitor's e-mail is filled in and the user kept as the author. The form posts to /faq#demander.

The guard

The form is guarded as the core guards a form (its guard option; the core's manual, "The forms' guard"): FaqController passes action: ask, min_delay: faq.min_delay, and challenge: false when Google reCAPTCHA v3 is on. The guard keeps a trap field, the time between the form being opened and sent, the core's address lists (base.guard.reputation), and a captcha when the site has omnisense/omnishield (the core asks it after a number of refused tries, base.guard.captcha_after). A robot is refused on the form: the page is rendered again with status 422.

With glitchr/ux-google installed and google.recaptcha.enable on (Google\Service\GrService::isEnabled()), the form gets a captcha field (ReCaptchaV3Type) and the guard asks no captcha of its own.

With a core that has no Base\Service\FormGuard (before the forms' guard), AskType adds its own trap (url, hidden) and a timestamp (opened); the controller refuses a form sent less than min_delay seconds after it was opened ("A little fast!"), and thanks a filled trap without keeping anything.

What is kept

A valid form that is not trapped and that Akismet does not doubt (Ask::isDoubtful()) becomes a Question:

  • the title is the question, trimmed and cut to 255 characters;
  • the excerpt is the details;
  • askedBy is the e-mail;
  • the state is DRAFT, the author the signed-in user if any.

It is persisted, and AskNotifier::questionAsked() tells the administrators (The back office). In every case - kept or not - the visitor is redirected to /faq#demander with the flash "Thank you! Your question arrived." (flash bag faq): a doubtful text is thanked and dropped, never queued for moderation.

JSON-LD

Both pages print a <script type="application/ld+json"> from Base\Faq\Service\JsonLd: /faq with every published question (whatever the search shows), a question's page with that question only, each with the page's absolute URL.

JsonLd::page(iterable $questions, ?string $url = null): ?array returns {"@context": "https://schema.org", "@type": "FAQPage", "url": ..., "mainEntity": [...]}, each entry {"@type": "Question", "name": <title>, "acceptedAnswer": {"@type": "Answer", "text": <answer>}}. A question listed twice (same slug) is there once; a question whose title or answer is empty is left out; with no entry left, page() returns null and no script is printed.

JsonLd::answer(?string $content): string turns an answer into what search engines read in one:

  • an EditorJS document (JSON starting with { and holding blocks) becomes HTML: a list block a <ul> or <ol> (style ordered), a header block an <h3>, any other block a <p> of its text; empty blocks are dropped;
  • <script>, <style>, <iframe> and <form> go with their content;
  • only <p> <br> <ul> <ol> <li> <a> <strong> <b> <em> <i> <h2> <h3> <h4> stay, without attributes, except a link's href when it is http(s)://, mailto:, tel:, a path or an anchor;
  • white space is collapsed; tags around nothing give ''.

Run in omnisense/omnibase's harness image, with this checkout's classes on the autoloader:

use Base\Faq\Service\JsonLd;

echo JsonLd::answer(json_encode(['blocks' => [
    ['type' => 'paragraph', 'data' => ['text' => 'Yes, in your own class.']],
    ['type' => 'list', 'data' => ['style' => 'unordered', 'items' => ['Print it', 'Project it']]],
]])), "\n";

echo JsonLd::answer('<p class="lead" onclick="x()">See <a href="/terms" target="_blank">the terms</a>.</p><script>alert(1)</script>'), "\n";

var_dump(JsonLd::answer('<p> </p>'));
<p>Yes, in your own class.</p><ul><li>Print it</li><li>Project it</li></ul>
<p>See <a href="/terms">the terms</a>.</p>
string(0) ""

JsonLd is autowired in the container; the controller takes it as an optional argument and prints no JSON-LD without it.

Proposer une modification docs/usage-pages.md

In a host page

Twig functions

Base\Faq\Twig\FaqExtension, autoconfigured:

Function Returns
faq_questions(section = null, limit = null) QuestionRepository::findPublished(): the published questions, of one Section when given, by position
faq_featured(limit = 4) findFeatured(): the featured questions first, then the others by position
faq_sections() findSections(): the top-level sections that have something to show

The partial

@Faq/client/_questions.html.twig draws a list of questions as <details> elements, each with the id q-<slug>, the answer through |wysiwyg and a link to the question's page. open: true unfolds them.

A short FAQ on a home page:

<section class="faq">
    <h2>Questions</h2>
    {% include '@Faq/client/_questions.html.twig' with {questions: faq_featured(3)} %}
    <a href="{{ path('faq_index') }}">Every question</a>
</section>
<link rel="stylesheet" href="{{ asset('bundles/faq/css/faq.css') }}">

The stylesheet

bundles/faq/css/faq.css styles the .faq block and reads the host's custom properties when they are set:

Property Default Use
--faq-accent #b5411f the accent: the buttons, the summary's marker, hovered links
--faq-soft #6b6b6b secondary text (eyebrow, leads)
--faq-line rgba(127,127,127,.25) borders
--faq-surface rgba(127,127,127,.06) the panels' background
--faq-measure 52rem the block's maximum width

Templates an application overrides

Every template is in the @Faq namespace: client/index.html.twig, client/question.html.twig, client/_questions.html.twig, admin/widget/pending_questions.html.twig. An application overrides one the Symfony way, in templates/bundles/FaqBundle/<path>.

Translations

The domain faq (ICU message format), in English, French and German (translations/faq+intl-icu.{en,fr,de}.yaml): the pages' texts (index.*, question.*), the form (ask.*), the notification (notify.asked) and the back office's labels (admin.*). The templates and the CRUDs refer to them as @faq.<key>.

Proposer une modification docs/usage-twig.md

The back office

The screens are loaded only when omnibase/admin is installed (Base\Admin\Controller\AbstractCrudController exists, config/services.php).

The screens

CRUD Entity Slug First route
Base\Faq\Controller\Admin\Crud\QuestionCrudController Question questions admin_crud_questions_index (/admin/questions)
Base\Faq\Controller\Admin\Crud\SectionCrudController Section sections admin_crud_sections_index (/admin/sections)

omnibase/admin's loader gives each the usual routes (_new, _edit, _detail, _delete, _batchDelete, _toggle...).

QuestionCrudController's fields, in order:

Field Type Notes
id IdField index only
title TextField the question
state StateField draft or published
slug SlugField follows the title as it is typed; not on the index
section SelectField of Section optional
position IntegerField
featured BooleanField
excerpt TextareaField optional: what the visitor added to the question; not shown on the site
content EditorField the answer, with collaborative autosave and live editing (collab_autosave, collab_live)
askedBy TextField optional: the visitor's e-mail

Filters: state and featured. A question created in the back office has the signed-in user as an owner (createEntity()).

SectionCrudController extends omnibase/admin's Base\Admin\Controller\Crud\Thread\TaxonCrudController (the label, description, icon, parent... of a taxon) and adds position.

Who may write

omnibase/admin requires the super-administrator for any action that writes, by default. Both screens open NEW, EDIT, DELETE, BATCH_DELETE and the three save actions to ROLE_ADMIN (Base\Faq\Controller\Admin\OpenToAdminsTrait::openToAdmins()): a site's editors answer the questions.

The dashboard tile

Base\Faq\Admin\Widget\PendingQuestionsWidgetType, named faq_pending_questions (template @Faq/admin/widget/pending_questions.html.twig): the number of draft questions, which links to the questions' list filtered on drafts, and the five oldest drafts, each linking to its edit page. With none: "Everything is answered.". A dashboard adds it as any widget type of omnibase/admin.

The notification

When a visitor's question is kept, Base\Faq\Service\AskNotifier::questionAsked() writes a Base\Entity\User\Notification for every user whose reachable roles include ROLE_ADMIN (through the role hierarchy): title and subject "A question asked: "<the first 80 characters>"", the content the details (240 characters) or the title, the URL the question's edit page (admin_crud_questions_edit). It is sent on the notify channel, the app's bell; no e-mail is sent.

Proposer une modification docs/back-office.md

Configuration reference

faq:

Base\Faq\DependencyInjection\FaqConfiguration, dumped with Symfony's YamlReferenceDumper in omnisense/omnibase's harness image:

faq:

    # The form to ask a question that is missing.
    ask_enabled:          true

    # Questions shown per section.
    per_section:          20

    # Seconds between the form being opened and sent: faster is a robot.
    min_delay:            4
Option Type Default Effect
ask_enabled bool true false: /faq has no form, and a POST to it only renders the page
per_section int, 1 or more 20 the questions drawn per section (and per child section) on /faq; the questions in no section are not cut
min_delay int, 0 or more 4 given to the forms' guard as min_delay; with a core older than the guard, the controller's own check (0 turns it off)

Every key is optional; the file can be left out.

Parameters

The extension sets one container parameter per option (the core's AbstractBaseExtension::setConfiguration()): faq.ask_enabled, faq.per_section, faq.min_delay. FaqController reads them with #[Autowire('%faq.…%')].

Services

config/services.php autowires and autoconfigures every class of src/, except DependencyInjection/, Entity/, Controller/Admin/, Admin/ and the bundle class. The controllers of Controller/Client/ are tagged controller.service_arguments. When omnibase/admin is installed, Controller/Admin/ (tagged controller.service_arguments) and Admin/ (the widget type) are loaded too.

Service Role
Base\Faq\Controller\Client\FaqController the two pages
Base\Faq\Repository\QuestionRepository the queries (Questions and sections)
Base\Faq\Form\AskType the form
Base\Faq\Service\JsonLd the FAQPage
Base\Faq\Service\AskNotifier the administrators' notification
Base\Faq\Twig\FaqExtension the Twig functions
Base\Faq\Admin\Widget\PendingQuestionsWidgetType the dashboard tile (with omnibase/admin)

Optional collaborators of the controller: Google\Service\GrService (glitchr/ux-google), used when present.

Proposer une modification docs/configuration.md

Testing

The suite

vendor/bin/phpunit                                                  # checked out on its own
php vendor/bin/phpunit -c vendor/omnibase/faq/phpunit.xml.dist      # from an application that requires it

tests/bootstrap.php registers the test namespace and this checkout's classes on the autoloader it finds (its own vendor/, the current directory's, or the application's), and the classes of the omnibase/* packages it requires or suggests, read beside the checkout.

In omnisense/omnibase's harness, with this checkout mounted over the package:

docker run --rm --network none -v "$PWD":/srv/demo/vendor/omnibase/faq:ro omnisense/omnibase-harness test faq
..........S.......                                                18 / 18 (100%)

OK, but incomplete, skipped, or risky tests!
Tests: 18, Assertions: 38, Skipped: 1.

What each test checks

  • tests/Form/AskGuardTest.php - the form is guarded by the core's guard instead of its own trap; a question asked as a person asks it goes through; a filled trap, a form sent too fast and a form without the captcha's token are refused; without a captcha the trap and the time alone. The captcha test skips, saying so, where the host asks the captcha only after refused tries (base.guard.captcha_after, 3 by default in the harness).
  • tests/Service/JsonLdTest.php - the questions are a FAQPage; a question without an answer is left out and none gives no page; a question listed twice is there once; an answer keeps paragraphs, lists, links and emphasis only; an EditorJS answer becomes paragraphs and lists; an empty answer.
  • tests/Controller/CrudRequiredFieldsTest.php - on omnibase/admin's CrudFieldsTestCase: only what a record needs is required, an optional field takes an empty value, one field per property, the slug follows the title.

tests/Fixtures/TestQuestion.php is a Question the tests build without a database.

In a site's tests

A functional test posts the form as a visitor would: open /faq, wait at least faq.min_delay seconds (or set it to 0 in the test environment), submit with the guard's fields as rendered. The question is then a draft Question with the e-mail in askedBy, and each administrator has a Notification.

Proposer une modification docs/testing.md