@cat-factory/app 0.293.0 → 0.294.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/app/components/board/AddTaskModal.vue +80 -0
  2. package/app/components/board/RecurringPipelineModal.vue +34 -20
  3. package/app/components/bootstrap/BootstrapModal.logic.spec.ts +43 -0
  4. package/app/components/bootstrap/BootstrapModal.logic.ts +28 -0
  5. package/app/components/bootstrap/BootstrapModal.vue +82 -15
  6. package/app/components/bugFishing/BugFishingWindow.vue +524 -0
  7. package/app/components/focus/BlockFocusView.vue +2 -0
  8. package/app/components/github/RepoTreeBrowser.vue +89 -6
  9. package/app/components/layout/NotificationsInbox.vue +15 -0
  10. package/app/components/panels/ResultWindowDrafts.logic.spec.ts +25 -3
  11. package/app/components/panels/ResultWindowShell.logic.spec.ts +4 -0
  12. package/app/components/settings/WorkspaceSettingsPanel.vue +46 -1
  13. package/app/components/slack/SlackPanel.vue +1 -0
  14. package/app/composables/api/bugFishing.ts +52 -0
  15. package/app/composables/useApi.ts +2 -0
  16. package/app/composables/usePipelineErrorToast.ts +21 -0
  17. package/app/modular/result-views.ts +6 -0
  18. package/app/stores/bugFishing.ts +143 -0
  19. package/app/stores/ui/resultViews.ts +14 -7
  20. package/app/stores/ui/runStepOpeners.ts +29 -1
  21. package/app/stores/workspaceSettings.ts +1 -0
  22. package/app/types/execution.ts +9 -0
  23. package/app/utils/catalog.spec.ts +1 -0
  24. package/app/utils/catalog.ts +25 -0
  25. package/app/utils/repoPath.spec.ts +49 -0
  26. package/app/utils/repoPath.ts +28 -0
  27. package/i18n/locales/de.json +103 -6
  28. package/i18n/locales/en.json +102 -5
  29. package/i18n/locales/es.json +103 -6
  30. package/i18n/locales/fr.json +103 -6
  31. package/i18n/locales/he.json +103 -6
  32. package/i18n/locales/it.json +103 -6
  33. package/i18n/locales/ja.json +103 -6
  34. package/i18n/locales/pl.json +103 -6
  35. package/i18n/locales/tr.json +103 -6
  36. package/i18n/locales/uk.json +103 -6
  37. package/package.json +2 -2
@@ -22,6 +22,7 @@ import type {
22
22
  TaskTypeFields,
23
23
  } from '~/types/domain'
24
24
  import { DOC_KINDS, DOC_KIND_FIELDS } from '~/types/domain'
25
+ import { BUG_FISHING_PHASES } from '@cat-factory/contracts'
25
26
  import { resolveComponentRegistry } from '@modular-vue/core'
26
27
  import { useReactiveSlots } from '@modular-vue/runtime'
27
28
  import type { AppSlots, ResultViewContribution } from '~/modular/slots'
@@ -97,6 +98,11 @@ const TASK_TYPES = computed<{ value: TaskTypeChoice; label: string; icon: string
97
98
  const all: { value: TaskTypeChoice; label: string; icon: string }[] = [
98
99
  { value: 'feature', label: t('board.addTask.types.feature'), icon: 'i-lucide-sparkles' },
99
100
  { value: 'bug', label: t('board.addTask.types.bug'), icon: 'i-lucide-bug' },
101
+ {
102
+ value: 'bug-fishing',
103
+ label: t('board.addTask.types.bugFishing'),
104
+ icon: 'i-lucide-fish',
105
+ },
100
106
  { value: 'document', label: t('board.addTask.types.document'), icon: 'i-lucide-file-text' },
101
107
  { value: 'spike', label: t('board.addTask.types.spike'), icon: 'i-lucide-flask-conical' },
102
108
  {
@@ -127,6 +133,11 @@ const isRecurring = computed(() => taskType.value === 'recurring')
127
133
  const severity = ref<'low' | 'medium' | 'high' | 'critical' | ''>('')
128
134
  const stepsToReproduce = ref('')
129
135
  const timeboxHours = ref<number | undefined>(undefined)
136
+ // Bug-fishing expedition: which ANGLES to fish (empty ⇒ every shipped angle, the intended
137
+ // default — an expedition exists to cover ground nobody thought to look at, so narrowing it is
138
+ // the deliberate act) plus an optional focus folded into every angle's prompt.
139
+ const fishingPhaseIds = ref<string[]>([])
140
+ const fishingFocus = ref('')
130
141
  // Spike research criteria — folded into the spike agent's prompt (see the backend `spike` kind).
131
142
  const spikeResearchQuestion = ref('')
132
143
  const spikeSuccessCriteria = ref('')
@@ -284,6 +295,24 @@ function buildCustomTypeFields(): TaskTypeFields | undefined {
284
295
  return Object.keys(bag).length ? { custom: bag } : undefined
285
296
  }
286
297
 
298
+ /**
299
+ * The bug-fishing expedition's creation fields. Both NARROW a hunt that otherwise covers every
300
+ * angle, so both are omitted when they narrow nothing: an empty selection and "every angle" are
301
+ * the same run, and storing the full catalog would freeze today's angle list onto a task that
302
+ * runs next quarter.
303
+ *
304
+ * Its own function rather than another arm of {@link buildTypeFields}, whose per-type chain is at
305
+ * its complexity ceiling — a budget is a split trigger, not a number to raise.
306
+ */
307
+ function buildBugFishingFields(): TaskTypeFields | undefined {
308
+ const f: TaskTypeFields = {}
309
+ if (fishingPhaseIds.value.length && fishingPhaseIds.value.length < BUG_FISHING_PHASES.length) {
310
+ f.fishingPhaseIds = [...fishingPhaseIds.value]
311
+ }
312
+ if (fishingFocus.value.trim()) f.fishingFocus = fishingFocus.value.trim()
313
+ return Object.keys(f).length ? f : undefined
314
+ }
315
+
287
316
  function buildTypeFields(): TaskTypeFields | undefined {
288
317
  if (taskType.value === 'bug') {
289
318
  const f: TaskTypeFields = {}
@@ -291,6 +320,7 @@ function buildTypeFields(): TaskTypeFields | undefined {
291
320
  if (stepsToReproduce.value.trim()) f.stepsToReproduce = stepsToReproduce.value.trim()
292
321
  return Object.keys(f).length ? f : undefined
293
322
  }
323
+ if (taskType.value === 'bug-fishing') return buildBugFishingFields()
294
324
  if (taskType.value === 'spike') {
295
325
  const f: TaskTypeFields = {}
296
326
  // `v-model.number` on a cleared number input yields '' (not undefined), which would
@@ -420,6 +450,7 @@ const DEFAULT_PIPELINE_FOR_TYPE: Partial<Record<TaskTypeChoice, string>> = {
420
450
  document: 'pl_document',
421
451
  review: 'pl_review',
422
452
  media: 'pl_media',
453
+ 'bug-fishing': 'pl_bug_fishing',
423
454
  }
424
455
  /**
425
456
  * The pipeline a task type opens with: a custom type's registered `defaultPipelineId`, else the
@@ -564,6 +595,8 @@ watch(open, (isOpen) => {
564
595
  severity.value = ''
565
596
  stepsToReproduce.value = ''
566
597
  timeboxHours.value = undefined
598
+ fishingPhaseIds.value = []
599
+ fishingFocus.value = ''
567
600
  spikeResearchQuestion.value = ''
568
601
  spikeSuccessCriteria.value = ''
569
602
  spikeOptionsToCompare.value = ''
@@ -1027,6 +1060,53 @@ function openReviewFrictionDialog(conflict: NonNullable<ReturnType<typeof parseC
1027
1060
  </UFormField>
1028
1061
  </div>
1029
1062
 
1063
+ <!-- Bug-fishing expedition. Both fields NARROW a hunt that otherwise covers every
1064
+ angle, which is why neither is required and why the angle list is rendered as
1065
+ opt-OUT checkboxes rather than an empty multi-select: leaving it alone has to
1066
+ mean "fish everything", not "fish nothing". -->
1067
+ <div v-else-if="taskType === 'bug-fishing'" class="space-y-3">
1068
+ <UFormField
1069
+ :label="t('board.addTask.bugFishingFields.angles.label')"
1070
+ :hint="t('board.addTask.optional')"
1071
+ :description="t('board.addTask.bugFishingFields.angles.hint')"
1072
+ >
1073
+ <div class="grid gap-1.5 sm:grid-cols-2">
1074
+ <label
1075
+ v-for="phase in BUG_FISHING_PHASES"
1076
+ :key="phase.id"
1077
+ class="flex items-start gap-2 rounded-md px-1.5 py-1 text-[12px] hover:bg-slate-800/40"
1078
+ >
1079
+ <input
1080
+ v-model="fishingPhaseIds"
1081
+ type="checkbox"
1082
+ :value="phase.id"
1083
+ class="mt-0.5 accent-sky-500"
1084
+ :data-testid="`add-task-fishing-angle-${phase.id}`"
1085
+ />
1086
+ <span class="min-w-0">
1087
+ <span class="block text-slate-200">{{ phase.title }}</span>
1088
+ <span class="block text-[11px] text-slate-500">{{ phase.goal }}</span>
1089
+ </span>
1090
+ </label>
1091
+ </div>
1092
+ <p v-if="fishingPhaseIds.length === 0" class="mt-1.5 text-[11px] text-slate-500">
1093
+ {{ t('board.addTask.bugFishingFields.angles.allSelected') }}
1094
+ </p>
1095
+ </UFormField>
1096
+ <UFormField
1097
+ :label="t('board.addTask.bugFishingFields.focus.label')"
1098
+ :hint="t('board.addTask.optional')"
1099
+ >
1100
+ <UTextarea
1101
+ v-model="fishingFocus"
1102
+ :rows="2"
1103
+ autoresize
1104
+ :placeholder="t('board.addTask.bugFishingFields.focus.placeholder')"
1105
+ class="w-full"
1106
+ />
1107
+ </UFormField>
1108
+ </div>
1109
+
1030
1110
  <div v-else-if="taskType === 'spike'" class="space-y-3">
1031
1111
  <UFormField :label="t('board.addTask.timebox')">
1032
1112
  <UInput
@@ -139,17 +139,43 @@ const selectablePipelines = computed(() =>
139
139
  )
140
140
  const selectedPipeline = computed(() => pipelines.getPipeline(pipelineId.value))
141
141
 
142
+ /**
143
+ * Whether a pipeline RUNS a step of this kind: present in its `agentKinds` AND not disabled.
144
+ *
145
+ * The three questions this modal asks about the picked pipeline (does it fish, does it file a
146
+ * ticket, does it pull from the tracker) are the same question about three kinds, and each of
147
+ * them has to honour the disabled half: a step somebody turned off imposes nothing, so demanding
148
+ * its configuration would block a schedule on a step that will not run.
149
+ */
150
+ function hasEnabledStep(
151
+ pipeline: { agentKinds: string[]; enabled?: boolean[] } | null | undefined,
152
+ kind: string,
153
+ ): boolean {
154
+ if (!pipeline) return false
155
+ return pipeline.agentKinds.some((k, i) => k === kind && pipeline.enabled?.[i] !== false)
156
+ }
157
+
142
158
  // Infer the template from the picked pipeline so the backend seeds the right block
143
159
  // description (and so we know to show the tracker config).
144
160
  //
145
161
  // Only the pipelines whose SHAPE is specific to one kind of recurring work can be inferred this
146
- // way, and `bug-triage` is now the only one: `dep-update` and `tech-debt` were both retired from
147
- // the catalog (the first was the ordinary build tail under a recurring name, the second that tail
148
- // behind an audit head), so those schedules now run an ordinary build rung — which is also what
149
- // every generic schedule runs, so inferring a template from it would mislabel all of them. Both
150
- // templates survive for an explicit API caller; see `scheduleTemplateSchema`.
162
+ // way. `dep-update` and `tech-debt` were both retired from the catalog (the first was the
163
+ // ordinary build tail under a recurring name, the second that tail behind an audit head), so
164
+ // those schedules now run an ordinary build rung — which is also what every generic schedule
165
+ // runs, so inferring a template from it would mislabel all of them. Both templates survive for an
166
+ // explicit API caller; see `scheduleTemplateSchema`.
167
+ //
168
+ // The expedition is read off its own step KIND rather than off `pl_bug_fishing`'s id, for the
169
+ // reason `filesTicket` below states: an id keys on the one preset that ships today and misses
170
+ // every pipeline a workspace composes around the same step, and the whole seed description is
171
+ // about what a `bug-fisher` pass does.
172
+ const isBugFishing = computed(() => hasEnabledStep(selectedPipeline.value, 'bug-fisher'))
151
173
  const template = computed<ScheduleTemplate>(() =>
152
- pipelineId.value === 'pl_bug_triage' ? 'bug-triage' : 'custom',
174
+ isBugFishing.value
175
+ ? 'bug-fishing'
176
+ : pipelineId.value === 'pl_bug_triage'
177
+ ? 'bug-triage'
178
+ : 'custom',
153
179
  )
154
180
  /**
155
181
  * Whether the picked pipeline FILES a ticket (an enabled `tracker` step), so the schedule's first
@@ -159,24 +185,12 @@ const template = computed<ScheduleTemplate>(() =>
159
185
  * `analysis` + `tracker` head. Keying on the id would have offered the tracker config to exactly
160
186
  * the one pipeline that no longer exists, and to none of the pipelines that now do this work.
161
187
  */
162
- const filesTicket = computed(() => {
163
- const pipeline = selectedPipeline.value
164
- if (!pipeline) return false
165
- return pipeline.agentKinds.some(
166
- (kind, i) => kind === 'tracker' && pipeline.enabled?.[i] !== false,
167
- )
168
- })
188
+ const filesTicket = computed(() => hasEnabledStep(selectedPipeline.value, 'tracker'))
169
189
 
170
190
  // A pipeline whose ENABLED steps include `bug-intake` pulls its work from the tracker board, so
171
191
  // the intake config is surfaced + required. Mirrors the backend `pipelineHasEnabledBugIntake`
172
192
  // (a disabled step imposes nothing), so the modal doesn't demand config for a step that won't run.
173
- const isBugIntake = computed(() => {
174
- const pipeline = selectedPipeline.value
175
- if (!pipeline) return false
176
- return pipeline.agentKinds.some(
177
- (kind, i) => kind === 'bug-intake' && pipeline.enabled?.[i] !== false,
178
- )
179
- })
193
+ const isBugIntake = computed(() => hasEnabledStep(selectedPipeline.value, 'bug-intake'))
180
194
  /**
181
195
  * Whether the intake section is shown, and in which DISPATCH mode — both DERIVED from the picked
182
196
  * pipeline rather than chosen, because the two modes are not interchangeable:
@@ -0,0 +1,43 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ serviceDirectoryLeaf,
4
+ serviceDirectoryParent,
5
+ } from '~/components/bootstrap/BootstrapModal.logic'
6
+
7
+ // The rule these two pin is what makes the field browsable AND typable at once: the tree hands
8
+ // back the folder it was standing in plus the leaf, so a name someone typed has to survive a
9
+ // trip through the tree. Reading the leaf off the service name instead would silently discard it.
10
+
11
+ describe('serviceDirectoryLeaf', () => {
12
+ it('is the typed path’s own last segment, not the service name', () => {
13
+ expect(serviceDirectoryLeaf('services/billing', 'payments')).toBe('billing')
14
+ })
15
+
16
+ it('treats a bare name as the leaf (a directory at the repo root)', () => {
17
+ expect(serviceDirectoryLeaf('billing', 'payments')).toBe('billing')
18
+ })
19
+
20
+ it('survives a trailing slash rather than reading as an empty leaf', () => {
21
+ expect(serviceDirectoryLeaf('services/billing/', 'payments')).toBe('billing')
22
+ })
23
+
24
+ it('falls back to the service name while the field is still blank', () => {
25
+ expect(serviceDirectoryLeaf('', 'payments')).toBe('payments')
26
+ expect(serviceDirectoryLeaf(' ', ' payments ')).toBe('payments')
27
+ })
28
+
29
+ it('is empty when neither is known, so the tree can say it has nothing to place', () => {
30
+ expect(serviceDirectoryLeaf('', '')).toBe('')
31
+ })
32
+ })
33
+
34
+ describe('serviceDirectoryParent', () => {
35
+ it('is the folder the typed path sits in', () => {
36
+ expect(serviceDirectoryParent('packages/services/billing')).toBe('packages/services')
37
+ })
38
+
39
+ it('is the repo root for a bare name', () => {
40
+ expect(serviceDirectoryParent('billing')).toBe('')
41
+ expect(serviceDirectoryParent('')).toBe('')
42
+ })
43
+ })
@@ -0,0 +1,28 @@
1
+ import { repoPathSegments } from '~/utils/repoPath'
2
+
3
+ // The pure half of the bootstrap launch form's monorepo service-directory field. That field
4
+ // holds one string but carries two decisions: what the new directory is CALLED and WHERE in the
5
+ // repo it sits. Browsing the repo tree answers only the second, so it rewrites the parent and
6
+ // keeps the leaf, which means both halves have to be readable off the typed value on their own.
7
+ // Extracted for the reason every `*.logic.ts` here is: a decision worth a test should not need a
8
+ // mounted component to reach.
9
+
10
+ /**
11
+ * What the new directory is called: the last segment of the typed path.
12
+ *
13
+ * Falls back to the service name, which is what the field's own seeding watcher would have put
14
+ * there anyway, so opening the tree before typing a path still has a name to place. Empty only
15
+ * when NEITHER is known, and that answer is load-bearing: with nothing to place, the tree can
16
+ * decide nothing and says so rather than offering picks that compose a bare folder path.
17
+ */
18
+ export function serviceDirectoryLeaf(directory: string, serviceName: string): string {
19
+ return repoPathSegments(directory).at(-1) ?? serviceName.trim()
20
+ }
21
+
22
+ /**
23
+ * The folder the typed path sits in, and so where the tree should OPEN: empty for a bare name,
24
+ * because a name with no parent is a directory at the repo root.
25
+ */
26
+ export function serviceDirectoryParent(directory: string): string {
27
+ return repoPathSegments(directory).slice(0, -1).join('/')
28
+ }
@@ -5,6 +5,11 @@
5
5
  // architecture, or from scratch following a freeform prompt. The modal pairs the
6
6
  // launch form with the managed base list.
7
7
  import type { BootstrapStatus, FrameRepoType, ReferenceArchitecture } from '~/types/domain'
8
+ import {
9
+ serviceDirectoryLeaf,
10
+ serviceDirectoryParent,
11
+ } from '~/components/bootstrap/BootstrapModal.logic'
12
+ import RepoTreeBrowser from '~/components/github/RepoTreeBrowser.vue'
8
13
  import VcsConnectSurfaces from '~/components/vcs/VcsConnectSurfaces.vue'
9
14
  import { appInstallationManageUrl, newRepoUrl, VCS_PROVIDER_LABELS } from '~/utils/vcs'
10
15
 
@@ -108,20 +113,43 @@ const monorepoRepoItems = computed(() =>
108
113
  github.repos.map((r) => ({ label: `${r.owner}/${r.name}`, value: r.githubId })),
109
114
  )
110
115
 
111
- // Mirrors the backend's `normalizeServiceDirectory`: the path becomes an agent's working
112
- // directory, so a value that could escape the checkout is refused here rather than at the API.
116
+ // `repoPathSegments` is the backend's `normalizeServiceDirectory` reduction: the path becomes
117
+ // an agent's working directory, so a value that could escape the checkout is refused here
118
+ // rather than at the API.
119
+ const directorySegments = computed(() => repoPathSegments(monorepoDirectory.value))
113
120
  const directoryError = computed<string | undefined>(() => {
114
- const value = monorepoDirectory.value.trim()
115
- if (!value) return undefined
116
- const segments = value
117
- .replace(/\\/g, '/')
118
- .split('/')
119
- .filter((s) => s && s !== '.')
120
- if (!segments.length) return t('bootstrap.monorepo.directory.error.empty')
121
- if (segments.some((s) => s === '..')) return t('bootstrap.monorepo.directory.error.escapes')
121
+ if (!monorepoDirectory.value.trim()) return undefined
122
+ if (!directorySegments.value.length) return t('bootstrap.monorepo.directory.error.empty')
123
+ if (directorySegments.value.some((seg) => seg === '..')) {
124
+ return t('bootstrap.monorepo.directory.error.escapes')
125
+ }
122
126
  return undefined
123
127
  })
124
128
 
129
+ // ---- exploring the monorepo for the directory's home -----------------------
130
+ // The target must NOT exist, so nothing in the tree can BE it: the tree picks the enclosing
131
+ // folder and hands back that folder plus the leaf (see `BootstrapModal.logic`, which owns the
132
+ // two readings of the typed value).
133
+ const directoryLeaf = computed(() => serviceDirectoryLeaf(monorepoDirectory.value, repoName.value))
134
+ const browsingDirectory = ref(false)
135
+ // The folder the tree opens at, captured when the browser is OPENED rather than read live off
136
+ // the field: as a computed it would re-navigate the listing on every keystroke in the input.
137
+ const directoryBrowseStart = ref('')
138
+
139
+ function toggleDirectoryBrowse() {
140
+ if (!browsingDirectory.value) {
141
+ directoryBrowseStart.value = serviceDirectoryParent(monorepoDirectory.value)
142
+ }
143
+ browsingDirectory.value = !browsingDirectory.value
144
+ }
145
+
146
+ /** The tree emits the composed path: the folder it was standing in plus the leaf it was given. */
147
+ function placeDirectory(path: string | undefined) {
148
+ if (!path) return
149
+ monorepoDirectory.value = path
150
+ browsingDirectory.value = false
151
+ }
152
+
125
153
  // Landing in a monorepo needs no NEW repository, so the repo name is the SERVICE's name (and
126
154
  // seeds the directory's leaf); the create-repo affordances below are for the other target.
127
155
  watch([intoMonorepo, repoName], ([into, name]) => {
@@ -332,6 +360,7 @@ async function launch() {
332
360
  description.value = ''
333
361
  instructions.value = ''
334
362
  monorepoDirectory.value = ''
363
+ browsingDirectory.value = false
335
364
  // Reset the repo role too, so a later bootstrap doesn't silently inherit this one's type.
336
365
  selectedType.value = 'service'
337
366
  // The provisional frame arrived (bootstrap() refreshed the board). Re-home it to
@@ -550,11 +579,49 @@ const statusLabel = computed<Record<BootstrapStatus, string>>(() => ({
550
579
  required
551
580
  :error="directoryError"
552
581
  >
553
- <UInput
554
- v-model="monorepoDirectory"
555
- :placeholder="t('bootstrap.monorepo.directory.placeholder')"
556
- class="w-full"
557
- />
582
+ <div class="space-y-2">
583
+ <div class="flex items-center gap-2">
584
+ <UInput
585
+ v-model="monorepoDirectory"
586
+ :placeholder="t('bootstrap.monorepo.directory.placeholder')"
587
+ class="flex-1"
588
+ />
589
+ <UButton
590
+ v-if="monorepoRepoId !== undefined"
591
+ variant="soft"
592
+ color="neutral"
593
+ icon="i-lucide-folder-search"
594
+ :title="t('bootstrap.monorepo.directory.browse')"
595
+ :aria-label="t('bootstrap.monorepo.directory.browse')"
596
+ data-testid="bootstrap-directory-browse"
597
+ @click="toggleDirectoryBrowse()"
598
+ />
599
+ </div>
600
+
601
+ <!-- The tree answers WHERE, never WHAT: with no name to place yet it could
602
+ decide nothing, so say that instead of listing a repo for nothing. -->
603
+ <div
604
+ v-if="browsingDirectory && monorepoRepoId !== undefined"
605
+ class="rounded-md border border-slate-800 bg-slate-900/40 p-2"
606
+ >
607
+ <p class="mb-2 text-xs text-slate-400">
608
+ {{
609
+ directoryLeaf
610
+ ? t('bootstrap.monorepo.directory.browseHint')
611
+ : t('bootstrap.monorepo.directory.browseNeedsName')
612
+ }}
613
+ </p>
614
+ <RepoTreeBrowser
615
+ v-if="directoryLeaf"
616
+ :repo-github-id="monorepoRepoId"
617
+ mode="dir"
618
+ :new-dir-name="directoryLeaf"
619
+ :model-value="monorepoDirectory"
620
+ :start-path="directoryBrowseStart"
621
+ @update:model-value="placeDirectory"
622
+ />
623
+ </div>
624
+ </div>
558
625
  </UFormField>
559
626
  </template>
560
627