Skip to main content

ytcli/render/
queue.rs

1//! Queue listings and field tables.
2
3use std::fmt::Write as _;
4
5use crate::api::{
6    Automation, Component, FieldSpec, Holder, Permission, Queue, QueueAccess, QueueField,
7    QueueSettings, Template, Unreadable,
8};
9use crate::render::Context;
10use crate::render::style::Palette;
11use crate::render::table::{Column, render, tally};
12
13/// One line per queue: key first, because the key is what every other command
14/// takes.
15#[must_use]
16pub fn queues(queues: &[Queue], ctx: &Context) -> String {
17    let columns = [
18        Column::whole("KEY", 12, Palette::key()),
19        Column::whole("NAME", 28, anstyle::Style::new()),
20        Column::whole("LEAD", 20, anstyle::Style::new()),
21    ];
22    let rows: Vec<Vec<String>> = queues
23        .iter()
24        .map(|queue| {
25            vec![
26                queue.key.clone(),
27                queue.name.clone(),
28                queue.lead.as_deref().unwrap_or("-").to_owned(),
29            ]
30        })
31        .collect();
32
33    let mut out = render(&columns, &rows, ctx);
34    out.push_str(&tally(queues.len(), Some(queues.len() as u64), None, ctx));
35    out
36}
37
38/// Field keys, types and names.
39///
40/// The key column comes first and is the point of the command: it is what
41/// `--fields` and `--set` accept, and without it a caller is guessing. Custom
42/// fields are marked, since those are the ones that differ per queue and are
43/// therefore the ones worth pinning in a profile.
44#[must_use]
45pub fn fields(fields: &[QueueField], ctx: &Context) -> String {
46    let columns = [
47        Column::whole("KEY", 28, Palette::key()),
48        Column::whole("TYPE", 12, anstyle::Style::new()),
49        // Custom fields are the reason to run this command, so they are the ones
50        // that stand out.
51        Column::by_value("ORIGIN", 8, |origin| {
52            if origin == "custom" {
53                Palette::warn()
54            } else {
55                Palette::label()
56            }
57        }),
58        Column::whole("NAME", 30, anstyle::Style::new()),
59    ];
60    let rows: Vec<Vec<String>> = fields
61        .iter()
62        .map(|field| {
63            vec![
64                field.key.clone(),
65                field.field_type.clone(),
66                if field.system { "system" } else { "custom" }.to_owned(),
67                field.name.clone(),
68            ]
69        })
70        .collect();
71
72    let mut out = render(&columns, &rows, ctx);
73
74    let custom = fields.iter().filter(|field| !field.system).count();
75    let paint = ctx.painter();
76    let _ = writeln!(
77        out,
78        "{}",
79        paint.paint(
80            &format!(
81                "shown {} of {} ({custom} custom)",
82                fields.len(),
83                fields.len()
84            ),
85            Palette::label()
86        )
87    );
88    out
89}
90
91/// How many values a constrained field lists before the rest are counted
92/// rather than printed.
93///
94/// A field with two hundred options is a real thing, and printing all of them
95/// by default makes a command nobody runs twice.
96const VALUES_SHOWN: usize = 20;
97
98/// One field: what it holds, whether it can be written, and what it accepts.
99///
100/// The last of those is the reason the command exists. `--set` is otherwise
101/// written blind and judged by Tracker, which answers with the field's name in
102/// the organisation's language and no hint of what it wanted instead.
103#[must_use]
104pub fn field_spec(field: &FieldSpec, all: bool, ctx: &Context) -> String {
105    let mut out = String::with_capacity(240);
106    let paint = ctx.painter();
107    let label = |text: &str| paint.paint(text, Palette::label());
108    let yes_no = |flag: bool| if flag { "yes" } else { "no" };
109
110    let _ = writeln!(
111        out,
112        "{}  {}",
113        paint.paint(&field.key, Palette::key()),
114        field.name
115    );
116
117    // `type: array` on its own says nothing a caller can act on; what the
118    // elements are is the part that decides whether `--set` takes one value or
119    // a list.
120    let kind = match &field.items {
121        Some(items) => format!("{} of {items}", field.field_type),
122        None => field.field_type.clone(),
123    };
124    let _ = writeln!(
125        out,
126        "{} {kind}   {} {}   {} {}",
127        label("type:"),
128        label("required:"),
129        yes_no(field.required),
130        label("readonly:"),
131        yes_no(field.readonly),
132    );
133    if let Some(category) = &field.category {
134        let _ = writeln!(out, "{} {category}", label("category:"));
135    }
136
137    out.push_str(&values(field, all, ctx));
138    out
139}
140
141/// The values half of `field get`.
142fn values(field: &FieldSpec, all: bool, ctx: &Context) -> String {
143    let paint = ctx.painter();
144    let label = |text: &str| paint.paint(text, Palette::label());
145
146    let Some(options) = &field.options else {
147        return format!("{} anything of that type\n", label("values:"));
148    };
149
150    if options.values.is_empty() {
151        // A provider with no list is not an empty field: the values exist, they
152        // are just kept somewhere this endpoint does not reach. Naming the
153        // command that does reach them is the whole use of knowing the
154        // provider's name.
155        return match provider_source(&options.provider) {
156            Some((what, command)) => format!("{} {what} — {command}\n", label("values:")),
157            None => format!(
158                "{} decided by {} — not listed by this endpoint\n",
159                label("values:"),
160                options.provider
161            ),
162        };
163    }
164
165    let mut out = String::with_capacity(64 + options.values.len() * 12);
166    let shown = if all {
167        options.values.len()
168    } else {
169        options.values.len().min(VALUES_SHOWN)
170    };
171    let _ = writeln!(
172        out,
173        "{} {}",
174        label("values:"),
175        options.values[..shown].join(", ")
176    );
177    let _ = writeln!(
178        out,
179        "{}",
180        paint.paint(
181            &if shown < options.values.len() {
182                format!(
183                    "shown {shown} of {} values; --all for the rest",
184                    options.values.len()
185                )
186            } else {
187                format!("shown {shown} of {shown} values")
188            },
189            Palette::label()
190        )
191    );
192    out
193}
194
195/// Which command answers what a provider will accept, and what it holds.
196///
197/// Tracker names a class; a caller wants a command. Only the providers whose
198/// answer this tool can actually fetch are mapped — an unrecognised one is
199/// passed through by name rather than guessed at, because a wrong command here
200/// costs a request and reads like a bug.
201fn provider_source(provider: &str) -> Option<(&'static str, &'static str)> {
202    Some(match provider {
203        "TeamOptionsProvider" => ("people in the organisation", "ytcli user list"),
204        "QueueOptionsProvider" => ("queue keys", "ytcli queue list"),
205        "IssueTypeOptionsProvider" => ("issue types", "ytcli dict list --kind types"),
206        "PriorityOptionsProvider" => ("priorities", "ytcli dict list --kind priorities"),
207        "StatusOptionsProvider" => ("statuses", "ytcli dict list --kind statuses"),
208        "ResolutionOptionsProvider" => ("resolutions", "ytcli dict list --kind resolutions"),
209        "VersionOptionsProvider" => ("versions of the queue", "ytcli queue versions PROJ"),
210        "TagOptionsProvider" => ("tags in use in the queue", "ytcli queue tags PROJ"),
211        "SprintOptionsProvider" => ("sprints", "ytcli sprint list"),
212        "BoardOptionsProvider" => ("boards", "ytcli board list"),
213        "ProjectOptionsProvider" => ("projects", "ytcli project list"),
214        "MetaEntityOptionsProvider" => ("goals", "ytcli goal list"),
215        _ => return None,
216    })
217}
218
219/// The fields a queue defines itself.
220///
221/// Carries what each accepts, which the organisation-wide `field get` cannot
222/// answer for these: a local field is not reachable through `/v3/fields`, so if
223/// this listing does not say, nothing does.
224#[must_use]
225pub fn local_fields(queue: &str, fields: &[FieldSpec], ctx: &Context) -> String {
226    let columns = [
227        Column::whole("KEY", 24, Palette::key()),
228        Column::whole("TYPE", 10, anstyle::Style::new()),
229        Column::new("NAME", 24, anstyle::Style::new()),
230        Column::new("ACCEPTS", 28, Palette::label()),
231    ];
232    let rows: Vec<Vec<String>> = fields
233        .iter()
234        .map(|field| {
235            vec![
236                field.key.clone(),
237                match &field.items {
238                    Some(items) => format!("[{items}]"),
239                    None => field.field_type.clone(),
240                },
241                field.name.clone(),
242                accepts(field),
243            ]
244        })
245        .collect();
246
247    let mut out = render(&columns, &rows, ctx);
248    out.push_str(&counted(queue, fields.len(), ctx));
249    out
250}
251
252/// One cell's worth of what a field accepts.
253fn accepts(field: &FieldSpec) -> String {
254    match &field.options {
255        None => "anything of that type".to_owned(),
256        // The command, not the sentence: a cell has no room for prose, and the
257        // command is the half a caller can run.
258        Some(options) if options.values.is_empty() => provider_source(&options.provider)
259            .map_or_else(
260                || options.provider.clone(),
261                |(_, command)| command.to_owned(),
262            ),
263        Some(options) => options.values.join(", "),
264    }
265}
266
267/// The components a queue splits its work by.
268///
269/// The name leads, because the name is what `--set components=…` takes; the id
270/// is beside it for the payloads that want one. `AUTO` is there because a
271/// component that assigns automatically changes what a write does, which is not
272/// something to discover afterwards.
273#[must_use]
274pub fn components(components: &[Component], scope: Option<&str>, ctx: &Context) -> String {
275    let columns = [
276        Column::new("NAME", 28, Palette::key()),
277        Column::whole("ID", 8, anstyle::Style::new()),
278        Column::whole("QUEUE", 12, anstyle::Style::new()),
279        Column::new("LEAD", 20, anstyle::Style::new()),
280        Column::by_value("AUTO", 6, |value| {
281            if value == "yes" {
282                Palette::warn()
283            } else {
284                Palette::label()
285            }
286        }),
287    ];
288
289    let rows: Vec<Vec<String>> = components
290        .iter()
291        .map(|component| {
292            vec![
293                component.name.clone(),
294                component.id.clone(),
295                component.queue.clone().unwrap_or_else(|| "-".to_owned()),
296                component.lead.clone().unwrap_or_else(|| "-".to_owned()),
297                if component.assign_auto { "yes" } else { "no" }.to_owned(),
298            ]
299        })
300        .collect();
301
302    let mut out = render(&columns, &rows, ctx);
303    out.push_str(&match scope {
304        Some(queue) => counted(queue, components.len(), ctx),
305        None => tally(components.len(), Some(components.len() as u64), None, ctx),
306    });
307    out
308}
309
310/// The versions a queue defines.
311#[must_use]
312pub fn versions(queue: &str, versions: &[crate::api::Version], ctx: &Context) -> String {
313    let columns = [
314        Column::whole("ID", 10, Palette::key()),
315        Column::new("NAME", 28, anstyle::Style::new()),
316        // A released version and an archived one are both "not open", and
317        // which of the two decides whether new work may still point at it.
318        Column::by_value("STATE", 10, |state| match state {
319            "open" => Palette::ok(),
320            _ => Palette::label(),
321        }),
322        Column::whole("DUE", 12, anstyle::Style::new()),
323    ];
324
325    let rows: Vec<Vec<String>> = versions
326        .iter()
327        .map(|version| {
328            vec![
329                version.id.clone(),
330                version.name.clone(),
331                version.state.to_owned(),
332                version.due.clone().unwrap_or_else(|| "-".to_owned()),
333            ]
334        })
335        .collect();
336
337    let mut out = render(&columns, &rows, ctx);
338    out.push_str(&counted(queue, versions.len(), ctx));
339    out
340}
341
342/// The tags in use in a queue.
343#[must_use]
344pub fn tags(queue: &str, tags: &[String], ctx: &Context) -> String {
345    let columns = [Column::whole("TAG", 30, Palette::key())];
346    let rows: Vec<Vec<String>> = tags.iter().map(|tag| vec![tag.clone()]).collect();
347
348    let mut out = render(&columns, &rows, ctx);
349    out.push_str(&counted(queue, tags.len(), ctx));
350    out
351}
352
353/// `shown N of N for QUEUE` — neither endpoint pages, so both numbers are the
354/// same one, and saying so is still better than leaving the reader to wonder.
355fn counted(queue: &str, count: usize, ctx: &Context) -> String {
356    let paint = ctx.painter();
357    format!(
358        "{}\n",
359        paint.paint(
360            &format!("shown {count} of {count} for {queue}"),
361            Palette::label()
362        )
363    )
364}
365
366/// What changes issues in a queue without anybody touching them.
367///
368/// Three sections under their own headings, and the sections Tracker refused
369/// named with its reason. A queue member cannot read triggers, and printing
370/// nothing for them would say "no triggers", which is a different and wrong
371/// answer.
372#[must_use]
373pub fn automation(queue: &str, automation: &Automation, ctx: &Context) -> String {
374    let mut out = String::with_capacity(320);
375    out.push_str(&macros_section(queue, automation, ctx));
376    out.push('\n');
377    out.push_str(&autoactions_section(queue, automation, ctx));
378    out.push('\n');
379    out.push_str(&triggers_section(queue, automation, ctx));
380    out
381}
382
383fn heading(text: &str, ctx: &Context) -> String {
384    format!("{}\n", ctx.painter().paint(text, Palette::heading()))
385}
386
387/// Canned changes somebody applies by hand.
388fn macros_section(queue: &str, automation: &Automation, ctx: &Context) -> String {
389    let rows: Vec<Vec<String>> = automation
390        .macros
391        .iter()
392        .map(|entry| {
393            vec![
394                entry.id.clone(),
395                entry.name.clone(),
396                actions(&entry.updates),
397                // The body is a comment somebody else wrote, so it is counted
398                // rather than printed: this is a listing, and the text belongs
399                // in the interface that runs it.
400                if entry.body.is_some() { "yes" } else { "no" }.to_owned(),
401            ]
402        })
403        .collect();
404
405    let mut out = heading("macros", ctx);
406    out.push_str(&render(
407        &[
408            Column::whole("ID", 8, Palette::key()),
409            Column::new("NAME", 30, anstyle::Style::new()),
410            Column::new("SETS", 24, anstyle::Style::new()),
411            Column::whole("COMMENTS", 8, Palette::label()),
412        ],
413        &rows,
414        ctx,
415    ));
416    out.push_str(&closing(
417        queue,
418        "macros",
419        rows.len(),
420        &automation.unreadable,
421        ctx,
422    ));
423    out
424}
425
426/// Changes Tracker applies on a schedule to whatever matches a filter.
427fn autoactions_section(queue: &str, automation: &Automation, ctx: &Context) -> String {
428    let rows: Vec<Vec<String>> = automation
429        .autoactions
430        .iter()
431        .map(|entry| {
432            vec![
433                entry.id.clone(),
434                entry.name.clone(),
435                active(entry.active).to_owned(),
436                entry
437                    .interval
438                    .map_or_else(|| "-".to_owned(), |seconds| format!("{seconds}s")),
439                actions(&entry.actions),
440            ]
441        })
442        .collect();
443
444    let mut out = heading("autoactions", ctx);
445    out.push_str(&render(
446        &[
447            Column::whole("ID", 8, Palette::key()),
448            Column::new("NAME", 28, anstyle::Style::new()),
449            Column::by_value("ACTIVE", 8, state_colour),
450            Column::whole("EVERY", 10, anstyle::Style::new()),
451            Column::new("DOES", 22, anstyle::Style::new()),
452        ],
453        &rows,
454        ctx,
455    ));
456    out.push_str(&closing(
457        queue,
458        "autoactions",
459        rows.len(),
460        &automation.unreadable,
461        ctx,
462    ));
463    out
464}
465
466/// Changes Tracker applies the moment something happens to an issue.
467fn triggers_section(queue: &str, automation: &Automation, ctx: &Context) -> String {
468    let rows: Vec<Vec<String>> = automation
469        .triggers
470        .iter()
471        .map(|entry| {
472            vec![
473                entry.id.clone(),
474                entry.name.clone(),
475                active(entry.active).to_owned(),
476                entry.conditions.to_string(),
477                actions(&entry.actions),
478            ]
479        })
480        .collect();
481
482    let mut out = heading("triggers", ctx);
483    out.push_str(&render(
484        &[
485            Column::whole("ID", 8, Palette::key()),
486            Column::new("NAME", 28, anstyle::Style::new()),
487            Column::by_value("ACTIVE", 8, state_colour),
488            Column::whole("WHEN", 10, Palette::label()),
489            Column::new("DOES", 22, anstyle::Style::new()),
490        ],
491        &rows,
492        ctx,
493    ));
494    out.push_str(&closing(
495        queue,
496        "triggers",
497        rows.len(),
498        &automation.unreadable,
499        ctx,
500    ));
501    out
502}
503
504/// The last line of a section: a tally, or why there is nothing to tally.
505///
506/// A refused section must not end with `shown 0 of 0`. That reads as "there are
507/// none", which is a different answer from "you may not see them" and the one
508/// mistake this command exists to avoid.
509fn closing(
510    queue: &str,
511    section: &str,
512    count: usize,
513    unreadable: &[Unreadable],
514    ctx: &Context,
515) -> String {
516    match unreadable.iter().find(|refusal| refusal.section == section) {
517        Some(refusal) => {
518            let paint = ctx.painter();
519            format!(
520                "{}\n",
521                paint.paint(
522                    &format!("not readable — {}", refusal.reason),
523                    Palette::warn()
524                )
525            )
526        }
527        None => counted(queue, count, ctx),
528    }
529}
530
531/// Who may do what in a queue.
532///
533/// Two tables because the two answers are not the same one twice. The first is
534/// the rule, roles and all; the second is the people it comes out as, and only
535/// the second can say whether the caller is among them.
536#[must_use]
537pub fn access(queue: &str, access: &QueueAccess, ctx: &Context) -> String {
538    let mut out = String::with_capacity(320);
539    out.push_str(&permissions_section(queue, access, ctx));
540    out.push('\n');
541    out.push_str(&access_section(queue, access, ctx));
542    out
543}
544
545/// The rule as somebody configured it.
546fn permissions_section(queue: &str, access: &QueueAccess, ctx: &Context) -> String {
547    let rows: Vec<Vec<String>> = access
548        .permissions
549        .iter()
550        .map(|entry| {
551            vec![
552                entry.operation.clone(),
553                granted_to(entry),
554                holders(&entry.users),
555            ]
556        })
557        .collect();
558
559    let mut out = heading("permissions", ctx);
560    out.push_str(&render(
561        &[
562            Column::whole("OPERATION", 14, Palette::key()),
563            Column::new("ROLES", 34, anstyle::Style::new()),
564            Column::new("USERS", 28, anstyle::Style::new()),
565        ],
566        &rows,
567        ctx,
568    ));
569    out.push_str(&closing(
570        queue,
571        "permissions",
572        rows.len(),
573        &access.unreadable,
574        ctx,
575    ));
576
577    // Without this the table looks like the whole answer, and a caller who is
578    // in none of its user lists concludes they are locked out — while being the
579    // assignee of every issue they care about.
580    if rows.iter().any(|row| row[1] != "-") {
581        let paint = ctx.painter();
582        let _ = writeln!(
583            out,
584            "{}",
585            paint.paint(
586                "a role is decided per issue: `assignee` is whoever that issue names",
587                Palette::label()
588            )
589        );
590    }
591    out
592}
593
594/// The people the rule comes out as.
595fn access_section(queue: &str, access: &QueueAccess, ctx: &Context) -> String {
596    let rows: Vec<Vec<String>> = access
597        .access
598        .iter()
599        .map(|entry| {
600            vec![
601                entry.operation.clone(),
602                holds(entry, access.you.as_deref()).to_owned(),
603                holders(&entry.users),
604            ]
605        })
606        .collect();
607
608    let mut out = heading("access", ctx);
609    out.push_str(&render(
610        &[
611            Column::whole("OPERATION", 14, Palette::key()),
612            Column::by_value("YOU", 5, |held| match held {
613                "yes" => Palette::ok(),
614                "no" => Palette::warn(),
615                _ => Palette::label(),
616            }),
617            Column::new("USERS", 44, anstyle::Style::new()),
618        ],
619        &rows,
620        ctx,
621    ));
622    out.push_str(&closing(
623        queue,
624        "access",
625        rows.len(),
626        &access.unreadable,
627        ctx,
628    ));
629    out
630}
631
632/// Whether the token's own user holds an operation.
633///
634/// `?` is not `no`. It means the user behind the token could not be read, and
635/// answering "no" to a question that was never asked is the failure this whole
636/// command is against.
637fn holds(permission: &Permission, you: Option<&str>) -> &'static str {
638    match you {
639        Some(id) if permission.users.iter().any(|holder| holder.id == id) => "yes",
640        Some(_) => "no",
641        None => "?",
642    }
643}
644
645/// Roles, and groups where a queue names any.
646///
647/// Groups share the column because they are the same kind of answer — a right
648/// held by something other than a named person — and no queue reachable from
649/// here has one to widen a column for.
650fn granted_to(permission: &Permission) -> String {
651    // Groups lead because the column truncates and the roles are the
652    // predictable half: `queue-lead, assignee, author` says less about a
653    // particular queue than one named group does.
654    let mut names: Vec<String> = permission
655        .groups
656        .iter()
657        .map(|group| format!("group:{}", group.display))
658        .collect();
659    names.extend(permission.roles.iter().map(|role| role.id.clone()));
660
661    if names.is_empty() {
662        "-".to_owned()
663    } else {
664        names.join(", ")
665    }
666}
667
668/// A count, then as many names as fit.
669///
670/// The count leads because the column truncates: a cell cut off mid-name would
671/// otherwise hide how many more there were, and `--format json` is the way to
672/// the rest.
673fn holders(list: &[Holder]) -> String {
674    if list.is_empty() {
675        return "-".to_owned();
676    }
677    let names: Vec<&str> = list.iter().map(|holder| holder.display.as_str()).collect();
678    format!("{}: {}", list.len(), names.join(", "))
679}
680
681fn active(flag: bool) -> &'static str {
682    if flag { "on" } else { "off" }
683}
684
685fn state_colour(state: &str) -> anstyle::Style {
686    if state == "on" {
687        Palette::ok()
688    } else {
689        Palette::label()
690    }
691}
692
693fn actions(actions: &[String]) -> String {
694    if actions.is_empty() {
695        "-".to_owned()
696    } else {
697        actions.join(", ")
698    }
699}
700
701/// One queue and the defaults an issue created in it starts with.
702#[must_use]
703pub fn settings(queue: &QueueSettings, ctx: &Context) -> String {
704    let mut out = String::with_capacity(200);
705    let paint = ctx.painter();
706    let label = |text: &str| paint.paint(text, Palette::label());
707
708    let _ = writeln!(
709        out,
710        "{}  {}",
711        paint.paint(&queue.key, Palette::key()),
712        queue.name
713    );
714    let _ = writeln!(
715        out,
716        "{} {}   {} {}   {} {}",
717        label("lead:"),
718        queue.lead.as_deref().unwrap_or("-"),
719        label("default type:"),
720        queue.default_type.as_deref().unwrap_or("-"),
721        label("default priority:"),
722        queue.default_priority.as_deref().unwrap_or("-"),
723    );
724
725    out
726}
727
728/// Issue or comment templates.
729///
730/// The id leads because it is what a caller passes on; the queue follows,
731/// because a template that belongs to a queue only applies there.
732#[must_use]
733pub fn templates(templates: &[Template], ctx: &Context) -> String {
734    let columns = [
735        Column::whole("ID", 12, Palette::key()),
736        Column::new("NAME", 36, anstyle::Style::new()),
737        Column::whole("QUEUE", 12, anstyle::Style::new()),
738        Column::new("AUTHOR", 18, Palette::label()),
739    ];
740    let rows: Vec<Vec<String>> = templates
741        .iter()
742        .map(|template| {
743            vec![
744                template.id.clone(),
745                template.name.clone(),
746                template.queue.as_deref().unwrap_or("-").to_owned(),
747                template.author.as_deref().unwrap_or("-").to_owned(),
748            ]
749        })
750        .collect();
751
752    let mut out = render(&columns, &rows, ctx);
753    out.push_str(&tally(
754        templates.len(),
755        Some(templates.len() as u64),
756        None,
757        ctx,
758    ));
759    out
760}
761
762#[cfg(test)]
763#[allow(clippy::expect_used)]
764mod tests {
765    use super::*;
766
767    fn ctx() -> Context {
768        Context {
769            format: crate::render::Format::Text,
770            audience: crate::render::Audience::Machine,
771            description_lines: Some(10),
772            extra_fields: Vec::new(),
773            width: 80,
774            images: false,
775            inline: crate::render::image::Inline::default(),
776        }
777    }
778
779    #[test]
780    fn field_listing_marks_custom_fields_and_counts_them() {
781        let listing = fields(
782            &[
783                QueueField {
784                    key: "summary".to_owned(),
785                    name: "Summary".to_owned(),
786                    field_type: "string".to_owned(),
787                    system: true,
788                },
789                QueueField {
790                    key: "storyPoints".to_owned(),
791                    name: "Story points".to_owned(),
792                    field_type: "number".to_owned(),
793                    system: false,
794                },
795            ],
796            &ctx(),
797        );
798
799        assert!(listing.contains("summary"));
800        assert!(listing.contains("system"));
801        assert!(listing.contains("storyPoints"));
802        assert!(listing.ends_with("shown 2 of 2 (1 custom)\n"));
803    }
804}