Fix/content page heading #36
No files matched your search
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; } = [];
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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">→</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; } = [];
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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": [],
|
||||
|
||||
Reference in new issue
Block a user