Skip to main content

sofie_engine/catalog/
spec.rs

1use super::Kind;
2
3#[derive(Clone, Copy, Debug, PartialEq, Eq)]
4pub struct ComponentSpec {
5    pub name: &'static str,
6    pub summary: &'static str,
7    pub children: Children,
8    pub props: &'static [PropSpec],
9    /// The engine feature an app enables to use it, if it is not always there.
10    pub feature: Option<&'static str>,
11}
12
13#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14pub enum Children {
15    None,
16    Any,
17    /// `Field` and `Submit`, in the order they are shown.
18    FormParts,
19    /// Rendered once per row, with the row named by `as`.
20    RowContent,
21}
22
23#[derive(Clone, Copy, Debug, PartialEq, Eq)]
24pub struct PropSpec {
25    pub name: &'static str,
26    pub kind: PropKind,
27    pub required: bool,
28    pub summary: &'static str,
29}
30
31#[derive(Clone, Copy, Debug, PartialEq, Eq)]
32pub enum PropKind {
33    /// Quoted text, `{…}`, or quoted text with `{…}` parts.
34    Text,
35    /// A path into the data, such as `{data.orders}`.
36    Data,
37    /// Quoted text without `{…}`.
38    Literal,
39    /// Quoted field names separated by spaces.
40    Names,
41    /// A URL posted to, quoted with `{…}` parts for its parameters.
42    Post,
43    /// One value: `{…}` or quoted text, not text with `{…}` parts.
44    Value,
45    /// Written bare, without a value.
46    Flag,
47    /// An element id: a letter, then letters, digits, `-` and `_`.
48    Id,
49    /// Named values, `{{name: value, …}}`.
50    Fields,
51}
52
53impl PropKind {
54    #[must_use]
55    pub fn describe(self) -> &'static str {
56        match self {
57            Self::Text => "text, `{…}` or text with `{…}` parts",
58            Self::Data => "a data path such as `{data.items}`",
59            Self::Literal => "quoted text without `{…}`",
60            Self::Names => "quoted field names separated by spaces",
61            Self::Post => "a URL, quoted, with `{…}` parts for its parameters",
62            Self::Value => "one value: `{…}` or quoted text",
63            Self::Flag => "written bare, without a value",
64            Self::Id => "an element id",
65            Self::Fields => "`{{name: value, …}}`, each value a path, a string or an integer",
66        }
67    }
68}
69
70const fn prop(name: &'static str, kind: PropKind, summary: &'static str) -> PropSpec {
71    PropSpec {
72        name,
73        kind,
74        required: false,
75        summary,
76    }
77}
78
79const fn required(name: &'static str, kind: PropKind, summary: &'static str) -> PropSpec {
80    PropSpec {
81        name,
82        kind,
83        required: true,
84        summary,
85    }
86}
87
88const ID: PropSpec = prop(
89    "id",
90    PropKind::Id,
91    "Names the element, so a fragment request or a target finds it.",
92);
93const TARGET: PropSpec = prop(
94    "target",
95    PropKind::Id,
96    "The id of the element the answer replaces; without it the whole page is shown.",
97);
98
99const SECTION: ComponentSpec = ComponentSpec {
100    name: "Section",
101    summary: "A titled part of a page holding any markup and components.",
102    children: Children::Any,
103    props: &[required("title", PropKind::Text, "The heading."), ID],
104    feature: None,
105};
106
107const TABLE: ComponentSpec = ComponentSpec {
108    name: "Table",
109    summary: "Rows of a list, one column per field; money fields are formatted. Its content, \
110              if any, is rendered in a last cell of every row, such as an `Action`.",
111    children: Children::RowContent,
112    props: &[
113        required(
114            "id",
115            PropKind::Id,
116            "Names the table, so actions and listings replace it.",
117        ),
118        required("of", PropKind::Data, "The list of rows."),
119        required(
120            "columns",
121            PropKind::Names,
122            "The fields shown, in order; each header is the field's label.",
123        ),
124        prop(
125            "as",
126            PropKind::Literal,
127            "Names the row for the content, such as `as=\"order\"` and then `{order.id}`.",
128        ),
129        prop(
130            "link",
131            PropKind::Text,
132            "Where each row links to, with `{…}` parts naming the row, such as \
133             `link=\"/orders/{order.id}\"` with `as=\"order\"`; the first column's cell becomes \
134             the link.",
135        ),
136        prop(
137            "sort",
138            PropKind::Names,
139            "Columns the viewer may sort by (needs the `listing` feature and a listing loader).",
140        ),
141        prop(
142            "filter",
143            PropKind::Names,
144            "Fields the viewer may filter by (needs the `listing` feature and a listing loader).",
145        ),
146    ],
147    feature: None,
148};
149
150const MONEY: ComponentSpec = ComponentSpec {
151    name: "Money",
152    summary: "An amount, `{minor, currency}`, formatted in the viewer's language.",
153    children: Children::None,
154    props: &[required("value", PropKind::Data, "The amount."), ID],
155    feature: None,
156};
157
158const ACTION: ComponentSpec = ComponentSpec {
159    name: "Action",
160    summary: "A button posting to an action route, such as cancelling or booking the row it \
161              sits in.",
162    children: Children::None,
163    props: &[
164        required("post", PropKind::Post, "The action's URL."),
165        required("label", PropKind::Text, "The button's text."),
166        TARGET,
167        prop(
168            "when",
169            PropKind::Value,
170            "Shown only when this is truthy, such as `{order.status_to.CANCELLED}`.",
171        ),
172        prop(
173            "with",
174            PropKind::Fields,
175            "Fields it posts, read by the route's input like a form's, such as \
176             `{{slot: slot.id}}` for the row it sits in.",
177        ),
178        ID,
179    ],
180    feature: None,
181};
182
183const FORM: ComponentSpec = ComponentSpec {
184    name: "Form",
185    summary: "A form posting to an action route; its fields come from the route's input type \
186              unless `Field`s name them.",
187    children: Children::FormParts,
188    props: &[
189        required(
190            "id",
191            PropKind::Literal,
192            "The form's element id, so errors are placed under its fields.",
193        ),
194        required("post", PropKind::Post, "The action's URL."),
195        TARGET,
196    ],
197    feature: None,
198};
199
200const FIELD: ComponentSpec = ComponentSpec {
201    name: "Field",
202    summary: "One field of a `Form`, its control chosen from the input type's field.",
203    children: Children::None,
204    props: &[
205        required("name", PropKind::Literal, "The input field it edits."),
206        prop("label", PropKind::Text, "Replaces the field's label."),
207        prop("value", PropKind::Value, "The value it starts with."),
208        prop(
209            "options",
210            PropKind::Value,
211            "A select's options: a list from the data, or a quoted registered enum name.",
212        ),
213        prop("currency", PropKind::Value, "A money field's currency."),
214        prop(
215            "required",
216            PropKind::Flag,
217            "Marks it required when the input type does not say so.",
218        ),
219    ],
220    feature: None,
221};
222
223const SUBMIT: ComponentSpec = ComponentSpec {
224    name: "Submit",
225    summary: "The button sending a `Form`.",
226    children: Children::None,
227    props: &[required("label", PropKind::Text, "The button's text.")],
228    feature: None,
229};
230
231const BADGE: ComponentSpec = ComponentSpec {
232    name: "Badge",
233    summary: "An enum value shown as its translated variant with the variant's tone.",
234    children: Children::None,
235    props: &[
236        required("value", PropKind::Data, "The enum value."),
237        required(
238            "enum",
239            PropKind::Literal,
240            "The registered enum, such as `order_status`.",
241        ),
242        ID,
243    ],
244    feature: Some("i18n"),
245};
246
247const GRID: ComponentSpec = ComponentSpec {
248    name: "Grid",
249    summary: "Lays its children out in responsive columns.",
250    children: Children::Any,
251    props: &[ID],
252    feature: Some("layout"),
253};
254
255const CARD: ComponentSpec = ComponentSpec {
256    name: "Card",
257    summary: "A boxed group of content, a link as a whole when it has `href`.",
258    children: Children::Any,
259    props: &[prop("href", PropKind::Text, "Where the card links to."), ID],
260    feature: Some("layout"),
261};
262
263const IMAGE: ComponentSpec = ComponentSpec {
264    name: "Image",
265    summary: "An image, loaded lazily.",
266    children: Children::None,
267    props: &[
268        required("src", PropKind::Text, "The image's URL."),
269        required(
270            "alt",
271            PropKind::Text,
272            "What the image shows, for screen readers.",
273        ),
274        ID,
275    ],
276    feature: Some("layout"),
277};
278
279const ISLAND: ComponentSpec = ComponentSpec {
280    name: "Island",
281    summary: "A Svelte component from the module's `islands/<name>.svelte`, mounted as a \
282              custom element with typed props.",
283    children: Children::None,
284    props: &[
285        required(
286            "name",
287            PropKind::Literal,
288            "The island's name, lowercase with a hyphen.",
289        ),
290        required(
291            "props",
292            PropKind::Value,
293            "The props, usually `{data}` or a part of it.",
294        ),
295        ID,
296    ],
297    feature: Some("islands"),
298};
299
300const MARKDOWN: ComponentSpec = ComponentSpec {
301    name: "Markdown",
302    summary: "Markdown rendered without raw HTML.",
303    children: Children::None,
304    props: &[
305        required("text", PropKind::Data, "The markdown, from data."),
306        ID,
307    ],
308    feature: Some("content"),
309};
310
311const HEAD: ComponentSpec = ComponentSpec {
312    name: "Head",
313    summary: "The page's title, description, canonical URL and link-preview tags, moved into \
314              `<head>`.",
315    children: Children::None,
316    props: &[
317        prop("title", PropKind::Text, "The page title."),
318        prop(
319            "description",
320            PropKind::Text,
321            "The description search engines show.",
322        ),
323        prop("image", PropKind::Text, "The link-preview image's URL."),
324        prop("url", PropKind::Text, "The canonical URL."),
325        prop(
326            "type",
327            PropKind::Text,
328            "The Open Graph type, such as `article`.",
329        ),
330    ],
331    feature: Some("content"),
332};
333
334/// Every component, whichever features this build enables.
335pub const SPECS: &[ComponentSpec] = &[
336    SECTION, TABLE, MONEY, ACTION, FORM, FIELD, SUBMIT, BADGE, GRID, CARD, IMAGE, ISLAND, MARKDOWN,
337    HEAD,
338];
339
340impl Kind {
341    pub(crate) fn spec(self) -> &'static ComponentSpec {
342        SPECS
343            .iter()
344            .find(|spec| spec.name == self.name())
345            .expect("every kind has a spec, as `every_kind_has_a_spec` checks")
346    }
347}
348
349#[cfg(test)]
350mod tests {
351    use super::SPECS;
352    use crate::catalog::Kind;
353
354    #[test]
355    fn every_kind_has_a_spec() {
356        for kind in Kind::ALL {
357            kind.spec();
358        }
359    }
360
361    #[test]
362    fn names_and_props_are_unique() {
363        for (index, spec) in SPECS.iter().enumerate() {
364            assert!(
365                SPECS[..index].iter().all(|other| other.name != spec.name),
366                "{}",
367                spec.name
368            );
369            for (position, prop) in spec.props.iter().enumerate() {
370                assert!(
371                    spec.props[..position]
372                        .iter()
373                        .all(|other| other.name != prop.name),
374                    "{}.{}",
375                    spec.name,
376                    prop.name
377                );
378            }
379        }
380    }
381}