fix: authors profile pictute all accross the SPA
This commit is contained in:
1 parent
18600ab49d
commit
500ad59458
19 files changed
+265
-16
No files matched your search
@@ -514,6 +514,7 @@ Feature: Sites and Authors screens
|
||||
and "Shown as a square. Choose an image with the face or logo near the middle." and "Use image"
|
||||
When the user picks an image and activates "Use image"
|
||||
Then the avatar shows as a square and avatar_id is sent on save
|
||||
And once saved, the avatar is the picked image in the list row, the read-only detail, the edit form and its byline preview, resolved with GET /v1/media/{avatar_id} and shared between all of them, with the initials only while it loads, when it fails or when avatar_id is null
|
||||
And the Sites default social image uses this same picker and sends default_social_image_id
|
||||
|
||||
Scenario Outline: Authors form: save failed
|
||||
|
||||
@@ -87,6 +87,7 @@ Authors: form (11):
|
||||
- [ ] Linked user: choose
|
||||
- [ ] Edit: linked author
|
||||
- [ ] Avatar: picker
|
||||
- [ ] Avatar: saved image shows in the list, the detail, the reopened form and the preview (not only the initials)
|
||||
- [ ] Save failed
|
||||
- [ ] Unlink: confirm
|
||||
- [ ] Re-link
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useAvatarImage } from '@/composables/useAvatarImage'
|
||||
import { initialsOf } from '@/lib/directory'
|
||||
|
||||
const TONES = ['bg-(--rc-sand)', 'bg-(--ui-bg-accented)', 'bg-(--ui-bg-elevated)'] as const
|
||||
@@ -7,18 +8,21 @@ const TONES = ['bg-(--rc-sand)', 'bg-(--ui-bg-accented)', 'bg-(--ui-bg-elevated)
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
name: string
|
||||
avatarId?: number | null
|
||||
size?: number
|
||||
ring?: boolean
|
||||
}>(),
|
||||
{ size: 32, ring: false },
|
||||
{ avatarId: null, size: 32, ring: false },
|
||||
)
|
||||
|
||||
const src = useAvatarImage(() => props.avatarId)
|
||||
const tone = computed(() => TONES[props.name.length % TONES.length])
|
||||
const fontSize = computed(() => `${Math.round(props.size * 0.36)}px`)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UAvatar
|
||||
:src="src"
|
||||
:alt="name"
|
||||
:text="initialsOf(name)"
|
||||
aria-hidden="true"
|
||||
|
||||
@@ -532,7 +532,11 @@ async function confirmUnlink() {
|
||||
|
||||
<template #preview>
|
||||
<div class="flex items-start gap-3 bg-elevated p-3">
|
||||
<AuthorAvatar :name="values.name || 'Author'" :size="40" />
|
||||
<AuthorAvatar
|
||||
:name="values.name || 'Author'"
|
||||
:avatar-id="values.avatarId"
|
||||
:size="40"
|
||||
/>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span class="text-xs text-muted">{{ t('Byline preview') }}</span>
|
||||
<span class="truncate font-semibold text-highlighted">{{
|
||||
|
||||
@@ -236,7 +236,11 @@ function rowClass(index: number) {
|
||||
@pointerup="onPointerUp"
|
||||
@pointercancel="endDrag"
|
||||
/>
|
||||
<AuthorAvatar :name="author.name" :size="avatarSize" />
|
||||
<AuthorAvatar
|
||||
:name="author.name"
|
||||
:avatar-id="author.avatar_id"
|
||||
:size="avatarSize"
|
||||
/>
|
||||
<span
|
||||
class="min-w-0 flex-1 truncate font-medium text-highlighted"
|
||||
:title="author.name"
|
||||
|
||||
@@ -35,6 +35,7 @@ const items = computed(() =>
|
||||
description: author.slug,
|
||||
value: author.id,
|
||||
avatarName: author.name,
|
||||
avatarId: author.avatar_id,
|
||||
class: chosenIds.value.includes(author.id) ? 'opacity-60' : undefined,
|
||||
}))
|
||||
: [],
|
||||
@@ -135,7 +136,12 @@ function createFromQuery() {
|
||||
<span class="flex-1 truncate text-left text-muted">{{ t('Add an author') }}</span>
|
||||
</template>
|
||||
<template #item-leading="{ item }">
|
||||
<AuthorAvatar v-if="'avatarName' in item" :name="String(item.avatarName)" :size="24" />
|
||||
<AuthorAvatar
|
||||
v-if="'avatarName' in item"
|
||||
:name="String(item.avatarName)"
|
||||
:avatar-id="'avatarId' in item ? item.avatarId : null"
|
||||
:size="24"
|
||||
/>
|
||||
</template>
|
||||
<template #empty>
|
||||
<div v-if="status === 'error'" class="flex flex-col items-start gap-2 p-2">
|
||||
|
||||
@@ -26,6 +26,7 @@ const extra = computed(() => Math.max(0, props.authors.length - props.max))
|
||||
v-for="author in shown"
|
||||
:key="author.id"
|
||||
:name="author.name"
|
||||
:avatar-id="author.avatar_id"
|
||||
:size="size"
|
||||
ring
|
||||
/>
|
||||
|
||||
@@ -16,7 +16,7 @@ const emit = defineEmits<{ close: [] }>()
|
||||
<template>
|
||||
<DirectoryPanel :title="author.name" read-only @close="emit('close')">
|
||||
<div class="flex items-center gap-3.5">
|
||||
<AuthorAvatar :name="author.name" :size="64" />
|
||||
<AuthorAvatar :name="author.name" :avatar-id="author.avatar_id" :size="64" />
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span class="truncate font-mono text-xs/4.5 text-muted">{{ author.slug }}</span>
|
||||
<div class="flex items-center gap-2 text-xs text-muted">
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import AuthorAvatar from '@/components/AuthorAvatar.vue'
|
||||
import DirectoryField from '@/components/DirectoryField.vue'
|
||||
import ImageUploader from '@/components/ImageUploader.vue'
|
||||
import MediaThumb from '@/components/MediaThumb.vue'
|
||||
import type { MediaItem } from '@/composables/useMedia'
|
||||
import { useMediaNames } from '@/composables/useMediaNames'
|
||||
import type { ImageChoice } from '@/lib/imageChoice'
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -25,6 +26,9 @@ const props = withDefaults(
|
||||
const imageId = defineModel<number | null>({ default: null })
|
||||
const item = defineModel<MediaItem | null>('item', { default: null })
|
||||
|
||||
const names = useMediaNames()
|
||||
const shownItem = computed(() => item.value ?? names.itemOf(imageId.value))
|
||||
|
||||
const pickerOpen = ref(false)
|
||||
const BUTTON_CLASS =
|
||||
'h-11 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-7'
|
||||
@@ -32,12 +36,21 @@ const BUTTON_CLASS =
|
||||
const hasImage = computed(() => imageId.value !== null)
|
||||
const chooseLabel = computed(() => (hasImage.value ? t('Change image') : t('Choose image')))
|
||||
const caption = computed(() => {
|
||||
if (item.value) {
|
||||
return item.value.filename
|
||||
if (shownItem.value) {
|
||||
return shownItem.value.filename
|
||||
}
|
||||
return imageId.value === null ? '' : t('Image #{0}', [imageId.value])
|
||||
})
|
||||
|
||||
function resolveMissing() {
|
||||
if (!item.value) {
|
||||
names.resolve([imageId.value])
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(resolveMissing)
|
||||
watch(imageId, resolveMissing)
|
||||
|
||||
function choose(picked: MediaItem) {
|
||||
item.value = picked
|
||||
imageId.value = picked.id
|
||||
@@ -54,8 +67,8 @@ function remove() {
|
||||
<div class="flex items-center gap-3">
|
||||
<template v-if="square">
|
||||
<MediaThumb
|
||||
v-if="item"
|
||||
:item="item"
|
||||
v-if="shownItem"
|
||||
:item="shownItem"
|
||||
size="md"
|
||||
class="size-16 border border-default"
|
||||
/>
|
||||
@@ -70,7 +83,7 @@ function remove() {
|
||||
: 'border-dashed border-accented bg-default'
|
||||
"
|
||||
>
|
||||
<MediaThumb v-if="item" :item="item" size="sm" class="size-full" />
|
||||
<MediaThumb v-if="shownItem" :item="shownItem" size="sm" class="size-full" />
|
||||
<UIcon v-else name="i-lucide-image" class="size-4.5" />
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
|
||||
@@ -58,7 +58,7 @@ const showHint = computed(
|
||||
)
|
||||
|
||||
function toByline(author: Author): Byline {
|
||||
return { id: author.id, name: author.name, slug: author.slug }
|
||||
return { id: author.id, name: author.name, slug: author.slug, avatar_id: author.avatar_id }
|
||||
}
|
||||
|
||||
function dismissHint() {
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface ReassignOption {
|
||||
description: string
|
||||
display: string
|
||||
avatarName?: string
|
||||
avatarId?: number | null
|
||||
none?: boolean
|
||||
}
|
||||
|
||||
@@ -100,7 +101,12 @@ const current = computed(() => props.options.find((option) => option.value === s
|
||||
>
|
||||
<UIcon name="i-lucide-user-x" class="size-3.5" />
|
||||
</span>
|
||||
<AuthorAvatar v-else-if="item.avatarName" :name="item.avatarName" :size="24" />
|
||||
<AuthorAvatar
|
||||
v-else-if="item.avatarName"
|
||||
:name="item.avatarName"
|
||||
:avatar-id="item.avatarId"
|
||||
:size="24"
|
||||
/>
|
||||
</template>
|
||||
</USelectMenu>
|
||||
<template v-if="summary && !selectOpen" #help>
|
||||
|
||||
@@ -117,6 +117,38 @@ describe('MediaField', () => {
|
||||
expect(wrapper.findComponent(MediaField).emitted('update:modelValue')![0]).toEqual([null])
|
||||
})
|
||||
|
||||
it('looks up the saved image by id and shows its filename when reopened without an item', async () => {
|
||||
const request = vi.fn().mockResolvedValue(ITEM)
|
||||
vi.mocked(useApi).mockReturnValue({ getMe: vi.fn(), request } as unknown as ReturnType<
|
||||
typeof useApi
|
||||
>)
|
||||
|
||||
await mountInApp(MediaField, { label: 'Avatar', picker: PICKER, modelValue: 55 })
|
||||
await flushPromises()
|
||||
|
||||
expect(request).toHaveBeenCalledWith('/v1/media/55')
|
||||
expect(text(document.body)).toContain('face.jpg')
|
||||
expect(text(document.body)).not.toContain('Image #55')
|
||||
expect(document.body.querySelector('[data-testid="media-thumb"] img')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('does not look the image up when the item is already known', async () => {
|
||||
const request = vi.fn()
|
||||
vi.mocked(useApi).mockReturnValue({ getMe: vi.fn(), request } as unknown as ReturnType<
|
||||
typeof useApi
|
||||
>)
|
||||
|
||||
await mountInApp(MediaField, {
|
||||
label: 'Avatar',
|
||||
picker: PICKER,
|
||||
modelValue: 55,
|
||||
item: ITEM,
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(request).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('draws the avatar variant as a square with initials when no image is chosen', async () => {
|
||||
await mountInApp(MediaField, {
|
||||
label: 'Avatar',
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { effectScope, nextTick, ref } from 'vue'
|
||||
|
||||
const request = vi.fn()
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: () => ({ request }) }
|
||||
})
|
||||
|
||||
import { useAvatarImage } from '@/composables/useAvatarImage'
|
||||
|
||||
async function settle() {
|
||||
await nextTick()
|
||||
await Promise.resolve()
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
describe('useAvatarImage', () => {
|
||||
beforeEach(() => {
|
||||
request.mockReset()
|
||||
request.mockImplementation(async (path: string) => ({
|
||||
id: Number(path.split('/').pop()),
|
||||
url: `/static/uploadedImages/${path.split('/').pop()}.jpg`,
|
||||
}))
|
||||
})
|
||||
|
||||
it('resolves the avatar id to the media url', async () => {
|
||||
const src = effectScope().run(() => useAvatarImage(() => 101))!
|
||||
|
||||
await settle()
|
||||
|
||||
expect(request).toHaveBeenCalledWith('/v1/media/101')
|
||||
expect(src.value).toBe('/static/uploadedImages/101.jpg')
|
||||
})
|
||||
|
||||
it('has no image and makes no request without an avatar id', async () => {
|
||||
const src = effectScope().run(() => useAvatarImage(() => null))!
|
||||
|
||||
await settle()
|
||||
|
||||
expect(request).not.toHaveBeenCalled()
|
||||
expect(src.value).toBeUndefined()
|
||||
})
|
||||
|
||||
it('shares one lookup between avatars of the same image', async () => {
|
||||
effectScope().run(() => {
|
||||
useAvatarImage(() => 102)
|
||||
useAvatarImage(() => 102)
|
||||
})
|
||||
|
||||
await settle()
|
||||
|
||||
expect(request).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('follows the id when the author picks another image', async () => {
|
||||
const id = ref(103)
|
||||
const src = effectScope().run(() => useAvatarImage(() => id.value))!
|
||||
await settle()
|
||||
|
||||
id.value = 104
|
||||
await settle()
|
||||
|
||||
expect(src.value).toBe('/static/uploadedImages/104.jpg')
|
||||
})
|
||||
|
||||
it('falls back to initials and does not retry when the lookup fails', async () => {
|
||||
request.mockRejectedValue(new Error('gone'))
|
||||
const src = effectScope().run(() => useAvatarImage(() => 105))!
|
||||
effectScope().run(() => useAvatarImage(() => 105))
|
||||
|
||||
await settle()
|
||||
|
||||
expect(src.value).toBeUndefined()
|
||||
expect(request).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
import { computed, reactive, watchEffect } from 'vue'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import type { MediaItem } from '@/composables/useMedia'
|
||||
|
||||
const urls = reactive<Record<number, string>>({})
|
||||
const pending = new Set<number>()
|
||||
const failed = new Set<number>()
|
||||
|
||||
function rememberAvatarImage(item: Pick<MediaItem, 'id' | 'url'>) {
|
||||
urls[item.id] = item.url
|
||||
}
|
||||
|
||||
export function useAvatarImage(avatarId: () => number | null | undefined) {
|
||||
const { request } = useApi()
|
||||
|
||||
async function lookUp(id: number) {
|
||||
pending.add(id)
|
||||
try {
|
||||
rememberAvatarImage(await request<MediaItem>(`/v1/media/${id}`))
|
||||
} catch {
|
||||
failed.add(id)
|
||||
} finally {
|
||||
pending.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
const id = avatarId()
|
||||
if (typeof id === 'number' && !(id in urls) && !pending.has(id) && !failed.has(id)) {
|
||||
lookUp(id)
|
||||
}
|
||||
})
|
||||
|
||||
return computed(() => {
|
||||
const id = avatarId()
|
||||
return typeof id === 'number' ? urls[id] : undefined
|
||||
})
|
||||
}
|
||||
@@ -33,5 +33,5 @@ export function sameIds(left: Byline[], right: Byline[]) {
|
||||
}
|
||||
|
||||
export function toBylines(authors: Byline[]): Byline[] {
|
||||
return authors.map(({ id, name, slug }) => ({ id, name, slug }))
|
||||
return authors.map(({ id, name, slug, avatar_id }) => ({ id, name, slug, avatar_id }))
|
||||
}
|
||||
@@ -2,4 +2,5 @@ export interface Byline {
|
||||
id: number
|
||||
name: string
|
||||
slug: string
|
||||
avatar_id?: number | null
|
||||
}
|
||||
@@ -357,6 +357,7 @@ async function loadDeleteOptions(author: Author) {
|
||||
description: candidate.slug,
|
||||
display: candidate.name,
|
||||
avatarName: candidate.name,
|
||||
avatarId: candidate.avatar_id,
|
||||
})),
|
||||
nobodyOption(),
|
||||
]
|
||||
@@ -668,7 +669,7 @@ const noResultsTitle = computed(() => t('No authors match "{0}"', [query.value.q
|
||||
>
|
||||
<template #cells="{ item }">
|
||||
<div class="flex min-w-0 items-center gap-2.5">
|
||||
<AuthorAvatar :name="item.name" :size="32" />
|
||||
<AuthorAvatar :name="item.name" :avatar-id="item.avatar_id" :size="32" />
|
||||
<span class="truncate font-medium text-highlighted" :title="item.name">{{
|
||||
item.name
|
||||
}}</span>
|
||||
@@ -680,7 +681,7 @@ const noResultsTitle = computed(() => t('No authors match "{0}"', [query.value.q
|
||||
<span class="text-muted">{{ pluralArticles(item.article_count) }}</span>
|
||||
</template>
|
||||
<template #card="{ item }">
|
||||
<AuthorAvatar :name="item.name" :size="40" />
|
||||
<AuthorAvatar :name="item.name" :avatar-id="item.avatar_id" :size="40" />
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-0.75">
|
||||
<span
|
||||
class="truncate text-md/5.25 font-medium text-highlighted"
|
||||
|
||||
@@ -49,6 +49,40 @@ function manyAuthors(count: number): Author[] {
|
||||
}))
|
||||
}
|
||||
|
||||
describe('Authors: avatar image', () => {
|
||||
const AVATAR_ID = 77
|
||||
const AVATAR_URL = '/static/uploadedImages/camille.jpg'
|
||||
|
||||
function backendWithAvatar() {
|
||||
const backend = createBackend()
|
||||
backend.authors = backend.authors.map((author) =>
|
||||
author.slug === 'camille-roche' ? { ...author, avatar_id: AVATAR_ID } : author,
|
||||
)
|
||||
backend.responders[`GET /v1/media/${AVATAR_ID}`] = () => ({
|
||||
id: AVATAR_ID,
|
||||
url: AVATAR_URL,
|
||||
})
|
||||
return backend
|
||||
}
|
||||
|
||||
it('draws the saved avatar in the row of the author that has one', async () => {
|
||||
await mountDirectory('authors', '/authors', { backend: backendWithAvatar() })
|
||||
await flushPromises()
|
||||
|
||||
const images = Array.from(
|
||||
document.body.querySelectorAll<HTMLImageElement>('[data-testid="directory-row"] img'),
|
||||
)
|
||||
expect(images.map((image) => image.getAttribute('src'))).toEqual([AVATAR_URL])
|
||||
})
|
||||
|
||||
it('keeps the initials for authors without an avatar', async () => {
|
||||
await mountDirectory('authors', '/authors')
|
||||
await flushPromises()
|
||||
|
||||
expect(document.body.querySelectorAll('[data-testid="directory-row"] img')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe.each(['owner', 'admin'])('Authors: default for %s', (role) => {
|
||||
it('shows the header, the search field, the columns and the rows', async () => {
|
||||
const backend = await mountDirectory('authors', '/authors', { role })
|
||||
|
||||
@@ -79,6 +79,31 @@ describe('Author list: an ordered list with the first author as the primary byli
|
||||
})
|
||||
})
|
||||
|
||||
describe('Author list: avatars', () => {
|
||||
const AVATAR_ID = 61
|
||||
const AVATAR_URL = '/static/uploadedImages/ines.jpg'
|
||||
|
||||
function rowImages() {
|
||||
return authorRows().map((row) => row.querySelector('img')?.getAttribute('src') ?? null)
|
||||
}
|
||||
|
||||
it('draws the avatar of an author loaded with the article and keeps the initials of the others', async () => {
|
||||
const backend = backendWith('Inès Duarte', 'Jonas Pereira')
|
||||
backend.article.authors = backend.article.authors!.map((author, index) =>
|
||||
index === 0 ? { ...author, avatar_id: AVATAR_ID } : author,
|
||||
)
|
||||
backend.responders[`GET /v1/media/${AVATAR_ID}`] = () => ({
|
||||
id: AVATAR_ID,
|
||||
url: AVATAR_URL,
|
||||
})
|
||||
|
||||
await mountMeta('editor', EDITOR, { backend })
|
||||
await flushPromises()
|
||||
|
||||
expect(rowImages()).toEqual([AVATAR_URL, null])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Author list: reorder with the keyboard', () => {
|
||||
async function mountThree() {
|
||||
const backend = backendWith('Inès Duarte', 'Jonas Pereira', 'Camille Roche')
|
||||
|
||||
Reference in new issue
Block a user