Fix/content page heading #36

Merged
Tom merged 13 commits from fix/content-page-heading into main 2026-09-30 23:18:44 +02:00
14 changed files with 328 additions and 128 deletions
Showing only changes of commit 2ea8856d65 - Show all commits

No files matched your search

+34 -10
View File
@@ -6,7 +6,9 @@ namespace Elternbeirat.Contracts;
/// A single question and answer of the FAQ, as stored in PocketBase.
/// </summary>
/// <remarks>
/// On the FAQ page, entries are grouped by <see cref="Topic"/>.
/// On the FAQ page, entries are grouped by their <see cref="Topic"/>. The topic is
/// a PocketBase relation; <see cref="Topic"/> is the resolved topic record, present
/// only when the query expanded the relation (see <see cref="FaqExpand"/>).
/// </remarks>
public record Faq
{
@@ -42,17 +44,39 @@ public record Faq
public required string Answer { get; init; }
/// <summary>
/// Gets the topic the question is grouped under, e.g.
/// <c>"Mensa und Mittagessen"</c>.
/// Gets the expanded relations returned by PocketBase, or <see langword="null"/>
/// when the query did not expand any.
/// </summary>
[JsonPropertyName("expand")]
public FaqExpand? Expand { get; init; }
/// <summary>
/// Gets the resolved topic this entry is grouped under, or <see langword="null"/>
/// when the query did not expand the topic relation.
/// </summary>
/// <remarks>
/// <see langword="required"/>: the FAQ page groups entries by topic, so an entry
/// without one has no place. The field is required in PocketBase.
///
/// This is the heading shown on the page verbatim, not a slug: editors add a
/// new group by typing its heading here, no code change. Entries with the same
/// topic text land in one group.
/// Convenience over <see cref="Expand"/>: the FAQ page reads the grouping
/// heading from here. It is <see langword="null"/> unless the entry was loaded
/// with the topic relation expanded.
/// </remarks>
[JsonIgnore]
public FaqTopic? Topic => Expand?.Topic;
}
/// <summary>
/// The relations of a <see cref="Faq"/> that PocketBase returns under
/// <c>expand</c> when the query asks for them.
/// </summary>
/// <remarks>
/// One property per expandable relation, named after its PocketBase field, so the
/// JSON <c>expand</c> object maps straight onto it. <see cref="Faq.Topic"/> reads
/// through this.
/// </remarks>
public record FaqExpand
{
/// <summary>
/// Gets the expanded topic of the <c>topic</c> relation, if it was expanded.
/// </summary>
[JsonPropertyName("topic")]
public required string Topic { get; init; }
public FaqTopic? Topic { get; init; }
}
+73
View File
@@ -0,0 +1,73 @@
using System.Text.Json.Serialization;
namespace Elternbeirat.Contracts;
/// <summary>
/// A topic the FAQ entries are grouped under, as stored in PocketBase.
/// </summary>
/// <remarks>
/// A <see cref="Faq"/> points at its topic through a relation, so the FAQ page
/// groups the questions by topic and shows the groups in <see cref="Order"/>.
/// Making the topic its own record (rather than a free-text field on each
/// question) means the heading, its order and its optional <see cref="Intro"/>
/// are edited in one place and shared by every question in the group.
/// </remarks>
public record FaqTopic
{
/// <summary>
/// Gets the PocketBase record id.
/// </summary>
/// <remarks>
/// <see langword="required"/>: every stored record has an id. Modeling it as
/// required states which fields a record must carry, independent of the store,
/// so a future data source has to supply them too.
/// </remarks>
[JsonPropertyName("id")]
public required string Id { get; init; }
/// <summary>
/// Gets the heading shown above the group of questions, e.g.
/// <c>"Mensa und Mittagessen"</c>.
/// </summary>
/// <remarks>
/// Free text; may contain umlauts and spaces. For the URL, see <see cref="Slug"/>.
/// <see langword="required"/>: a topic without a heading has nothing to show, and
/// the field is required in PocketBase.
/// </remarks>
[JsonPropertyName("title")]
public required string Title { get; init; }
/// <summary>
/// Gets the URL slug of the topic, e.g. <c>"mensa"</c>.
/// </summary>
/// <value>
/// A URL path segment: lowercase letters, digits and single hyphens as
/// separators, no umlauts. PocketBase enforces this on save via a field
/// pattern (<c>^[a-z0-9]+(-[a-z0-9]+)*$</c>), so every stored slug is already
/// canonical and the app can use it verbatim.
/// </value>
/// <remarks>
/// <see langword="required"/>: the slug is required in PocketBase and is the
/// stable identifier of a topic, independent of its display title.
/// </remarks>
[JsonPropertyName("slug")]
public required string Slug { get; init; }
/// <summary>
/// Gets the optional introduction shown below the topic heading, as Markdown.
/// </summary>
/// <value>
/// The intro Markdown, or an empty string when the topic has none.
/// </value>
[JsonPropertyName("intro")]
public string Intro { get; init; } = string.Empty;
/// <summary>
/// Gets the sort order of the topic among the FAQ groups.
/// </summary>
/// <value>
/// The sort key; topics with smaller values appear first.
/// </value>
[JsonPropertyName("order")]
public double Order { get; init; }
}
+2 -2
View File
@@ -90,8 +90,8 @@ public record Page
/// Gets the dynamic blocks rendered below the <see cref="Body"/>.
/// </summary>
/// <value>
/// Any of <c>"posts"</c>, <c>"events"</c> and <c>"faqs"</c>, or an empty list
/// for a plain text page.
/// Any of <c>"posts"</c> and <c>"events"</c>, or an empty list for a plain
/// text page.
/// </value>
[JsonPropertyName("embed")]
public IReadOnlyList<string> Embed { get; init; } = [];
+21 -9
View File
@@ -70,15 +70,17 @@ public sealed class PocketBaseClient(HttpClient httpClient)
=> GetRecordsAsync<Event>("events", "start", token);
/// <summary>
/// Gets all public FAQ entries, ordered by <see cref="Faq.Topic"/>, then by
/// the editor's <c>order</c> within each topic.
/// Gets all public FAQ entries, grouped by topic: ordered by the topic's own
/// <c>order</c>, then by the entry's <c>order</c> within that topic. Each entry
/// carries its expanded <see cref="Faq.Topic"/>.
/// </summary>
/// <remarks>
/// The second sort key makes the order total: sorting by topic alone leaves
/// entries within a topic in an undefined order, so the same content could
/// render differently between requests. The per-topic <c>order</c> field lets
/// an editor arrange the questions within a topic and keeps that arrangement
/// stable between requests; the topic groups themselves stay alphabetical.
/// Sorting by <c>topic.order</c> first keeps all questions of a topic together
/// and lists the topic groups in the order an editor gave the topics; the
/// entry's own <c>order</c> then arranges the questions within each group. Both
/// keys together make the order total, so the same content renders the same way
/// between requests. The topic relation is expanded so the page can show each
/// group's heading without a second request.
/// </remarks>
/// <inheritdoc cref="GetPagesAsync" path="/param"/>
/// <returns>
@@ -86,7 +88,7 @@ public sealed class PocketBaseClient(HttpClient httpClient)
/// </returns>
/// <inheritdoc cref="GetPagesAsync" path="/exception"/>
public Task<IReadOnlyList<Faq>> GetFaqsAsync(CancellationToken token = default)
=> GetRecordsAsync<Faq>("faqs", "topic,order", token);
=> GetRecordsAsync<Faq>("faqs", "topic.order,order", token, expand: "topic");
/// <summary>
/// Gets all public records of a collection in a single request.
@@ -103,6 +105,10 @@ public sealed class PocketBaseClient(HttpClient httpClient)
/// <param name="token">
/// A token to cancel the request.
/// </param>
/// <param name="expand">
/// A PocketBase <c>expand</c> expression naming relations to resolve inline
/// (comma-separated, dot-nested), or <see langword="null"/> to expand none.
/// </param>
/// <returns>
/// The public records, or an empty list if there are none.
/// </returns>
@@ -115,7 +121,8 @@ public sealed class PocketBaseClient(HttpClient httpClient)
private async Task<IReadOnlyList<T>> GetRecordsAsync<T>(
string collection,
string sort,
CancellationToken token)
CancellationToken token,
string? expand = null)
{
// filter=public=true keeps drafts out; perPage is large enough to fetch
// every record in a single page given the small content volume.
@@ -123,6 +130,11 @@ public sealed class PocketBaseClient(HttpClient httpClient)
+ $"?perPage=500&filter={Uri.EscapeDataString("public=true")}"
+ $"&sort={Uri.EscapeDataString(sort)}";
// Add expand only when asked, so callers that need no relations send the
// leaner request they did before.
if (expand is not null)
url += $"&expand={Uri.EscapeDataString(expand)}";
try
{
var result = await httpClient.GetFromJsonAsync<RecordList<T>>(url, token);
@@ -18,7 +18,7 @@ public sealed class PocketBaseClientTests(PocketBaseFixture pocketBase)
var events = await client.GetEventsAsync();
// Two seeded events, earliest start first.
events.Select(e => e.Title).ShouldBe(["Elternbeiratssitzung", "Herbstbasar"]);
events.Select(@event => @event.Title).ShouldBe(["Elternbeiratssitzung", "Herbstbasar"]);
}
[Fact]
@@ -46,8 +46,8 @@ public sealed class PocketBaseClientTests(PocketBaseFixture pocketBase)
var events = await client.GetEventsAsync();
var meeting = events.Single(e => e.Title == "Elternbeiratssitzung");
var basar = events.Single(e => e.Title == "Herbstbasar");
var meeting = events.Single(@event => @event.Title == "Elternbeiratssitzung");
var basar = events.Single(@event => @event.Title == "Herbstbasar");
meeting.End.ShouldBeNull(); // no end seeded
basar.End.ShouldNotBeNull(); // end seeded
}
@@ -60,7 +60,7 @@ public sealed class PocketBaseClientTests(PocketBaseFixture pocketBase)
var posts = await client.GetPostsAsync();
// Sorted by -date: March before January.
posts.Select(p => p.Title).ShouldBe(["Neuer Vorstand", "Neue Sporthalle"]);
posts.Select(post => post.Title).ShouldBe(["Neuer Vorstand", "Neue Sporthalle"]);
}
[Fact]
@@ -85,9 +85,9 @@ public sealed class PocketBaseClientTests(PocketBaseFixture pocketBase)
var pages = await client.GetPagesAsync();
// The draft (public=false) must be filtered out, the public ones present.
pages.ShouldNotContain(p => p.Slug == "draft");
pages.ShouldContain(p => p.Slug == "home");
pages.ShouldContain(p => p.Slug == "contact");
pages.ShouldNotContain(page => page.Slug == "draft");
pages.ShouldContain(page => page.Slug == "home");
pages.ShouldContain(page => page.Slug == "contact");
}
[Fact]
@@ -111,7 +111,10 @@ public sealed class PocketBaseClientTests(PocketBaseFixture pocketBase)
var faqs = await client.GetFaqsAsync();
faqs.Select(f => f.Topic).ShouldBe(["Mensa und Mittagessen", "Schließfächer"], ignoreOrder: true);
faqs.ShouldAllBe(f => !string.IsNullOrWhiteSpace(f.Question));
// The topic relation is expanded, so each entry carries its topic. The two
// seeded topics are ordered (Mensa order 1 before Schließfächer order 2), and
// GetFaqsAsync sorts by topic.order, so the groups come back in that order.
faqs.Select(faq => faq.Topic?.Title).ShouldBe(["Mensa und Mittagessen", "Schließfächer"]);
faqs.ShouldAllBe(faq => !string.IsNullOrWhiteSpace(faq.Question));
}
}
+30 -4
View File
@@ -313,23 +313,49 @@ public sealed class PocketBaseFixture : IAsyncLifetime
title = "Herbstbasar", location = "Schulhof", note = "", @public = true,
});
// Faqs: two topics.
// Faq topics: two, ordered. The topics own the grouping and the order the FAQ
// page shows the groups in (Mensa before Schließfächer).
var lunchTopicId = await CreateRecordAsync(http, "faq_topics", new
{
title = "Mensa und Mittagessen", slug = "mensa", intro = "",
order = 1, @public = true,
});
var lockerTopicId = await CreateRecordAsync(http, "faq_topics", new
{
title = "Schließfächer", slug = "schliessfach", intro = "",
order = 2, @public = true,
});
// Faqs: each points at its topic through the "topic" relation (the created
// topic id), and carries its own order within the topic.
await CreateRecordAsync(http, "faqs", new
{
question = "Wann gibt es Mittagessen?", answer = "Um 12 Uhr.",
topic = "Mensa und Mittagessen", @public = true,
topic = lunchTopicId, order = 1, @public = true,
});
await CreateRecordAsync(http, "faqs", new
{
question = "Wie viel kostet ein Schließfach?", answer = "20 Euro.",
topic = "Schließfächer", @public = true,
topic = lockerTopicId, order = 1, @public = true,
});
}
private static async Task CreateRecordAsync(HttpClient http, string collection, object record)
private static async Task<string> CreateRecordAsync(HttpClient http, string collection, object record)
{
var response = await http.PostAsJsonAsync($"/api/collections/{collection}/records", record);
response.EnsureSuccessStatusCode();
// Return the created record's id so a caller can seed a relation to it (e.g. a
// faq pointing at its topic). Callers that do not need it just ignore the value.
var created = await response.Content.ReadFromJsonAsync<CreatedRecord>();
return created?.Id ?? throw new InvalidOperationException(
$"PocketBase returned no id for the created '{collection}' record.");
}
private sealed record CreatedRecord
{
[System.Text.Json.Serialization.JsonPropertyName("id")]
public string Id { get; init; } = string.Empty;
}
private sealed record AuthResponse
@@ -1,16 +0,0 @@
@if (_faqs.Count > 0)
{
<section class="embed embed-faqs">
<h2>Häufige Fragen</h2>
@foreach (var faq in _faqs)
{
<details class="faq-item">
<summary>@faq.Question</summary>
<div class="faq-answer">
@((MarkupString)Markdown.ToHtml(faq.Answer))
</div>
</details>
}
<p class="embed-more"><a href="/faqs">Alle Fragen <span aria-hidden="true">&rarr;</span></a></p>
</section>
}
@@ -1,46 +0,0 @@
using Elternbeirat.Contracts;
using Elternbeirat.PocketBase;
using Microsoft.AspNetCore.Components;
namespace Elternbeirat.Web.Features.Embeds;
public partial class FaqsEmbed
{
// Number of questions shown in the teaser; the full hub is one link away.
private const int TeaserCount = 3;
[LoggerMessage(
Level = LogLevel.Warning,
Message = "Could not load FAQs for an embed from PocketBase; the teaser is omitted.")]
private static partial void LogSourceUnavailable(
ILogger logger,
Exception exception);
[Inject]
private PocketBaseClient PocketBase { get; set; } = null!;
[Inject]
private ILogger<FaqsEmbed> Logger { get; set; } = null!;
[CascadingParameter]
private HttpContext? HttpContext { get; set; }
private IReadOnlyList<Faq> _faqs = [];
protected override async Task OnInitializedAsync()
{
try
{
// GetFaqsAsync is ordered by topic, then question, so the first entries
// the teaser takes are stable across requests.
var faqs = await PocketBase.GetFaqsAsync(HttpContext?.RequestAborted ?? CancellationToken.None);
_faqs = [.. faqs.Take(TeaserCount)];
}
catch (PocketBaseUnavailableException exception)
{
// Only part of the page fails: omit the teaser, keep the page (and its
// 200) intact. A missing teaser is degraded, not broken.
LogSourceUnavailable(Logger, exception);
}
}
}
@@ -1,5 +0,0 @@
/* Card styles for .faq-item are shared with the full FAQ page and live in the
global app.css. Only the embed's spacing on the home page is specific here. */
.embed-faqs {
margin-top: var(--space-5);
}
@@ -12,14 +12,11 @@
case "events":
<EventsEmbed />
break;
case "faqs":
<FaqsEmbed />
break;
}
}
@code {
/// <summary>The page's embed keys ("posts", "events", "faqs"), in order.</summary>
/// <summary>The page's embed keys ("posts", "events"), in order.</summary>
[Parameter]
public IReadOnlyList<string> Embed { get; set; } = [];
}
+1 -1
View File
@@ -17,7 +17,7 @@ else
@foreach (var group in _groups)
{
<section class="faq-topic">
<h2>@group.Key</h2>
<h2>@group.Key.Title</h2>
@foreach (var faq in group)
{
<details class="faq-item">
@@ -23,7 +23,7 @@ public partial class FaqList
[CascadingParameter]
private HttpContext? HttpContext { get; set; }
private IReadOnlyList<IGrouping<string, Faq>> _groups = [];
private IReadOnlyList<IGrouping<FaqTopic, Faq>> _groups = [];
// Set when the content source could not be reached or read. The markup then
// shows a "temporarily unavailable" note instead of "no questions", matching
@@ -35,7 +35,12 @@ public partial class FaqList
try
{
var faqs = await PocketBase.GetFaqsAsync(HttpContext?.RequestAborted ?? CancellationToken.None);
_groups = [.. faqs.GroupBy(faq => faq.Topic)];
// GetFaqsAsync sorts by topic.order first, so grouping in encounter order
// keeps the topic groups in the order an editor gave the topics. Entries
// without an expanded topic have no group and are skipped rather than
// grouped under a null heading.
_groups = [.. faqs.Where(faq => faq.Topic is not null).GroupBy(faq => faq.Topic!)];
}
catch (PocketBaseUnavailableException exception)
{
+17 -2
View File
@@ -27,11 +27,19 @@ migrate((app) => {
,
events: [{"end":"","location":"Aula","note":"Gäste sind willkommen. Um Anmeldung im Sekretariat wird gebeten.","public":true,"start":"2026-10-08 19:30:00.000Z","title":"Elternbeiratssitzung"},{"end":"2026-10-20 18:30:00.000Z","location":"Klassenräume","note":"Terminvergabe über das Schulportal.","public":true,"start":"2026-10-20 16:00:00.000Z","title":"Elternsprechtag"},{"end":"2026-11-22 13:00:00.000Z","location":"Schulhof","note":"Kuchenspenden willkommen.","public":true,"start":"2026-11-22 09:00:00.000Z","title":"Herbstbasar"},{"end":"2027-01-06 00:00:00.000Z","location":"","note":"","public":true,"start":"2026-12-19 00:00:00.000Z","title":"Weihnachtsferien"},{"end":"","location":"Mensa","note":"Kennenlernen bei Butterbrezeln und Kaffee.","public":true,"start":"2026-09-10 18:00:00.000Z","title":"Willkommensabend der neuen Fünftklässler"}]
,
faqs: [{"answer":"Die Anmeldung läuft über das Bestellsystem der Schule. Die Zugangsdaten erhalten Sie zu Schuljahresbeginn über die Klassenleitung.","public":true,"question":"Wie melde ich mein Kind zum Mittagessen an?","topic":"Mensa und Mittagessen"},{"answer":"Ein Mittagessen kostet aktuell **4,20 €**. Für Familien mit Anspruch auf Leistungen nach dem Bildungs- und Teilhabepaket ist das Essen kostenfrei – sprechen Sie das Sekretariat an.","public":true,"question":"Was kostet ein Mittagessen?","topic":"Mensa und Mittagessen"},{"answer":"Ja, täglich steht mindestens ein vegetarisches Gericht zur Wahl.","public":true,"question":"Gibt es vegetarische Gerichte?","topic":"Mensa und Mittagessen"},{"answer":"Schließfächer werden über einen externen Anbieter gemietet. Das Anmeldeformular liegt bei den [Downloads](/downloads).","public":true,"question":"Wie bekommt mein Kind ein Schließfach?","topic":"Schließfächer"},{"answer":"Die Miete beträgt rund 15 € pro Schuljahr zuzüglich einer Kaution, die bei Rückgabe erstattet wird.","public":true,"question":"Was kostet die Miete?","topic":"Schließfächer"},{"answer":"Wenden Sie sich an den Anbieter; die Kontaktdaten stehen auf dem Mietvertrag. Bei Fragen hilft auch der [Elternbeirat](/contact).","public":true,"question":"Der Schlüssel ist verloren – was nun?","topic":"Schließfächer"},{"answer":"Der Eltern-Euro ist ein **freiwilliger** Beitrag, mit dem kleinere Anschaffungen für die Klassen unkompliziert finanziert werden – etwa Bastelmaterial oder Ausflüge.","public":true,"question":"Was ist der Eltern-Euro?","topic":"Eltern-Euro"},{"answer":"Nein. Der Beitrag ist ausdrücklich freiwillig, und niemand wird bei Nichtzahlung benachteiligt.","public":true,"question":"Ist der Beitrag verpflichtend?","topic":"Eltern-Euro"},{"answer":"Ob Schulfest, Basar oder eine helfende Hand bei Ausflügen – über jede Unterstützung freuen wir uns. Melden Sie sich einfach über die [Kontaktseite](/contact).","public":true,"question":"Wie kann ich mich engagieren?","topic":"Elternarbeit und Mitmachen"},{"answer":"Nein. Viele Aufgaben erledigen Eltern, die einfach mithelfen möchten, ganz ohne Amt.","public":true,"question":"Muss ich in den Elternbeirat gewählt sein, um zu helfen?","topic":"Elternarbeit und Mitmachen"}]
// Topics own the grouping and the order of the FAQ page; a question links to
// its topic below via the "topic" relation, resolved by slug at seed time.
faq_topics: [{"title":"Elternarbeit","slug":"elternarbeit","intro":"","order":1,"public":true},{"title":"Eltern-Euro","slug":"eltern-euro","intro":"","order":2,"public":true},{"title":"Mensa und Mittagessen","slug":"mensa","intro":"","order":3,"public":true},{"title":"Schließfächer","slug":"schliessfach","intro":"","order":4,"public":true}]
,
// "topic" is a slug here, not a relation id: the record loader below resolves it
// to the faq_topics id, since the ids are only known once the topics are saved.
faqs: [{"answer":"Die Anmeldung läuft über das Bestellsystem der Schule. Die Zugangsdaten erhalten Sie zu Schuljahresbeginn über die Klassenleitung.","public":true,"question":"Wie melde ich mein Kind zum Mittagessen an?","topic":"mensa","order":1},{"answer":"Ein Mittagessen kostet aktuell **4,20 €**. Für Familien mit Anspruch auf Leistungen nach dem Bildungs- und Teilhabepaket ist das Essen kostenfrei – sprechen Sie das Sekretariat an.","public":true,"question":"Was kostet ein Mittagessen?","topic":"mensa","order":2},{"answer":"Ja, täglich steht mindestens ein vegetarisches Gericht zur Wahl.","public":true,"question":"Gibt es vegetarische Gerichte?","topic":"mensa","order":3},{"answer":"Schließfächer werden über einen externen Anbieter gemietet. Das Anmeldeformular liegt bei den [Downloads](/downloads).","public":true,"question":"Wie bekommt mein Kind ein Schließfach?","topic":"schliessfach","order":1},{"answer":"Die Miete beträgt rund 15 € pro Schuljahr zuzüglich einer Kaution, die bei Rückgabe erstattet wird.","public":true,"question":"Was kostet die Miete?","topic":"schliessfach","order":2},{"answer":"Wenden Sie sich an den Anbieter; die Kontaktdaten stehen auf dem Mietvertrag. Bei Fragen hilft auch der [Elternbeirat](/contact).","public":true,"question":"Der Schlüssel ist verloren – was nun?","topic":"schliessfach","order":3},{"answer":"Der Eltern-Euro ist ein **freiwilliger** Beitrag, mit dem kleinere Anschaffungen für die Klassen unkompliziert finanziert werden – etwa Bastelmaterial oder Ausflüge.","public":true,"question":"Was ist der Eltern-Euro?","topic":"eltern-euro","order":1},{"answer":"Nein. Der Beitrag ist ausdrücklich freiwillig, und niemand wird bei Nichtzahlung benachteiligt.","public":true,"question":"Ist der Beitrag verpflichtend?","topic":"eltern-euro","order":2},{"answer":"Ob Schulfest, Basar oder eine helfende Hand bei Ausflügen – über jede Unterstützung freuen wir uns. Melden Sie sich einfach über die [Kontaktseite](/contact).","public":true,"question":"Wie kann ich mich engagieren?","topic":"elternarbeit","order":1},{"answer":"Nein. Viele Aufgaben erledigen Eltern, die einfach mithelfen möchten, ganz ohne Amt.","public":true,"question":"Muss ich in den Elternbeirat gewählt sein, um zu helfen?","topic":"elternarbeit","order":2}]
,
}
// Phase 1: schema. Create any collection that is not present yet.
// Phase 1: schema. Create any collection that is not present yet. The schema
// file lists faq_topics before faqs, so the target of the faqs "topic" relation
// exists by the time faqs is created.
for (const name of Object.keys(schema)) {
try {
app.findCollectionByNameOrId(name)
@@ -42,13 +50,20 @@ migrate((app) => {
// Phase 2: records. Fill a collection only while it is still empty, so a
// populated volume (or, should this ever run there, production) is untouched.
// faqs are seeded after faq_topics (object key order), so the slug->id map is
// populated before a faq resolves its "topic" against it.
const faqTopicIdBySlug = {}
for (const [name, rows] of Object.entries(records)) {
if (app.countRecords(name) > 0) continue
const collection = app.findCollectionByNameOrId(name)
for (const data of rows) {
// A faq's "topic" is a topic slug in this seed; swap it for the real id.
if (name === "faqs") data = { ...data, topic: faqTopicIdBySlug[data.topic] }
const record = new Record(collection)
record.load(data)
app.save(record)
// Remember each topic's id so the faqs seeded afterwards can point at it.
if (name === "faq_topics") faqTopicIdBySlug[data.slug] = record.id
}
}
}, (app) => {
+130 -18
View File
@@ -309,7 +309,7 @@
"help": "",
"hidden": false,
"id": "select1203373331",
"maxSelect": 3,
"maxSelect": 2,
"name": "embed",
"presentable": false,
"required": false,
@@ -317,8 +317,7 @@
"type": "select",
"values": [
"posts",
"events",
"faqs"
"events"
]
},
{
@@ -337,6 +336,107 @@
"updated": "2026-09-23 11:25:35.648Z",
"system": false
},
"faq_topics": {
"id": "pbc_529028860",
"listRule": "",
"viewRule": "",
"createRule": null,
"updateRule": null,
"deleteRule": null,
"name": "faq_topics",
"type": "base",
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"help": "",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text724990059",
"max": 0,
"min": 0,
"name": "title",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text2560465762",
"max": 0,
"min": 0,
"name": "slug",
"pattern": "^[a-z0-9]+(-[a-z0-9]+)*$",
"presentable": false,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text436585760",
"max": 0,
"min": 0,
"name": "intro",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"help": "",
"hidden": false,
"id": "number4113142680",
"max": null,
"min": null,
"name": "order",
"onlyInt": true,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"help": "",
"hidden": false,
"id": "bool1001664029",
"name": "public",
"presentable": false,
"required": false,
"system": false,
"type": "bool"
}
],
"indexes": [
"CREATE UNIQUE INDEX `idx_faq_topics_slug` ON `faq_topics` (`slug`)"
],
"created": "2026-09-23 11:59:13.899Z",
"updated": "2026-09-23 11:59:13.899Z",
"system": false
},
"faqs": {
"id": "pbc_2923026773",
"listRule": "",
@@ -392,21 +492,6 @@
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "select2638274075",
"max": 0,
"min": 0,
"name": "topic",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
},
{
"help": "",
"hidden": false,
@@ -416,6 +501,33 @@
"required": false,
"system": false,
"type": "bool"
},
{
"cascadeDelete": false,
"collectionId": "pbc_529028860",
"help": "",
"hidden": false,
"id": "relation3340346911",
"maxSelect": 1,
"minSelect": 0,
"name": "topic",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
},
{
"help": "",
"hidden": false,
"id": "number4113142680",
"max": null,
"min": null,
"name": "order",
"onlyInt": true,
"presentable": false,
"required": false,
"system": false,
"type": "number"
}
],
"indexes": [],