feat(activity): update summary cards to toggles and introduce export menu
This commit is contained in:
1 parent
73f4b539fe
commit
03b39e64f2
30 files changed
+2376
-238
No files matched your search
@@ -36,7 +36,7 @@ const emit = defineEmits<{
|
||||
variant="ghost"
|
||||
icon="i-lucide-x"
|
||||
:aria-label="t('Remove filter {0}', [chip.label])"
|
||||
class="size-5 justify-center p-0 text-muted"
|
||||
class="size-5 justify-center p-0 text-muted hover:bg-accented hover:text-highlighted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
|
||||
:ui="{ leadingIcon: 'size-3' }"
|
||||
@click="emit('remove', chip.key)"
|
||||
/>
|
||||
@@ -45,7 +45,7 @@ const emit = defineEmits<{
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="link"
|
||||
class="h-11 px-1.5 text-xs font-medium text-highlighted underline underline-offset-3 md:h-6"
|
||||
class="h-11 px-1.5 text-xs font-medium text-highlighted underline underline-offset-3 hover:bg-elevated focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary md:h-6"
|
||||
@click="emit('clear')"
|
||||
>
|
||||
{{ t('Clear all') }}
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import type { ExportStage } from '@/composables/useActivityExport'
|
||||
import { EXPORT_RANGE_KEYS, exportRangeLabel, type ExportRange } from '@/lib/activity'
|
||||
|
||||
const props = defineProps<{
|
||||
stage: ExportStage
|
||||
canSubmit: boolean
|
||||
fileName: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: []
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false })
|
||||
const range = defineModel<ExportRange>('range', { default: 'all' })
|
||||
const from = defineModel<string>('from', { default: '' })
|
||||
const to = defineModel<string>('to', { default: '' })
|
||||
|
||||
const MODAL_CONTENT =
|
||||
'divide-y-0 shadow-(--shadow-modal) ring-0 sm:max-w-md md:top-35 md:translate-y-0 max-md:!inset-x-0 max-md:!top-auto max-md:!bottom-0 max-md:!left-0 max-md:!w-auto max-md:!max-w-none max-md:!translate-x-0 max-md:!translate-y-0 max-md:before:absolute max-md:before:top-2 max-md:before:left-1/2 max-md:before:h-1 max-md:before:w-9 max-md:before:-translate-x-1/2 max-md:before:bg-(--ui-border-accented) max-md:pt-3'
|
||||
const BUTTON_CLASS = 'h-11 justify-center px-3 text-sm md:h-8'
|
||||
const OUTLINE_CLASS = `${BUTTON_CLASS} border border-accented bg-default text-highlighted ring-0`
|
||||
const FIELD_LABEL_CLASS = 'font-medium text-highlighted'
|
||||
const CLOSE_BUTTON = {
|
||||
color: 'neutral',
|
||||
variant: 'ghost',
|
||||
class: 'size-11 justify-center p-0 text-muted md:size-7',
|
||||
} as const
|
||||
|
||||
const preparing = computed(() => props.stage === 'preparing')
|
||||
const rangeItems = computed(() =>
|
||||
EXPORT_RANGE_KEYS.map((key) => ({ value: key, label: exportRangeLabel(key) })),
|
||||
)
|
||||
const showCustomFields = computed(() => range.value === 'custom')
|
||||
|
||||
function onOpenChange(value: boolean) {
|
||||
if (!value) emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UModal
|
||||
:open="open"
|
||||
:title="t('Export all activity')"
|
||||
:description="t('Downloads every event as a CSV file. Filters on the page are ignored.')"
|
||||
:dismissible="!preparing"
|
||||
:close="preparing ? false : CLOSE_BUTTON"
|
||||
:ui="{
|
||||
content: MODAL_CONTENT,
|
||||
close: 'end-6 top-8 md:top-5',
|
||||
header: 'min-h-0 items-start px-6 pt-5 pb-0 sm:px-6',
|
||||
title: 'pe-14 text-lg/6 font-semibold md:pe-10',
|
||||
description: 'mt-1 pe-14 text-sm text-muted text-pretty md:pe-10',
|
||||
body: 'px-6 pt-5 pb-0 sm:px-6 sm:pt-5 sm:pb-0',
|
||||
footer: 'px-6 py-5 sm:px-6',
|
||||
}"
|
||||
@update:open="onOpenChange"
|
||||
>
|
||||
<template #body>
|
||||
<div class="flex flex-col gap-4">
|
||||
<template v-if="stage === 'configure'">
|
||||
<UFormField :label="t('Format')" :ui="{ label: FIELD_LABEL_CLASS }">
|
||||
<UInput
|
||||
model-value="CSV"
|
||||
readonly
|
||||
:aria-label="t('Format')"
|
||||
class="w-full"
|
||||
:ui="{
|
||||
base: 'h-8 rounded-none border border-accented bg-default text-sm text-highlighted ring-0',
|
||||
}"
|
||||
/>
|
||||
</UFormField>
|
||||
<UFormField
|
||||
:label="t('Date range')"
|
||||
:help="t('Default is all time. Filters on the page are ignored.')"
|
||||
:ui="{ label: FIELD_LABEL_CLASS, help: 'mt-1.5 text-xs text-muted' }"
|
||||
>
|
||||
<USelect
|
||||
v-model="range"
|
||||
:items="rangeItems"
|
||||
value-key="value"
|
||||
icon="i-lucide-calendar"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
:aria-label="t('Date range')"
|
||||
class="w-full"
|
||||
:ui="{
|
||||
base: 'h-11 rounded-none border border-accented bg-default ps-8 pe-8 text-sm ring-0 md:h-8',
|
||||
leadingIcon: 'size-3.5 text-muted',
|
||||
trailingIcon: 'size-3.5 text-muted',
|
||||
}"
|
||||
/>
|
||||
</UFormField>
|
||||
<div v-if="showCustomFields" class="flex items-center gap-2">
|
||||
<UInput
|
||||
v-model="from"
|
||||
type="date"
|
||||
icon="i-lucide-calendar"
|
||||
:aria-label="t('Start date')"
|
||||
class="min-w-0 flex-1"
|
||||
:ui="{ base: 'h-11 w-full min-w-0 text-lg md:h-8 md:text-sm' }"
|
||||
/>
|
||||
<UInput
|
||||
v-model="to"
|
||||
type="date"
|
||||
icon="i-lucide-calendar"
|
||||
:aria-label="t('End date')"
|
||||
class="min-w-0 flex-1"
|
||||
:ui="{ base: 'h-11 w-full min-w-0 text-lg md:h-8 md:text-sm' }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else-if="preparing" class="flex flex-col gap-3">
|
||||
<span class="flex items-center gap-2 font-medium text-highlighted">
|
||||
<UIcon name="i-lucide-loader-circle" class="size-4 animate-spin" />
|
||||
{{ t('Preparing download…') }}
|
||||
</span>
|
||||
<UProgress size="xs" color="primary" />
|
||||
<span class="text-muted text-pretty">
|
||||
{{
|
||||
t(
|
||||
'The file starts downloading as soon as it is ready. Keep this window open.',
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="stage === 'started'" class="flex items-start gap-3">
|
||||
<UIcon
|
||||
name="i-lucide-circle-check"
|
||||
class="mt-0.5 size-5 shrink-0 text-success"
|
||||
/>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="text-md font-semibold text-highlighted">{{
|
||||
t('Download started')
|
||||
}}</span>
|
||||
<span class="text-muted">{{
|
||||
t(
|
||||
"{0} is downloading. Check your browser's downloads if nothing appears.",
|
||||
[fileName],
|
||||
)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UAlert
|
||||
v-else-if="stage === 'failed'"
|
||||
role="alert"
|
||||
color="error"
|
||||
variant="soft"
|
||||
icon="i-lucide-circle-x"
|
||||
:close="false"
|
||||
:title="t('Couldn\'t prepare the download')"
|
||||
:description="t('Nothing was downloaded. Try again in a moment.')"
|
||||
/>
|
||||
|
||||
<UAlert
|
||||
v-else-if="stage === 'denied'"
|
||||
role="alert"
|
||||
color="error"
|
||||
variant="soft"
|
||||
icon="i-lucide-lock"
|
||||
:close="false"
|
||||
:title="t('You can\'t export all activity')"
|
||||
:description="
|
||||
t(
|
||||
'Only owners and admins can export all activity. Ask an owner if you need a copy.',
|
||||
)
|
||||
"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-else-if="stage === 'empty'"
|
||||
class="flex flex-col items-center gap-2.5 py-3 text-center"
|
||||
>
|
||||
<span class="flex size-11 items-center justify-center bg-elevated text-toned">
|
||||
<UIcon name="i-lucide-file-x" class="size-5" />
|
||||
</span>
|
||||
<span class="text-md font-semibold text-highlighted">{{
|
||||
t('Nothing to export')
|
||||
}}</span>
|
||||
<span class="max-w-75 text-muted text-pretty">{{
|
||||
t('There is no activity in this date range. Choose a wider range.')
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex w-full flex-col-reverse gap-2 md:flex-row md:justify-end">
|
||||
<template v-if="stage === 'configure' || preparing || stage === 'failed'">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
block
|
||||
:class="[OUTLINE_CLASS, 'md:w-auto']"
|
||||
@click="emit('close')"
|
||||
>
|
||||
{{ t('Cancel') }}
|
||||
</UButton>
|
||||
<UButton
|
||||
v-if="stage === 'configure'"
|
||||
icon="i-lucide-download"
|
||||
block
|
||||
:class="[BUTTON_CLASS, 'md:w-auto']"
|
||||
:disabled="!canSubmit"
|
||||
@click="emit('submit')"
|
||||
>
|
||||
{{ t('Export CSV') }}
|
||||
</UButton>
|
||||
<UButton
|
||||
v-else-if="preparing"
|
||||
block
|
||||
disabled
|
||||
:class="[BUTTON_CLASS, 'md:w-auto']"
|
||||
>
|
||||
{{ t('Preparing…') }}
|
||||
</UButton>
|
||||
<UButton
|
||||
v-else
|
||||
icon="i-lucide-rotate-cw"
|
||||
block
|
||||
:class="[BUTTON_CLASS, 'md:w-auto']"
|
||||
@click="emit('submit')"
|
||||
>
|
||||
{{ t('Retry') }}
|
||||
</UButton>
|
||||
</template>
|
||||
<UButton
|
||||
v-else
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
block
|
||||
:class="[OUTLINE_CLASS, 'md:w-auto']"
|
||||
@click="emit('close')"
|
||||
>
|
||||
{{ t('Close') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
@@ -0,0 +1,157 @@
|
||||
<script setup lang="ts">
|
||||
import { formatNumber, t } from '@/i18n'
|
||||
import { NOTHING_TO_EXPORT_COPY, OWNERS_ONLY_COPY } from '@/lib/activity'
|
||||
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
total: number
|
||||
isDesktop: boolean
|
||||
disabled: boolean
|
||||
nothingToExport: boolean
|
||||
forbidden: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
current: []
|
||||
all: []
|
||||
}>()
|
||||
|
||||
const open = ref(false)
|
||||
|
||||
const BUTTON_CLASS =
|
||||
'h-11 border border-accented bg-default px-3 text-sm text-highlighted ring-0 data-[state=open]:bg-elevated md:h-8'
|
||||
const ITEM_HINT_CLASS = 'text-xs/4.5 text-muted text-pretty'
|
||||
|
||||
const currentDisabled = computed(() => props.forbidden || props.nothingToExport)
|
||||
const currentHint = computed(() => {
|
||||
if (props.forbidden) return t(OWNERS_ONLY_COPY)
|
||||
if (props.nothingToExport) return t(NOTHING_TO_EXPORT_COPY)
|
||||
return props.total === 1
|
||||
? t('CSV · 1 event matching your filters and sort, across all pages')
|
||||
: t('CSV · {0} events matching your filters and sort, across all pages', [
|
||||
formatNumber(props.total),
|
||||
])
|
||||
})
|
||||
const allHint = computed(() =>
|
||||
props.forbidden
|
||||
? t(OWNERS_ONLY_COPY)
|
||||
: t('CSV · every event, ignoring filters. Owners and admins only.'),
|
||||
)
|
||||
const trailingIcon = computed(() => (open.value ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'))
|
||||
|
||||
const entries = computed(() => [
|
||||
{
|
||||
action: 'current' as const,
|
||||
label: t('Export current view'),
|
||||
description: currentHint.value,
|
||||
icon: 'i-lucide-download',
|
||||
disabled: currentDisabled.value,
|
||||
},
|
||||
{
|
||||
action: 'all' as const,
|
||||
label: t('Export all activity…'),
|
||||
description: allHint.value,
|
||||
icon: props.forbidden ? 'i-lucide-lock' : 'i-lucide-archive',
|
||||
disabled: props.forbidden,
|
||||
},
|
||||
])
|
||||
|
||||
const items = computed(() =>
|
||||
entries.value.map(({ action, label, description, icon, disabled }) => [
|
||||
{ label, description, icon, disabled, onSelect: () => choose(action) },
|
||||
]),
|
||||
)
|
||||
|
||||
function choose(action: 'current' | 'all') {
|
||||
open.value = false
|
||||
if (action === 'current') emit('current')
|
||||
else emit('all')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UDropdownMenu
|
||||
v-if="isDesktop"
|
||||
v-model:open="open"
|
||||
:items="items"
|
||||
:content="{ align: 'end', sideOffset: 4 }"
|
||||
:ui="{
|
||||
content: 'w-90 rounded-none p-1 shadow-(--rc-shadow-pop)',
|
||||
group: 'p-0',
|
||||
separator: 'my-0',
|
||||
item: 'min-h-14 items-start gap-3 rounded-none px-3 py-2.5 text-sm data-disabled:opacity-100 data-disabled:text-muted',
|
||||
itemLeadingIcon: 'mt-0.5 size-4 text-highlighted group-data-disabled:text-muted',
|
||||
itemLabel: 'font-medium',
|
||||
itemDescription: 'text-xs/4.5 text-muted text-pretty whitespace-normal',
|
||||
}"
|
||||
>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-download"
|
||||
:trailing-icon="trailingIcon"
|
||||
:disabled="disabled"
|
||||
:class="BUTTON_CLASS"
|
||||
:ui="{ trailingIcon: 'size-3.5 text-muted' }"
|
||||
>
|
||||
{{ t('Export') }}
|
||||
</UButton>
|
||||
</UDropdownMenu>
|
||||
|
||||
<template v-else>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-download"
|
||||
:trailing-icon="trailingIcon"
|
||||
:disabled="disabled"
|
||||
:class="BUTTON_CLASS"
|
||||
:ui="{ trailingIcon: 'size-3.5 text-muted' }"
|
||||
@click="open = true"
|
||||
>
|
||||
{{ t('Export') }}
|
||||
</UButton>
|
||||
<USlideover
|
||||
v-model:open="open"
|
||||
side="bottom"
|
||||
:title="t('Export')"
|
||||
:description="t('Export')"
|
||||
:ui="{
|
||||
content:
|
||||
'max-h-none before:absolute before:top-2 before:left-1/2 before:h-1 before:w-9 before:-translate-x-1/2 before:bg-(--ui-border-accented) pt-3',
|
||||
header: 'h-14 min-h-14 border-b border-default px-4 py-0 sm:px-4',
|
||||
title: 'text-lg font-semibold text-highlighted',
|
||||
description: 'sr-only',
|
||||
close: 'top-4.5 end-2 size-11 justify-center p-0',
|
||||
body: 'p-0 pb-4 sm:p-0 sm:pb-4',
|
||||
}"
|
||||
>
|
||||
<template #body>
|
||||
<div role="menu" :aria-label="t('Export')" class="flex flex-col">
|
||||
<template v-for="(entry, index) in entries" :key="entry.action">
|
||||
<USeparator v-if="index > 0" />
|
||||
<UButton
|
||||
role="menuitem"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
block
|
||||
:icon="entry.icon"
|
||||
:disabled="entry.disabled"
|
||||
class="min-h-16 items-start justify-start gap-3 rounded-none px-4 py-3 text-start text-sm text-highlighted disabled:text-muted disabled:opacity-100"
|
||||
:ui="{ leadingIcon: 'mt-0.5 size-4' }"
|
||||
@click="choose(entry.action)"
|
||||
>
|
||||
<span class="flex min-w-0 flex-col gap-0.5">
|
||||
<span class="font-medium">{{ entry.label }}</span>
|
||||
<span :class="[ITEM_HINT_CLASS, 'font-normal']">{{
|
||||
entry.description
|
||||
}}</span>
|
||||
</span>
|
||||
</UButton>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</USlideover>
|
||||
</template>
|
||||
</template>
|
||||
@@ -1,5 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, ref } from 'vue'
|
||||
import type { ActivityFilters, ActivitySummary } from '@/lib/activity'
|
||||
import {
|
||||
barHeights,
|
||||
@@ -41,6 +43,14 @@ const VALUE_TONES: Record<StripCard['tone'], string> = {
|
||||
warning: 'text-warning',
|
||||
calm: 'text-highlighted',
|
||||
}
|
||||
const PRESSED_TEXT = 'text-(--rc-on-sand)'
|
||||
const PRESSED_BAR = 'bg-(--rc-on-sand)'
|
||||
|
||||
const hovered = ref<StripKey | null>(null)
|
||||
|
||||
function isToggle(key: StripKey) {
|
||||
return key === 'failed' || key === 'security'
|
||||
}
|
||||
|
||||
function pressed(key: StripKey) {
|
||||
if (key === 'failed') return props.outcome === 'failed'
|
||||
@@ -49,21 +59,63 @@ function pressed(key: StripKey) {
|
||||
}
|
||||
|
||||
function rootClass(card: StripCard) {
|
||||
const fill =
|
||||
card.tone === 'error'
|
||||
const isPressed = pressed(card.key)
|
||||
const fill = isPressed
|
||||
? 'bg-(--rc-sand)'
|
||||
: card.tone === 'error'
|
||||
? 'bg-(--rc-error-soft)'
|
||||
: card.tone === 'warning'
|
||||
? 'bg-(--rc-warning-soft)'
|
||||
: 'bg-default'
|
||||
const edge = pressed(card.key)
|
||||
const edge = isPressed
|
||||
? 'border-primary ring-1 ring-primary ring-inset'
|
||||
: 'border-default ring-0'
|
||||
: isToggle(card.key)
|
||||
? 'border-default ring-0 hover:border-accented hover:bg-elevated'
|
||||
: 'border-default ring-0'
|
||||
return [
|
||||
'w-full cursor-pointer border text-left max-md:w-auto max-md:shrink-0 max-md:basis-39 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary',
|
||||
fill,
|
||||
edge,
|
||||
]
|
||||
}
|
||||
|
||||
function labelClass(card: StripCard) {
|
||||
return pressed(card.key) ? PRESSED_TEXT : 'text-toned'
|
||||
}
|
||||
|
||||
function valueClass(card: StripCard) {
|
||||
return pressed(card.key) ? PRESSED_TEXT : VALUE_TONES[card.tone]
|
||||
}
|
||||
|
||||
function barClass(card: StripCard) {
|
||||
return pressed(card.key) ? PRESSED_BAR : BAR_TONES[card.tone]
|
||||
}
|
||||
|
||||
function sublineClass(card: StripCard) {
|
||||
if (pressed(card.key)) return PRESSED_TEXT
|
||||
return card.tone === 'calm' ? 'text-success' : 'text-muted'
|
||||
}
|
||||
|
||||
function hint(card: StripCard) {
|
||||
if (!isToggle(card.key)) return null
|
||||
if (pressed(card.key)) {
|
||||
return hovered.value === card.key
|
||||
? { text: t('Click to clear this filter'), icon: 'i-lucide-x' }
|
||||
: { text: t('Filtering. Click to clear'), icon: 'i-lucide-filter' }
|
||||
}
|
||||
return hovered.value === card.key
|
||||
? { text: t('Click to filter the list'), icon: 'i-lucide-filter' }
|
||||
: null
|
||||
}
|
||||
|
||||
function sublineText(card: StripCard) {
|
||||
return hint(card)?.text ?? card.subline
|
||||
}
|
||||
|
||||
function sublineIcon(card: StripCard) {
|
||||
const shown = hint(card)
|
||||
return shown ? shown.icon : card.icon
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -102,11 +154,15 @@ function rootClass(card: StripCard) {
|
||||
root: 'overflow-visible rounded-none shadow-none md:min-w-0',
|
||||
body: 'p-0 sm:p-0',
|
||||
}"
|
||||
@mouseenter="hovered = card.key"
|
||||
@mouseleave="hovered = null"
|
||||
@click="emit('apply-filter', card.key)"
|
||||
>
|
||||
<span class="flex flex-col gap-0.5" :class="CARD_PADDING">
|
||||
<span class="flex items-center justify-between gap-2">
|
||||
<span class="truncate font-medium text-toned">{{ card.label }}</span>
|
||||
<span class="truncate font-medium" :class="labelClass(card)">{{
|
||||
card.label
|
||||
}}</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
data-testid="activity-bars"
|
||||
@@ -117,22 +173,28 @@ function rootClass(card: StripCard) {
|
||||
v-for="(height, index) in barHeights(card.series, barHeight)"
|
||||
:key="index"
|
||||
class="block w-1"
|
||||
:class="BAR_TONES[card.tone]"
|
||||
:class="barClass(card)"
|
||||
:style="{ height: `${height}px` }"
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
class="text-stat-sm font-semibold md:text-stat"
|
||||
:class="VALUE_TONES[card.tone]"
|
||||
:class="valueClass(card)"
|
||||
>{{ card.value }}</span
|
||||
>
|
||||
<span
|
||||
class="flex items-center gap-1 truncate text-xs/5"
|
||||
:class="card.tone === 'calm' ? 'text-success' : 'text-muted'"
|
||||
:class="sublineClass(card)"
|
||||
>
|
||||
<UIcon v-if="card.icon" :name="card.icon" class="size-3 shrink-0" />
|
||||
<span class="truncate">{{ card.subline }}</span>
|
||||
<UIcon
|
||||
v-if="sublineIcon(card)"
|
||||
:name="sublineIcon(card)!"
|
||||
class="size-3 shrink-0"
|
||||
/>
|
||||
<span class="truncate" data-testid="activity-strip-subline">{{
|
||||
sublineText(card)
|
||||
}}</span>
|
||||
</span>
|
||||
</span>
|
||||
</UCard>
|
||||
|
||||
Reference in new issue
Block a user