fix: the Latest feed's filter dropdowns opened empty
CI / lint (push) Successful in 2s
CI / extension-version (push) Successful in 2s
Build images / sign-extension (push) Successful in 3s
Build images / build-agent (push) Successful in 6s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 33s
Build images / build-web (push) Successful in 1m2s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 1m54s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m20s
CI / lint (push) Successful in 2s
CI / extension-version (push) Successful in 2s
Build images / sign-extension (push) Successful in 3s
Build images / build-agent (push) Successful in 6s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 33s
Build images / build-web (push) Successful in 1m2s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 1m54s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m20s
Operator: "the filters in the latest feed, drop down but don't have values". Two separate causes: - Platform: PostsFilterBar built its items from `platformsStore.platforms`. The platforms store has never had that property; it exposes `list` and `byKey`. The read returned undefined, `|| []` turned that into an empty list, and nothing failed. ArtistsView had copied the same read, so the Browse → Artists platform filter was empty too. Both now read `list` and show platform names rather than raw keys. - Artist: the autocomplete searched the server only after something was typed (autocomplete returns [] for an empty query by design, which its tests pin). Opening the dropdown therefore showed an empty menu. PostsFilterBar now loads every artist once from a new lightweight `GET /api/artists/names` (id, name, slug; alphabetical; no joins) and filters client-side, so the list is there on open. A deep-linked artist_id now also shows the artist's real name instead of "Artist #id". Guard: frontend/test/storeUsage.spec.js scans src for `platformsStore.<name>` and fails on any name the store doesn't define, since the frontend CI has no type-checker to catch this. A positive control shows the shipped `platformsStore.platforms` read is flagged, and a vacuity check confirms the scan really walks the tree. tests/test_api_artists_create.py covers /names. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SHQB1YukL3VyvMK8rcbmV9
This commit is contained in:
@@ -65,6 +65,16 @@ async def autocomplete():
|
|||||||
])
|
])
|
||||||
|
|
||||||
|
|
||||||
|
@artists_bp.route("/names", methods=["GET"])
|
||||||
|
async def names():
|
||||||
|
"""Every artist, id + name + slug, alphabetical. For filter pickers that
|
||||||
|
list artists before anything is typed; `autocomplete` deliberately returns
|
||||||
|
nothing for an empty query."""
|
||||||
|
async with get_session() as session:
|
||||||
|
rows = await ArtistService(session).all_names()
|
||||||
|
return jsonify([{"id": i, "name": n, "slug": s} for i, n, s in rows])
|
||||||
|
|
||||||
|
|
||||||
@artists_bp.route("/directory", methods=["GET"])
|
@artists_bp.route("/directory", methods=["GET"])
|
||||||
async def directory():
|
async def directory():
|
||||||
"""FC-3f: cursor-paginated artists directory.
|
"""FC-3f: cursor-paginated artists directory.
|
||||||
|
|||||||
@@ -300,6 +300,18 @@ class ArtistService:
|
|||||||
await self.session.commit()
|
await self.session.commit()
|
||||||
return artist
|
return artist
|
||||||
|
|
||||||
|
async def all_names(self) -> list[tuple[int, str, str]]:
|
||||||
|
"""Every artist as (id, name, slug), alphabetical.
|
||||||
|
|
||||||
|
For pickers that should show a full list before anything is typed (the
|
||||||
|
Latest feed's artist filter). Three columns and no joins, so it stays
|
||||||
|
cheap on a library of thousands of artists.
|
||||||
|
"""
|
||||||
|
rows = (await self.session.execute(
|
||||||
|
select(Artist.id, Artist.name, Artist.slug).order_by(func.lower(Artist.name))
|
||||||
|
)).all()
|
||||||
|
return [(r.id, r.name, r.slug) for r in rows]
|
||||||
|
|
||||||
async def autocomplete(self, prefix: str, limit: int = 20) -> list[Artist]:
|
async def autocomplete(self, prefix: str, limit: int = 20) -> list[Artist]:
|
||||||
cleaned = (prefix or "").strip()
|
cleaned = (prefix or "").strip()
|
||||||
if not cleaned:
|
if not cleaned:
|
||||||
|
|||||||
@@ -1,21 +1,22 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="fc-posts-filters">
|
<div class="fc-posts-filters">
|
||||||
|
<!-- The full artist list, filtered client-side as you type. This used to
|
||||||
|
search the server only once something was typed, so opening the
|
||||||
|
dropdown showed an empty menu. -->
|
||||||
<v-autocomplete
|
<v-autocomplete
|
||||||
v-model="artistModel"
|
v-model="artistModel"
|
||||||
:items="artistOptions"
|
:items="artistOptions"
|
||||||
:loading="artistLoading"
|
:loading="artistLoading"
|
||||||
:search="artistQuery"
|
|
||||||
item-title="name"
|
item-title="name"
|
||||||
item-value="id"
|
item-value="id"
|
||||||
label="Artist"
|
label="Artist"
|
||||||
density="compact"
|
density="compact"
|
||||||
hide-details
|
hide-details
|
||||||
clearable
|
clearable
|
||||||
no-filter
|
|
||||||
return-object
|
return-object
|
||||||
|
no-data-text="No matching artists"
|
||||||
class="fc-posts-filters__artist"
|
class="fc-posts-filters__artist"
|
||||||
@update:search="onArtistSearch"
|
@update:model-value="emitFilters"
|
||||||
@update:model-value="onArtistPicked"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<v-select
|
<v-select
|
||||||
@@ -39,7 +40,8 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import { useSourcesStore } from '../../stores/sources.js'
|
|
||||||
|
import { useApi } from '../../composables/useApi.js'
|
||||||
import { usePlatformsStore } from '../../stores/platforms.js'
|
import { usePlatformsStore } from '../../stores/platforms.js'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -48,18 +50,19 @@ const props = defineProps({
|
|||||||
})
|
})
|
||||||
const emit = defineEmits(['update:filters'])
|
const emit = defineEmits(['update:filters'])
|
||||||
|
|
||||||
const sourcesStore = useSourcesStore()
|
const api = useApi()
|
||||||
const platformsStore = usePlatformsStore()
|
const platformsStore = usePlatformsStore()
|
||||||
|
|
||||||
// Artist autocomplete state
|
|
||||||
const artistQuery = ref('')
|
|
||||||
const artistOptions = ref([])
|
const artistOptions = ref([])
|
||||||
const artistLoading = ref(false)
|
const artistLoading = ref(false)
|
||||||
const artistModel = ref(null)
|
const artistModel = ref(null)
|
||||||
|
|
||||||
// Platform v-select state
|
// `list`, not `platforms`: the store has never had a `platforms` property, so
|
||||||
|
// this read undefined and the dropdown opened empty.
|
||||||
|
// test/storeUsage.spec.js now fails on any read of a property the store does
|
||||||
|
// not define.
|
||||||
const platformItems = computed(() =>
|
const platformItems = computed(() =>
|
||||||
(platformsStore.platforms || []).map(p => ({ title: p.key, value: p.key }))
|
platformsStore.list.map(p => ({ title: p.name || p.key, value: p.key }))
|
||||||
)
|
)
|
||||||
const platformModel = ref(props.platform)
|
const platformModel = ref(props.platform)
|
||||||
|
|
||||||
@@ -67,29 +70,6 @@ const hasFilters = computed(
|
|||||||
() => artistModel.value != null || platformModel.value != null
|
() => artistModel.value != null || platformModel.value != null
|
||||||
)
|
)
|
||||||
|
|
||||||
let _searchTimer = null
|
|
||||||
function onArtistSearch(q) {
|
|
||||||
artistQuery.value = q
|
|
||||||
clearTimeout(_searchTimer)
|
|
||||||
if (!q || !q.trim()) {
|
|
||||||
artistOptions.value = artistModel.value ? [artistModel.value] : []
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_searchTimer = setTimeout(async () => {
|
|
||||||
artistLoading.value = true
|
|
||||||
try {
|
|
||||||
const results = await sourcesStore.autocompleteArtist(q, 20)
|
|
||||||
artistOptions.value = results
|
|
||||||
} finally {
|
|
||||||
artistLoading.value = false
|
|
||||||
}
|
|
||||||
}, 200)
|
|
||||||
}
|
|
||||||
|
|
||||||
function onArtistPicked() {
|
|
||||||
emitFilters()
|
|
||||||
}
|
|
||||||
|
|
||||||
function emitFilters() {
|
function emitFilters() {
|
||||||
emit('update:filters', {
|
emit('update:filters', {
|
||||||
artist_id: artistModel.value?.id ?? null,
|
artist_id: artistModel.value?.id ?? null,
|
||||||
@@ -100,30 +80,38 @@ function emitFilters() {
|
|||||||
function clearAll() {
|
function clearAll() {
|
||||||
artistModel.value = null
|
artistModel.value = null
|
||||||
platformModel.value = null
|
platformModel.value = null
|
||||||
artistOptions.value = []
|
|
||||||
artistQuery.value = ''
|
|
||||||
emitFilters()
|
emitFilters()
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
// A deep link carries only the id. Once the list is loaded, show the real name
|
||||||
await platformsStore.loadAll()
|
// rather than a placeholder.
|
||||||
// If a deep-link arrives with an artist_id, hydrate the selected display.
|
function selectArtistById(id) {
|
||||||
if (props.artistId != null) {
|
if (id == null) {
|
||||||
const seed = { id: props.artistId, name: `Artist #${props.artistId}` }
|
artistModel.value = null
|
||||||
artistModel.value = seed
|
return
|
||||||
artistOptions.value = [seed]
|
|
||||||
}
|
}
|
||||||
|
if (artistModel.value?.id === id) return
|
||||||
|
artistModel.value = artistOptions.value.find(a => a.id === id)
|
||||||
|
|| { id, name: `Artist #${id}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadArtists() {
|
||||||
|
artistLoading.value = true
|
||||||
|
try {
|
||||||
|
artistOptions.value = await api.get('/api/artists/names')
|
||||||
|
} catch {
|
||||||
|
artistOptions.value = []
|
||||||
|
} finally {
|
||||||
|
artistLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await Promise.all([platformsStore.loadAll(), loadArtists()])
|
||||||
|
selectArtistById(props.artistId)
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(() => props.artistId, (val) => {
|
watch(() => props.artistId, (val) => selectArtistById(val))
|
||||||
if (val == null) {
|
|
||||||
artistModel.value = null
|
|
||||||
} else if (artistModel.value?.id !== val) {
|
|
||||||
const seed = { id: val, name: `Artist #${val}` }
|
|
||||||
artistModel.value = seed
|
|
||||||
artistOptions.value = [seed]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(() => props.platform, (val) => {
|
watch(() => props.platform, (val) => {
|
||||||
if (platformModel.value !== val) platformModel.value = val || null
|
if (platformModel.value !== val) platformModel.value = val || null
|
||||||
|
|||||||
@@ -58,8 +58,9 @@ useInfiniteScroll(sentinelEl, () => {
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await platformsStore.loadAll()
|
await platformsStore.loadAll()
|
||||||
platformItems.value = (platformsStore.platforms || []).map(p => ({
|
// `list` — the store has no `platforms` property (test/storeUsage.spec.js).
|
||||||
title: p.key, value: p.key,
|
platformItems.value = platformsStore.list.map(p => ({
|
||||||
|
title: p.name || p.key, value: p.key,
|
||||||
}))
|
}))
|
||||||
// Apply any deep-linked q (and load). setQuery resets + fetches.
|
// Apply any deep-linked q (and load). setQuery resets + fetches.
|
||||||
store.setQuery(searchTerm.value || '')
|
store.setQuery(searchTerm.value || '')
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// Reads of a store property that does not exist.
|
||||||
|
//
|
||||||
|
// JavaScript returns `undefined` for a missing property instead of failing, and
|
||||||
|
// the frontend CI has no type-checker to catch it. `platformsStore.platforms`
|
||||||
|
// was read for months by the Latest feed's filter bar and the Artists view. The
|
||||||
|
// store exposes `list` and `byKey`, so both platform dropdowns opened empty and
|
||||||
|
// only someone clicking them noticed.
|
||||||
|
//
|
||||||
|
// This scans the source for `platformsStore.<name>` and checks each name
|
||||||
|
// against what the store really defines.
|
||||||
|
|
||||||
|
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { usePlatformsStore } from '../src/stores/platforms.js'
|
||||||
|
|
||||||
|
const SRC = fileURLToPath(new URL('../src', import.meta.url))
|
||||||
|
const READ = /\bplatformsStore\.([A-Za-z_$][\w$]*)/g
|
||||||
|
|
||||||
|
function sourceFiles (dir) {
|
||||||
|
return readdirSync(dir).flatMap((name) => {
|
||||||
|
const path = join(dir, name)
|
||||||
|
if (statSync(path).isDirectory()) return sourceFiles(path)
|
||||||
|
return /\.(vue|js)$/.test(name) ? [path] : []
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function unknownReads (text, known) {
|
||||||
|
return [...text.matchAll(READ)].map(m => m[1]).filter(name => !known.has(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
function storeKeys () {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
return new Set(Object.keys(usePlatformsStore()))
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('platforms store usage', () => {
|
||||||
|
it('every platformsStore.<name> read in src names something the store defines', () => {
|
||||||
|
const known = storeKeys()
|
||||||
|
const offenders = sourceFiles(SRC).flatMap((file) =>
|
||||||
|
unknownReads(readFileSync(file, 'utf8'), known)
|
||||||
|
.map(name => `${file.slice(SRC.length + 1)}: platformsStore.${name}`),
|
||||||
|
)
|
||||||
|
expect(offenders).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('the scan finds the read that shipped broken (positive control)', () => {
|
||||||
|
const known = storeKeys()
|
||||||
|
expect(known.has('list')).toBe(true)
|
||||||
|
expect(unknownReads('(platformsStore.platforms || []).map(p => p)', known))
|
||||||
|
.toEqual(['platforms'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('the scan actually walks the source tree', () => {
|
||||||
|
const hasRead = /\bplatformsStore\./
|
||||||
|
const withReads = sourceFiles(SRC).filter(f => hasRead.test(readFileSync(f, 'utf8')))
|
||||||
|
expect(withReads.length).toBeGreaterThan(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -72,3 +72,16 @@ async def test_patch_rename_validation(client):
|
|||||||
f"/api/artists/{created['id']}", json={})).status_code == 400
|
f"/api/artists/{created['id']}", json={})).status_code == 400
|
||||||
assert (await client.patch(
|
assert (await client.patch(
|
||||||
"/api/artists/999999", json={"name": "Ghost"})).status_code == 404
|
"/api/artists/999999", json={"name": "Ghost"})).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_names_lists_every_artist_alphabetically(client):
|
||||||
|
"""The Latest feed's artist filter shows this list before anything is typed.
|
||||||
|
`autocomplete` stays empty for an empty query; this is the full list."""
|
||||||
|
for name in ("zed", "Alice", "bob"):
|
||||||
|
await client.post("/api/artists", json={"name": name})
|
||||||
|
resp = await client.get("/api/artists/names")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = await resp.get_json()
|
||||||
|
assert [a["name"] for a in body] == ["Alice", "bob", "zed"]
|
||||||
|
assert set(body[0]) == {"id", "name", "slug"}
|
||||||
|
|||||||
Reference in New Issue
Block a user