feat: entry list at /content/:key
This commit is contained in:
1 parent
b4cbcb0395
commit
91d916fb74
44 files changed
+4984
-32
No files matched your search
@@ -0,0 +1,119 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { ref } from 'vue'
|
||||
import { useMediaQuery } from '@vueuse/core'
|
||||
import ColumnPickerList from '@/components/ColumnPickerList.vue'
|
||||
import type { BuiltinField } from '@/lib/contentTypes'
|
||||
import { COLUMN_CAP } from '@/lib/entryColumns'
|
||||
import { BOTTOM_SHEET_UI } from '@/lib/sheetUi'
|
||||
|
||||
const DESKTOP_QUERY = '(min-width: 48rem)'
|
||||
const FOCUS_RING =
|
||||
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary'
|
||||
const TRIGGER = `h-11 w-11 justify-center rounded-none border border-accented bg-default p-0 text-highlighted ring-0 md:h-8 md:w-auto md:px-3 ${FOCUS_RING}`
|
||||
|
||||
defineProps<{ fields: BuiltinField[]; keys: string[]; atCap: boolean; disabled?: boolean }>()
|
||||
|
||||
const emit = defineEmits<{ toggle: [key: string, on: boolean]; reset: [] }>()
|
||||
|
||||
const isDesktop = useMediaQuery(DESKTOP_QUERY)
|
||||
const open = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UPopover
|
||||
v-if="isDesktop"
|
||||
v-model:open="open"
|
||||
:content="{ align: 'start', side: 'bottom', sideOffset: 4 }"
|
||||
:ui="{
|
||||
content:
|
||||
'w-75 rounded-none bg-default p-2 shadow-(--shadow-popover) ring-0 border border-default',
|
||||
}"
|
||||
>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-columns-3"
|
||||
:disabled="disabled"
|
||||
:class="TRIGGER"
|
||||
>
|
||||
{{ t('Columns') }}
|
||||
</UButton>
|
||||
<template #content>
|
||||
<div class="flex flex-col gap-2" data-testid="column-picker">
|
||||
<div class="flex items-center justify-between px-2 py-1">
|
||||
<span class="font-semibold text-highlighted">{{ t('Columns') }}</span>
|
||||
<span class="text-xs text-muted">{{
|
||||
t('{0} of {1}', [keys.length, COLUMN_CAP])
|
||||
}}</span>
|
||||
</div>
|
||||
<ColumnPickerList
|
||||
:fields="fields"
|
||||
:keys="keys"
|
||||
:at-cap="atCap"
|
||||
@toggle="(key, on) => emit('toggle', key, on)"
|
||||
/>
|
||||
<div class="flex justify-end px-2 py-1">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
class="h-7 rounded-none border border-accented bg-default px-2.5 text-xs text-highlighted ring-0"
|
||||
@click="emit('reset')"
|
||||
>
|
||||
{{ t('Reset') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UPopover>
|
||||
|
||||
<USlideover
|
||||
v-else
|
||||
v-model:open="open"
|
||||
side="bottom"
|
||||
:title="t('Columns')"
|
||||
:description="t('Columns')"
|
||||
:ui="BOTTOM_SHEET_UI"
|
||||
>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-columns-3"
|
||||
:aria-label="t('Columns')"
|
||||
:disabled="disabled"
|
||||
:class="TRIGGER"
|
||||
/>
|
||||
<template #title>
|
||||
<span>{{ t('Columns') }}</span>
|
||||
<span class="ms-2 text-xs font-normal text-muted">{{
|
||||
t('{0} of {1}', [keys.length, COLUMN_CAP])
|
||||
}}</span>
|
||||
</template>
|
||||
<template #body>
|
||||
<p class="text-muted">
|
||||
{{ t('Choose up to {0} fields to show. {1} selected.', [COLUMN_CAP, keys.length]) }}
|
||||
</p>
|
||||
<ColumnPickerList
|
||||
touch
|
||||
:fields="fields"
|
||||
:keys="keys"
|
||||
:at-cap="atCap"
|
||||
@toggle="(key, on) => emit('toggle', key, on)"
|
||||
/>
|
||||
</template>
|
||||
<template #footer>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
class="h-11 flex-1 justify-center rounded-none border border-accented bg-default text-highlighted ring-0"
|
||||
@click="emit('reset')"
|
||||
>
|
||||
{{ t('Reset') }}
|
||||
</UButton>
|
||||
<UButton class="h-11 flex-1 justify-center" @click="open = false">
|
||||
{{ t('Done') }}
|
||||
</UButton>
|
||||
</template>
|
||||
</USlideover>
|
||||
</template>
|
||||
@@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { fieldIcon } from '@/lib/schemaFields'
|
||||
import { COLUMN_CAP } from '@/lib/entryColumns'
|
||||
import type { BuiltinField } from '@/lib/contentTypes'
|
||||
|
||||
const props = defineProps<{
|
||||
fields: BuiltinField[]
|
||||
keys: string[]
|
||||
atCap: boolean
|
||||
touch?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ toggle: [key: string, on: boolean] }>()
|
||||
|
||||
function isLocked(key: string) {
|
||||
const checked = props.keys.includes(key)
|
||||
return checked ? props.keys.length === 1 : props.atCap
|
||||
}
|
||||
|
||||
const ROW = 'flex items-center gap-2.5 px-2'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<div
|
||||
v-for="field in fields"
|
||||
:key="field.key"
|
||||
data-testid="column-row"
|
||||
:class="[ROW, touch ? 'min-h-12' : 'min-h-9']"
|
||||
>
|
||||
<UCheckbox
|
||||
:model-value="keys.includes(field.key)"
|
||||
:disabled="isLocked(field.key)"
|
||||
:ui="{
|
||||
root: 'w-full items-center',
|
||||
base: 'rounded-none',
|
||||
container: 'h-auto',
|
||||
wrapper: 'ms-2.5 min-w-0 flex-1',
|
||||
label: 'flex w-full items-center gap-2.5 font-normal text-default',
|
||||
}"
|
||||
@update:model-value="emit('toggle', field.key, $event === true)"
|
||||
>
|
||||
<template #label>
|
||||
<UIcon :name="fieldIcon(field.type)" class="size-3.5 shrink-0 text-muted" />
|
||||
<span class="min-w-0 flex-1 truncate">{{ field.label }}</span>
|
||||
<span class="font-mono text-xs text-muted">{{ field.key }}</span>
|
||||
</template>
|
||||
</UCheckbox>
|
||||
</div>
|
||||
<div data-testid="column-fixed-row" :class="[ROW, touch ? 'min-h-12' : 'min-h-9']">
|
||||
<UCheckbox
|
||||
:model-value="false"
|
||||
disabled
|
||||
:ui="{
|
||||
root: 'w-full items-center',
|
||||
base: 'rounded-none',
|
||||
container: 'h-auto',
|
||||
wrapper: 'ms-2.5 min-w-0 flex-1',
|
||||
label: 'flex w-full items-center gap-2.5 font-normal text-muted',
|
||||
}"
|
||||
>
|
||||
<template #label>
|
||||
<UIcon name="i-lucide-clock" class="size-3.5 shrink-0" />
|
||||
<span>{{ t('Created and updated dates always show') }}</span>
|
||||
</template>
|
||||
</UCheckbox>
|
||||
</div>
|
||||
<p v-if="atCap" role="status" class="px-2 pt-1 text-xs text-muted">
|
||||
{{ t('You can show up to {0} fields.', [COLUMN_CAP]) }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
const emit = defineEmits<{ create: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UEmpty
|
||||
variant="naked"
|
||||
icon="i-lucide-file-plus"
|
||||
:title="t('No entries yet')"
|
||||
:description="
|
||||
t(
|
||||
'Use New entry to add the first entry. Each entry follows the fields of this content type.',
|
||||
)
|
||||
"
|
||||
:ui="{
|
||||
root: 'gap-3 border border-default bg-default px-6 py-10 sm:px-6 sm:py-10 lg:px-6 lg:py-10',
|
||||
avatar: 'mb-0 size-11 rounded-none bg-elevated text-toned',
|
||||
title: 'text-md/7 font-semibold',
|
||||
description: 'max-w-90 text-muted text-pretty',
|
||||
}"
|
||||
>
|
||||
<template #actions>
|
||||
<UButton icon="i-lucide-plus" class="h-8 px-4 max-md:h-11" @click="emit('create')">
|
||||
{{ t('New entry') }}
|
||||
</UButton>
|
||||
</template>
|
||||
</UEmpty>
|
||||
</template>
|
||||
@@ -0,0 +1,218 @@
|
||||
<script setup lang="ts">
|
||||
import { t, formatNumber } from '@/i18n'
|
||||
|
||||
import { computed, onMounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useMediaQuery } from '@vueuse/core'
|
||||
import AppPaginator from '@/components/AppPaginator.vue'
|
||||
import ArticleListNotice from '@/components/ArticleListNotice.vue'
|
||||
import ContentEmpty from '@/components/ContentEmpty.vue'
|
||||
import ContentState from '@/components/ContentState.vue'
|
||||
import ContentToolbar from '@/components/ContentToolbar.vue'
|
||||
import DirectoryAddButton from '@/components/DirectoryAddButton.vue'
|
||||
import DirectoryLoadError from '@/components/DirectoryLoadError.vue'
|
||||
import EntryCardList from '@/components/EntryCardList.vue'
|
||||
import EntrySkeleton from '@/components/EntrySkeleton.vue'
|
||||
import EntryTable from '@/components/EntryTable.vue'
|
||||
import type { ContentType, ContentTypeDetailStatus } from '@/lib/contentTypes'
|
||||
import { useEntryColumns } from '@/composables/useEntryColumns'
|
||||
import { useEntryList } from '@/composables/useEntryList'
|
||||
import { resolveContentListState, type ContentListState } from '@/lib/contentListState'
|
||||
import { entryCaption } from '@/lib/entryListQuery'
|
||||
import { fieldCountLabel } from '@/lib/schemaRows'
|
||||
import { HOME_PATH, entryPath, schemaPath } from '@/lib/routePaths'
|
||||
|
||||
const DESKTOP_QUERY = '(min-width: 48rem)'
|
||||
const DESKTOP_SKELETON_ROWS = 8
|
||||
const MOBILE_SKELETON_ROWS = 6
|
||||
|
||||
const props = defineProps<{
|
||||
typeKey: string
|
||||
type: ContentType | null
|
||||
typeStatus: ContentTypeDetailStatus
|
||||
canEdit: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ view: [view: ContentListState]; 'retry-type': [] }>()
|
||||
|
||||
const router = useRouter()
|
||||
const isDesktop = useMediaQuery(DESKTOP_QUERY)
|
||||
const list = useEntryList(props.typeKey)
|
||||
const columns = useEntryColumns(props.typeKey, () => props.type?.fields ?? [])
|
||||
|
||||
const view = computed(() =>
|
||||
resolveContentListState({
|
||||
type: props.typeStatus,
|
||||
entries: list.status.value,
|
||||
total: list.total.value,
|
||||
}),
|
||||
)
|
||||
const centered = computed(() => view.value === 'forbidden' || view.value === 'notFound')
|
||||
const failed = computed(() => view.value === 'typeError' || view.value === 'entriesError')
|
||||
const name = computed(() => props.type?.name ?? props.typeKey)
|
||||
const fields = computed(() => props.type?.fields ?? [])
|
||||
const subtitle = computed(() =>
|
||||
props.type ? `${props.type.key} · ${fieldCountLabel(props.type.fields.length)}` : '',
|
||||
)
|
||||
const editTo = computed(() => (props.canEdit ? schemaPath(props.typeKey) : undefined))
|
||||
const caption = computed(() => entryCaption(name.value, list.shown.value.sort))
|
||||
|
||||
const showSkeleton = computed(() =>
|
||||
view.value === 'loading'
|
||||
? list.showSkeleton.value || list.status.value === 'ready'
|
||||
: view.value === 'list' && list.skeletonActive.value,
|
||||
)
|
||||
const skeletonRows = computed(() => {
|
||||
if (view.value === 'list') {
|
||||
return list.items.value.length
|
||||
}
|
||||
return isDesktop.value ? DESKTOP_SKELETON_ROWS : MOBILE_SKELETON_ROWS
|
||||
})
|
||||
const showBody = computed(() => view.value === 'list' && !showSkeleton.value)
|
||||
const singlePageLabel = computed(() =>
|
||||
list.total.value === 1 ? t('1 entry') : t('{0} entries', [formatNumber(list.total.value)]),
|
||||
)
|
||||
const typeErrorLine = computed(() =>
|
||||
t('GET /v1/content-type/{0} failed. Check your connection and try again.', [props.typeKey]),
|
||||
)
|
||||
const entriesErrorLine = computed(() =>
|
||||
t('GET /v1/content-type/{0}/entry failed. Check your connection and try again.', [
|
||||
props.typeKey,
|
||||
]),
|
||||
)
|
||||
|
||||
function createEntry() {
|
||||
router.push(entryPath(props.typeKey, 'new'))
|
||||
}
|
||||
|
||||
function goHome() {
|
||||
router.push(HOME_PATH)
|
||||
}
|
||||
|
||||
function retry() {
|
||||
if (view.value === 'typeError') {
|
||||
emit('retry-type')
|
||||
return
|
||||
}
|
||||
list.retry()
|
||||
}
|
||||
|
||||
watch(view, (value) => emit('view', value), { immediate: true })
|
||||
|
||||
onMounted(() => {
|
||||
list.start()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContentState
|
||||
v-if="centered"
|
||||
:kind="view === 'forbidden' ? 'forbidden' : 'notFound'"
|
||||
:name="name"
|
||||
@home="goHome"
|
||||
/>
|
||||
|
||||
<div v-else class="flex flex-col gap-4">
|
||||
<div class="flex flex-wrap items-end gap-4">
|
||||
<div class="flex min-w-50 flex-1 flex-col gap-0.5">
|
||||
<template v-if="typeStatus === 'loading'">
|
||||
<USkeleton class="h-7 w-40 rounded-none bg-accented max-lg:hidden" />
|
||||
<USkeleton class="h-5 w-28 rounded-none bg-accented" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<h1 class="text-xl font-semibold text-highlighted max-lg:sr-only">
|
||||
{{ name }}
|
||||
</h1>
|
||||
<p v-if="type" class="text-muted">{{ subtitle }}</p>
|
||||
</template>
|
||||
</div>
|
||||
<UButton
|
||||
v-if="isDesktop"
|
||||
icon="i-lucide-plus"
|
||||
class="h-8 px-3.5"
|
||||
:disabled="view === 'loading'"
|
||||
@click="createEntry"
|
||||
>
|
||||
{{ t('New entry') }}
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<ContentToolbar
|
||||
v-if="!failed"
|
||||
:sort="list.desired.value.sort"
|
||||
:fields="fields"
|
||||
:keys="columns.keys.value"
|
||||
:at-cap="columns.atCap.value"
|
||||
:picker-disabled="type === null"
|
||||
:edit-to="editTo"
|
||||
@field="list.changeSortField"
|
||||
@direction="list.toggleDirection"
|
||||
@toggle="columns.toggle"
|
||||
@reset="columns.reset"
|
||||
/>
|
||||
|
||||
<DirectoryLoadError
|
||||
v-if="failed"
|
||||
:title="t('Couldn\'t load entries')"
|
||||
:description="view === 'typeError' ? typeErrorLine : entriesErrorLine"
|
||||
@retry="retry"
|
||||
/>
|
||||
|
||||
<ArticleListNotice
|
||||
v-if="list.notice.value"
|
||||
:kind="list.notice.value.kind"
|
||||
:failed-page="
|
||||
list.notice.value.kind === 'error'
|
||||
? list.notice.value.query.page
|
||||
: list.shown.value.page
|
||||
"
|
||||
:shown-page="list.shown.value.page"
|
||||
@retry="list.retryNotice"
|
||||
/>
|
||||
|
||||
<EntrySkeleton v-if="showSkeleton" :rows="skeletonRows" :cards="!isDesktop" />
|
||||
|
||||
<ContentEmpty v-else-if="view === 'empty'" @create="createEntry" />
|
||||
|
||||
<template v-else-if="showBody">
|
||||
<EntryTable
|
||||
v-if="isDesktop"
|
||||
:type-key="typeKey"
|
||||
:entries="list.items.value"
|
||||
:fields="columns.columns.value"
|
||||
:sort="list.shown.value.sort"
|
||||
:names="list.names.value"
|
||||
:caption="caption"
|
||||
/>
|
||||
<EntryCardList
|
||||
v-else
|
||||
:type-key="typeKey"
|
||||
:entries="list.items.value"
|
||||
:fields="fields"
|
||||
:names="list.names.value"
|
||||
:caption="caption"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<AppPaginator
|
||||
v-if="view === 'list'"
|
||||
flush
|
||||
:page="list.shown.value.page"
|
||||
:page-count="list.pageCount.value"
|
||||
:total="list.total.value"
|
||||
:size="list.shown.value.size"
|
||||
:loading-page="list.pendingPage.value"
|
||||
:single-page-label="singlePageLabel"
|
||||
@update:page="list.changePage"
|
||||
@update:size="list.changeSize"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DirectoryAddButton
|
||||
v-if="!isDesktop && !centered && !failed && view !== 'loading'"
|
||||
pinned
|
||||
@click="createEntry"
|
||||
>
|
||||
{{ t('New entry') }}
|
||||
</DirectoryAddButton>
|
||||
</template>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{ kind: 'forbidden' | 'notFound'; name: string }>()
|
||||
|
||||
const emit = defineEmits<{ home: [] }>()
|
||||
|
||||
const forbidden = computed(() => props.kind === 'forbidden')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-h-0 flex-1 items-center justify-center px-6 py-6">
|
||||
<UEmpty
|
||||
variant="naked"
|
||||
:icon="forbidden ? 'i-lucide-lock' : 'i-lucide-file-x'"
|
||||
:title="
|
||||
forbidden ? t('You don\'t have access to {0}', [name]) : t('Content type not found')
|
||||
"
|
||||
:description="
|
||||
forbidden
|
||||
? t(
|
||||
'Your account can\'t open this content right now. If you think this is a mistake, ask an owner or an admin.',
|
||||
)
|
||||
: t('It may have been deleted, or the address may be mistyped.')
|
||||
"
|
||||
:ui="{
|
||||
root: 'w-full max-w-105 gap-3 p-0 sm:p-0 lg:p-0',
|
||||
avatar: 'mb-0 size-11 rounded-none bg-elevated text-toned',
|
||||
title: 'text-title font-semibold text-wrap',
|
||||
description: 'mt-1 w-full max-w-none px-0 text-base/5 text-wrap text-muted',
|
||||
}"
|
||||
>
|
||||
<template #actions>
|
||||
<UButton class="h-8 px-4 max-md:h-11" @click="emit('home')">
|
||||
{{ t('Go to Home') }}
|
||||
</UButton>
|
||||
</template>
|
||||
</UEmpty>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { useMediaQuery } from '@vueuse/core'
|
||||
import ColumnPicker from '@/components/ColumnPicker.vue'
|
||||
import SortSelect from '@/components/SortSelect.vue'
|
||||
import type { BuiltinField } from '@/lib/contentTypes'
|
||||
import type { EntrySortField } from '@/lib/entryListQuery'
|
||||
|
||||
const DESKTOP_QUERY = '(min-width: 48rem)'
|
||||
|
||||
defineProps<{
|
||||
sort: string
|
||||
fields: BuiltinField[]
|
||||
keys: string[]
|
||||
atCap: boolean
|
||||
pickerDisabled: boolean
|
||||
editTo?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
field: [field: EntrySortField]
|
||||
direction: []
|
||||
toggle: [key: string, on: boolean]
|
||||
reset: []
|
||||
}>()
|
||||
|
||||
const isDesktop = useMediaQuery(DESKTOP_QUERY)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex shrink-0 items-center gap-2" data-testid="entry-toolbar">
|
||||
<SortSelect :sort="sort" @field="emit('field', $event)" @direction="emit('direction')" />
|
||||
<ColumnPicker
|
||||
:fields="fields"
|
||||
:keys="keys"
|
||||
:at-cap="atCap"
|
||||
:disabled="pickerDisabled"
|
||||
@toggle="(key, on) => emit('toggle', key, on)"
|
||||
@reset="emit('reset')"
|
||||
/>
|
||||
<template v-if="isDesktop">
|
||||
<span class="flex-1" />
|
||||
<UButton
|
||||
v-if="editTo"
|
||||
:to="editTo"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
icon="i-lucide-settings-2"
|
||||
class="h-8 rounded-none px-3 text-highlighted"
|
||||
>
|
||||
{{ t('Edit content type') }}
|
||||
</UButton>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterLink } from 'vue-router'
|
||||
import FieldCell from '@/components/FieldCell.vue'
|
||||
import type { BuiltinField, ContentEntry } from '@/lib/contentTypes'
|
||||
import { EMPTY_TEXT, cardFields, describeCell, isEmptyCell, plainCellText } from '@/lib/entryCells'
|
||||
import { entryPath } from '@/lib/routePaths'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
typeKey: string
|
||||
entries: ContentEntry[]
|
||||
fields: BuiltinField[]
|
||||
names: Record<number, string>
|
||||
caption: string
|
||||
}>()
|
||||
|
||||
const shown = computed(() => cardFields(props.fields))
|
||||
|
||||
function textOf(field: BuiltinField | undefined, entry: ContentEntry) {
|
||||
return field ? plainCellText(describeCell(field, entry.data[field.key])) : ''
|
||||
}
|
||||
|
||||
const secondIsChoice = computed(
|
||||
() => shown.value.second !== undefined && shown.value.second === shown.value.choice,
|
||||
)
|
||||
|
||||
function hasChoice(entry: ContentEntry) {
|
||||
const { choice } = shown.value
|
||||
return choice !== undefined && !isEmptyCell(describeCell(choice, entry.data[choice.key]))
|
||||
}
|
||||
|
||||
function nameOf(entry: ContentEntry) {
|
||||
return props.names[entry.updated_by]
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul :aria-label="caption" class="border border-default bg-default">
|
||||
<li
|
||||
v-for="(entry, index) in entries"
|
||||
:key="entry.id"
|
||||
:class="index > 0 ? 'border-t border-default' : ''"
|
||||
>
|
||||
<RouterLink
|
||||
:to="entryPath(typeKey, entry.id)"
|
||||
data-testid="entry-card"
|
||||
class="flex min-h-17 items-center gap-2 py-3 pr-1 pl-4 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary"
|
||||
>
|
||||
<span class="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span class="truncate text-md/5.25 font-medium text-highlighted">{{
|
||||
textOf(shown.first, entry)
|
||||
}}</span>
|
||||
<span class="flex items-center gap-2 text-xs text-muted">
|
||||
<template v-if="shown.second && !secondIsChoice">
|
||||
<span class="shrink-0">{{ textOf(shown.second, entry) }}</span>
|
||||
</template>
|
||||
<FieldCell
|
||||
v-if="shown.choice && hasChoice(entry)"
|
||||
:field="shown.choice"
|
||||
:value="entry.data[shown.choice.key]"
|
||||
class="shrink-0"
|
||||
/>
|
||||
<span class="truncate">· {{ nameOf(entry) ?? EMPTY_TEXT }}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="flex h-11 w-9 shrink-0 items-center justify-center text-muted">
|
||||
<UIcon name="i-lucide-chevron-right" class="size-4.5" />
|
||||
</span>
|
||||
</RouterLink>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
defineProps<{ rows: number; cards?: boolean }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
role="status"
|
||||
:aria-label="t('Loading')"
|
||||
aria-busy="true"
|
||||
class="border border-default bg-default"
|
||||
>
|
||||
<div
|
||||
v-for="n in rows"
|
||||
:key="n"
|
||||
data-testid="entry-skeleton-row"
|
||||
class="flex items-center gap-5 px-4"
|
||||
:class="[cards ? 'h-17' : 'h-12', n > 1 ? 'border-t border-default' : '']"
|
||||
>
|
||||
<USkeleton v-if="!cards" class="h-3 w-8 rounded-none bg-accented" />
|
||||
<USkeleton class="h-3.5 rounded-none bg-accented" :class="n % 2 ? 'w-1/4' : 'w-3/10'" />
|
||||
<template v-if="!cards">
|
||||
<USkeleton class="h-3 w-22.5 rounded-none bg-accented" />
|
||||
<USkeleton class="h-3 w-22.5 rounded-none bg-accented" />
|
||||
<USkeleton class="h-3 w-12.5 rounded-none bg-accented" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,174 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, ref, watchPostEffect } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import FieldCell from '@/components/FieldCell.vue'
|
||||
import type { BuiltinField, ContentEntry } from '@/lib/contentTypes'
|
||||
import { EMPTY_TEXT, formatEntryDate } from '@/lib/entryCells'
|
||||
import { ENTRY_SORT_FIELDS, sortParts } from '@/lib/entryListQuery'
|
||||
import { entryPath } from '@/lib/routePaths'
|
||||
|
||||
const ID_WIDTH = 'w-16.5'
|
||||
const NARROW_WIDTH = 'w-23'
|
||||
const FIELD_WIDTH = 'w-33'
|
||||
const CREATED_WIDTH = 'w-30.5'
|
||||
const UPDATED_WIDTH = 'w-40.5'
|
||||
const UPDATED_BY_WIDTH = 'w-37'
|
||||
const NARROW_TYPES = ['boolean', 'number']
|
||||
const FIELD_PREFIX = 'field:'
|
||||
|
||||
const props = defineProps<{
|
||||
typeKey: string
|
||||
entries: ContentEntry[]
|
||||
fields: BuiltinField[]
|
||||
sort: string
|
||||
names: Record<number, string>
|
||||
caption: string
|
||||
}>()
|
||||
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
const sorted = computed(() => sortParts(props.sort))
|
||||
|
||||
const HEADER = 'h-9 py-0 font-medium text-muted'
|
||||
|
||||
function widthOf(field: BuiltinField, index: number) {
|
||||
if (index === 0) {
|
||||
return ''
|
||||
}
|
||||
return NARROW_TYPES.includes(field.type) ? NARROW_WIDTH : FIELD_WIDTH
|
||||
}
|
||||
|
||||
function column(id: string, header: string, width: string, edge = 'px-1.5') {
|
||||
return {
|
||||
id,
|
||||
header,
|
||||
meta: { class: { th: `${width} ${edge} ${HEADER}`, td: `${width} ${edge}` } },
|
||||
}
|
||||
}
|
||||
|
||||
const columns = computed(() => [
|
||||
column('id', t('ID'), ID_WIDTH, 'pr-1.5 pl-3'),
|
||||
...props.fields.map((field, index) =>
|
||||
column(`${FIELD_PREFIX}${field.key}`, field.label, widthOf(field, index)),
|
||||
),
|
||||
column('created_at', t('Created'), CREATED_WIDTH),
|
||||
column('updated_at', t('Updated'), UPDATED_WIDTH),
|
||||
column('updated_by', t('Updated by'), UPDATED_BY_WIDTH, 'pr-3 pl-1.5'),
|
||||
])
|
||||
|
||||
function fieldOf(columnId: string) {
|
||||
return props.fields.find((field) => `${FIELD_PREFIX}${field.key}` === columnId)
|
||||
}
|
||||
|
||||
function isRight(columnId: string) {
|
||||
return fieldOf(columnId)?.type === 'number'
|
||||
}
|
||||
|
||||
function sortIcon(columnId: string) {
|
||||
if (sorted.value.field !== columnId) {
|
||||
return undefined
|
||||
}
|
||||
return sorted.value.direction === 'asc' ? 'i-lucide-arrow-up' : 'i-lucide-arrow-down'
|
||||
}
|
||||
|
||||
function nameOf(entry: ContentEntry) {
|
||||
return props.names[entry.updated_by]
|
||||
}
|
||||
|
||||
watchPostEffect(() => {
|
||||
const headers = root.value?.querySelectorAll('thead th') ?? []
|
||||
headers.forEach((header, index) => {
|
||||
const id = columns.value[index]?.id
|
||||
if (
|
||||
id &&
|
||||
(ENTRY_SORT_FIELDS as readonly string[]).includes(id) &&
|
||||
id === sorted.value.field
|
||||
) {
|
||||
header.setAttribute(
|
||||
'aria-sort',
|
||||
sorted.value.direction === 'desc' ? 'descending' : 'ascending',
|
||||
)
|
||||
} else {
|
||||
header.removeAttribute('aria-sort')
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="root" class="border border-default bg-default">
|
||||
<UTable
|
||||
:data="entries"
|
||||
:columns="columns"
|
||||
:ui="{
|
||||
base: 'table-fixed',
|
||||
thead: 'bg-elevated',
|
||||
separator: 'bg-(--ui-border)',
|
||||
td: 'h-12 px-1.5 py-0 text-default',
|
||||
}"
|
||||
>
|
||||
<template #caption>
|
||||
<caption class="sr-only">
|
||||
{{
|
||||
caption
|
||||
}}
|
||||
</caption>
|
||||
</template>
|
||||
|
||||
<template v-for="column in columns" :key="column.id" #[`${column.id}-header`]>
|
||||
<span
|
||||
class="flex items-center gap-1"
|
||||
:class="isRight(column.id) ? 'justify-end' : ''"
|
||||
>
|
||||
{{ column.header }}
|
||||
<UIcon
|
||||
v-if="sortIcon(column.id)"
|
||||
:name="sortIcon(column.id)!"
|
||||
:data-sort-direction="sorted.direction"
|
||||
class="size-3"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #id-cell="{ row }">
|
||||
<span class="font-mono text-xs text-muted">{{ row.original.id }}</span>
|
||||
</template>
|
||||
|
||||
<template
|
||||
v-for="(field, index) in fields"
|
||||
:key="field.key"
|
||||
#[`${FIELD_PREFIX}${field.key}-cell`]="{ row }"
|
||||
>
|
||||
<RouterLink
|
||||
v-if="index === 0"
|
||||
:to="entryPath(typeKey, row.original.id)"
|
||||
data-testid="entry-link"
|
||||
class="flex min-w-0 font-medium text-highlighted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
|
||||
>
|
||||
<FieldCell
|
||||
:field="field"
|
||||
:value="row.original.data[field.key]"
|
||||
class="min-w-0"
|
||||
/>
|
||||
</RouterLink>
|
||||
<FieldCell v-else :field="field" :value="row.original.data[field.key]" />
|
||||
</template>
|
||||
|
||||
<template #created_at-cell="{ row }">
|
||||
<span class="text-muted">{{ formatEntryDate(row.original.created_at) }}</span>
|
||||
</template>
|
||||
|
||||
<template #updated_at-cell="{ row }">
|
||||
<span class="text-muted">{{ formatEntryDate(row.original.updated_at) }}</span>
|
||||
</template>
|
||||
|
||||
<template #updated_by-cell="{ row }">
|
||||
<span v-if="nameOf(row.original)" class="block truncate">{{
|
||||
nameOf(row.original)
|
||||
}}</span>
|
||||
<span v-else class="text-muted">{{ EMPTY_TEXT }}</span>
|
||||
</template>
|
||||
</UTable>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import type { BuiltinField } from '@/lib/contentTypes'
|
||||
import { EMPTY_TEXT, describeCell, isEmptyCell } from '@/lib/entryCells'
|
||||
|
||||
const props = defineProps<{ field: BuiltinField; value: unknown }>()
|
||||
|
||||
const cell = computed(() => describeCell(props.field, props.value))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span v-if="isEmptyCell(cell)" class="text-muted">{{ EMPTY_TEXT }}</span>
|
||||
<span v-else-if="cell.kind === 'boolean'" class="flex items-center gap-1.5">
|
||||
<UIcon
|
||||
:name="cell.yes ? 'i-lucide-check' : 'i-lucide-minus'"
|
||||
class="size-3.5 shrink-0 text-muted"
|
||||
/>
|
||||
{{ cell.yes ? t('Yes') : t('No') }}
|
||||
</span>
|
||||
<UBadge
|
||||
v-else-if="cell.kind === 'choice'"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
:label="cell.text"
|
||||
class="max-w-full border border-accented bg-transparent text-toned ring-0"
|
||||
:ui="{ base: 'h-5 rounded-none px-1.5 py-0 text-xs font-normal', label: 'truncate' }"
|
||||
/>
|
||||
<span v-else-if="cell.kind === 'number'" class="block text-right tabular-nums">{{
|
||||
cell.text
|
||||
}}</span>
|
||||
<span
|
||||
v-else
|
||||
class="block truncate"
|
||||
:class="cell.kind === 'richtext' ? 'text-toned' : ''"
|
||||
:title="cell.text"
|
||||
>{{ cell.text }}</span
|
||||
>
|
||||
</template>
|
||||
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
ENTRY_SORT_FIELDS,
|
||||
sortDirectionLabel,
|
||||
sortFieldLabel,
|
||||
sortParts,
|
||||
type EntrySortField,
|
||||
} from '@/lib/entryListQuery'
|
||||
|
||||
const FOCUS_RING =
|
||||
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary'
|
||||
|
||||
const props = defineProps<{ sort: string }>()
|
||||
|
||||
const emit = defineEmits<{ field: [field: EntrySortField]; direction: [] }>()
|
||||
|
||||
const parts = computed(() => sortParts(props.sort))
|
||||
const items = computed(() =>
|
||||
ENTRY_SORT_FIELDS.map((field) => ({ label: sortFieldLabel(field), value: field })),
|
||||
)
|
||||
const directionIcon = computed(() =>
|
||||
parts.value.direction === 'asc'
|
||||
? 'i-lucide-arrow-up-narrow-wide'
|
||||
: 'i-lucide-arrow-down-wide-narrow',
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2 md:flex-none">
|
||||
<USelect
|
||||
:model-value="parts.field"
|
||||
:items="items"
|
||||
value-key="value"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
:aria-label="t('Sort by')"
|
||||
class="min-w-0 flex-1 md:w-55 md:flex-none"
|
||||
:ui="{
|
||||
base: `h-11 gap-2 rounded-none border border-accented bg-default px-2.5 text-sm text-highlighted ring-0 focus-visible:ring-0 md:h-8 ${FOCUS_RING}`,
|
||||
trailingIcon: 'size-3.5 text-muted',
|
||||
content: 'rounded-none shadow-(--shadow-popover)',
|
||||
item: 'min-h-11 items-center rounded-none px-2 py-0 data-[state=checked]:bg-(--rc-sand) data-[state=checked]:font-semibold data-[state=checked]:text-(--rc-on-sand) md:min-h-8',
|
||||
itemTrailingIcon: 'hidden',
|
||||
}"
|
||||
@update:model-value="emit('field', $event as EntrySortField)"
|
||||
>
|
||||
<template #default>
|
||||
<span class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span class="shrink-0 text-muted">{{ t('Sort by') }}</span>
|
||||
<span class="min-w-0 flex-1 truncate text-left font-medium">{{
|
||||
sortFieldLabel(parts.field)
|
||||
}}</span>
|
||||
</span>
|
||||
</template>
|
||||
</USelect>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
:icon="directionIcon"
|
||||
:aria-label="sortDirectionLabel(sort)"
|
||||
:data-sort-direction="parts.direction"
|
||||
:class="`size-11 shrink-0 justify-center rounded-none border border-accented bg-default p-0 text-highlighted ring-0 md:size-8 ${FOCUS_RING}`"
|
||||
@click="emit('direction')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,113 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { addCollection } from '@iconify/vue'
|
||||
import lucide from 'virtual:lucide-subset'
|
||||
import { apiField } from '@/lib/__tests__/schemaFixtures'
|
||||
import { setLocale } from '@/i18n'
|
||||
import FieldCell from '../FieldCell.vue'
|
||||
import { mountInApp, text, unmountApp } from './directoryKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
addCollection(lucide)
|
||||
|
||||
afterEach(async () => {
|
||||
setLocale('en')
|
||||
unmountApp()
|
||||
})
|
||||
|
||||
const LONG = 'A very long title that would never fit on a single line of the table'
|
||||
|
||||
function iconPath(name: string) {
|
||||
return /d="([^"]+)"/.exec(lucide.icons[name]!.body)![1]
|
||||
}
|
||||
|
||||
function cell() {
|
||||
return document.body.firstElementChild!.firstElementChild!
|
||||
}
|
||||
|
||||
async function show(type: string, value: unknown) {
|
||||
await mountInApp(FieldCell, { field: apiField('Field', 'field', type), value })
|
||||
return cell()
|
||||
}
|
||||
|
||||
describe('FieldCell', () => {
|
||||
it('truncates text on one line with the full text as tooltip', async () => {
|
||||
const element = await show('text', LONG)
|
||||
|
||||
expect(text(element)).toBe(LONG)
|
||||
expect(element.className).toContain('truncate')
|
||||
expect(element.getAttribute('title')).toBe(LONG)
|
||||
})
|
||||
|
||||
it('shows the first line of a rich text in plain text, truncated, in the toned colour', async () => {
|
||||
const element = await show('richtext', '## **Two** days\n\nSecond paragraph')
|
||||
|
||||
expect(text(element)).toBe('Two days')
|
||||
expect(element.className).toContain('truncate')
|
||||
expect(element.className).toContain('text-toned')
|
||||
})
|
||||
|
||||
it('right-aligns a number with tabular figures', async () => {
|
||||
const element = await show('number', 500)
|
||||
|
||||
expect(text(element)).toBe('500')
|
||||
expect(element.className).toContain('text-right')
|
||||
expect(element.className).toContain('tabular-nums')
|
||||
})
|
||||
|
||||
it.each([
|
||||
[true, 'Yes'],
|
||||
[false, 'No'],
|
||||
])('shows %s as an icon and %s', async (value, label) => {
|
||||
const element = await show('boolean', value)
|
||||
|
||||
expect(text(element)).toBe(label)
|
||||
expect(element.querySelector('svg')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('draws a check for yes and a minus for no', async () => {
|
||||
const yes = (await show('boolean', true)).querySelector('svg path')!.getAttribute('d')
|
||||
unmountApp()
|
||||
const no = (await show('boolean', false)).querySelector('svg path')!.getAttribute('d')
|
||||
|
||||
expect(yes).toBe(iconPath('check'))
|
||||
expect(no).toBe(iconPath('minus'))
|
||||
})
|
||||
|
||||
it.each([
|
||||
['2026-11-21', '21 Nov 2026'],
|
||||
['2027-01-01', '1 Jan 2027'],
|
||||
])('shows the day %s as %s', async (value, label) => {
|
||||
expect(text(await show('date', value))).toBe(label)
|
||||
})
|
||||
|
||||
it('shows the day in French', async () => {
|
||||
setLocale('fr')
|
||||
|
||||
expect(text(await show('date', '2026-11-21'))).toMatch(/^21 nov\.? 2026$/)
|
||||
})
|
||||
|
||||
it('shows a choice as a small outline tag', async () => {
|
||||
const element = await show('select', 'Workshop')
|
||||
|
||||
expect(text(element)).toBe('Workshop')
|
||||
expect(element.className).toContain('border-accented')
|
||||
expect(element.className).toContain('ring-0')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['text', ''],
|
||||
['richtext', null],
|
||||
['number', undefined],
|
||||
['date', ''],
|
||||
['select', null],
|
||||
])('shows a dash for an empty %s', async (type, value) => {
|
||||
const element = await show(type, value)
|
||||
|
||||
expect(text(element)).toBe('–')
|
||||
expect(element.className).toContain('text-muted')
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user