Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion frontend/src/admin/LineupTab.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,16 @@ export default function LineupTab({ selectedEventId, selectedEvent, events, show
}
}, [selectedEventId, showToast])

// The roster API now returns inactive/retired profiles too (#619, so
// RosterTab can manage them) — filter them back out here so the lineup
// builder never offers a retired band as a schedulable option. Origin/genre
// suggestion lists below intentionally keep using the unfiltered `allBands`
// — reusing a retired band's origin/genre value is harmless.
const activeBands = useMemo(
() => allBands.filter(band => band.is_active !== 0 && band.is_active !== false),
[allBands]
)

const originCitySuggestions = useMemo(() => {
const values = new Set()
allBands.forEach(band => {
Expand Down Expand Up @@ -690,7 +700,7 @@ export default function LineupTab({ selectedEventId, selectedEvent, events, show

{viewMode === 'picker' && !readOnly && (
<ArtistPicker
artists={allBands}
artists={activeBands}
onSelect={handlePickerSelect}
onBulkSelect={handleBulkSelect}
onCancel={() => setViewMode('list')}
Expand Down
71 changes: 54 additions & 17 deletions frontend/src/admin/RosterTab.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,25 @@ function SortIcon({ col, sortConfig }) {
)
}

// The roster API now returns inactive/retired profiles alongside active ones
// (#619) — `is_active` comes straight off the D1 row (INTEGER NOT NULL
// DEFAULT 1), so it's always 0 or 1, but check both the number and legacy
// boolean shape defensively rather than relying on falsiness of `is_active`.
function isInactive(band) {
return band.is_active === 0 || band.is_active === false
}

// Same pill styling as the existing Status column below — reused here so an
// inactive profile is visible right next to its name too, not just in a
// column that can scroll out of view.
function InactiveBadge() {
return (
<span className="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold bg-gray-700 text-white/80">
Inactive
</span>
)
}

/**
* RosterTab - Manage Global Artist Roster (Band Profiles)
*
Expand All @@ -174,6 +193,10 @@ export default function RosterTab({ showToast, readOnly = false }) {
const [editingId, setEditingId] = useState(null)
const [sortConfig, setSortConfig] = useState({ key: 'name', direction: 'asc' })
const [searchTerm, setSearchTerm] = useState('')
// 'all' | 'active' | 'inactive' — defaults to 'all' (#619) so a retired
// profile is never hidden by default; the admin roster must be able to see
// and edit everything it manages.
const [statusFilter, setStatusFilter] = useState('all')

// Selection state for bulk actions
const [selectedIds, setSelectedIds] = useState(new Set())
Expand Down Expand Up @@ -250,9 +273,11 @@ export default function RosterTab({ showToast, readOnly = false }) {
}

const filteredBands = useMemo(() => {
if (!searchTerm.trim()) return bands
const query = searchTerm.trim().toLowerCase()
return bands.filter(band => {
if (statusFilter === 'active' && isInactive(band)) return false
if (statusFilter === 'inactive' && !isInactive(band)) return false
if (!query) return true
const originText = formatOrigin(band)
return (
band.name?.toLowerCase().includes(query) ||
Expand All @@ -261,15 +286,15 @@ export default function RosterTab({ showToast, readOnly = false }) {
band.contact_email?.toLowerCase().includes(query)
)
})
}, [bands, searchTerm])
}, [bands, searchTerm, statusFilter])

const sortedBands = useMemo(() => {
if (!sortConfig.key) return filteredBands

return [...filteredBands].sort((a, b) => {
if (sortConfig.key === 'is_active') {
const aVal = a.is_active === 0 || a.is_active === false ? 0 : 1
const bVal = b.is_active === 0 || b.is_active === false ? 0 : 1
const aVal = isInactive(a) ? 0 : 1
const bVal = isInactive(b) ? 0 : 1
return sortConfig.direction === 'asc' ? aVal - bVal : bVal - aVal
}
if (sortConfig.key === 'origin') {
Expand Down Expand Up @@ -565,6 +590,16 @@ export default function RosterTab({ showToast, readOnly = false }) {
placeholder="Search name, origin, genre"
className="min-h-[44px] px-3 py-2 rounded bg-bg-navy text-white border border-white/10 focus:border-accent-500 focus:outline-hidden w-64"
/>
<select
value={statusFilter}
onChange={e => setStatusFilter(e.target.value)}
aria-label="Filter by status"
className="min-h-[44px] px-3 py-2 rounded bg-bg-navy text-white border border-white/10 focus:border-accent-500 focus:outline-hidden"
>
<option value="all">All statuses</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
{!showAddForm && !editingId && !readOnly && (
<button
onClick={() => setShowAddForm(true)}
Expand Down Expand Up @@ -703,26 +738,27 @@ export default function RosterTab({ showToast, readOnly = false }) {
</td>
)}
<td className="px-4 py-3 text-white font-medium">
<a
href={`/band/${band.band_profile_id || band.id?.toString().replace('profile_', '')}`}
className="text-accent-400 hover:underline"
target="_blank"
rel="noreferrer"
>
{band.name}
</a>
<div className="flex items-center gap-2">
<a
href={`/band/${band.band_profile_id || band.id?.toString().replace('profile_', '')}`}
className="text-accent-400 hover:underline"
target="_blank"
rel="noreferrer"
>
{band.name}
</a>
{isInactive(band) && <InactiveBadge />}
</div>
</td>
<td className="px-4 py-3 text-white/70">{formatOrigin(band) || '-'}</td>
<td className="px-4 py-3 text-white/70">{band.genre || '-'}</td>
<td className="px-4 py-3 text-white/70">
<span
className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-semibold ${
band.is_active === 0 || band.is_active === false
? 'bg-gray-700 text-white/80'
: 'bg-emerald-600/20 text-emerald-200'
isInactive(band) ? 'bg-gray-700 text-white/80' : 'bg-emerald-600/20 text-emerald-200'
}`}
>
{band.is_active === 0 || band.is_active === false ? 'Inactive' : 'Active'}
{isInactive(band) ? 'Inactive' : 'Active'}
</span>
</td>
<td className="px-4 py-3">
Expand Down Expand Up @@ -797,12 +833,13 @@ export default function RosterTab({ showToast, readOnly = false }) {
>
{band.name}
</a>
{isInactive(band) && <InactiveBadge />}
</label>
</div>
<div className="text-sm text-text-secondary space-y-1">
<div>Origin: {formatOrigin(band) || '-'}</div>
<div>Genre: {band.genre || '-'}</div>
<div>Status: {band.is_active === 0 || band.is_active === false ? 'Inactive' : 'Active'}</div>
<div>Status: {isInactive(band) ? 'Inactive' : 'Active'}</div>
<div className="flex items-center gap-2">
<span>Links:</span>
<SocialLinksIcons band={band} />
Expand Down
80 changes: 80 additions & 0 deletions frontend/src/admin/__tests__/RosterTab.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import RosterTab from '../RosterTab'

// RosterTab renders both a desktop <table> and a mobile card list
// unconditionally (visibility is CSS-only via `hidden md:block` /
// `md:hidden`), so any text query below matches twice — hence
// getAllByText/queryAllByText throughout rather than getByText.
vi.mock('../../utils/adminApi', () => ({
bandsApi: {
getAll: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
bulkDelete: vi.fn(),
},
}))

import { bandsApi } from '../../utils/adminApi'

// Deactivated profile (#619) — e.g. The Essential Letdowns, retired after
// their farewell show, but the admin roster still has to manage them.
const INACTIVE_BAND = {
id: 'profile_98',
band_profile_id: 98,
name: 'The Essential Letdowns',
genre: 'punk',
origin_city: 'Kitchener',
origin_region: 'ON',
is_active: 0,
follower_count: 0,
social_links: '{}',
}

const ACTIVE_BAND = {
id: 'profile_1',
band_profile_id: 1,
name: 'Active Aardvarks',
genre: 'rock',
origin_city: 'Waterloo',
origin_region: 'ON',
is_active: 1,
follower_count: 0,
social_links: '{}',
}

describe('RosterTab — inactive profile visibility (#619)', () => {
it('shows an inactive profile with an Inactive badge, included by default (status filter = All)', async () => {
bandsApi.getAll.mockResolvedValue({ bands: [ACTIVE_BAND, INACTIVE_BAND] })
render(<RosterTab showToast={vi.fn()} />)

expect(await screen.findAllByText('The Essential Letdowns')).not.toHaveLength(0)
expect(screen.getAllByText('Active Aardvarks').length).toBeGreaterThan(0)
// Inactive badge next to the name (plus the existing Status column) —
// both render for the retired band.
expect(screen.getAllByText('Inactive').length).toBeGreaterThan(0)
})

it('the Active filter hides inactive profiles', async () => {
bandsApi.getAll.mockResolvedValue({ bands: [ACTIVE_BAND, INACTIVE_BAND] })
render(<RosterTab showToast={vi.fn()} />)
await screen.findAllByText('Active Aardvarks')

fireEvent.change(screen.getByLabelText('Filter by status'), { target: { value: 'active' } })

expect(screen.queryAllByText('The Essential Letdowns')).toHaveLength(0)
expect(screen.getAllByText('Active Aardvarks').length).toBeGreaterThan(0)
})

it('the Inactive filter shows only inactive profiles', async () => {
bandsApi.getAll.mockResolvedValue({ bands: [ACTIVE_BAND, INACTIVE_BAND] })
render(<RosterTab showToast={vi.fn()} />)
await screen.findAllByText('Active Aardvarks')

fireEvent.change(screen.getByLabelText('Filter by status'), { target: { value: 'inactive' } })

expect(screen.queryAllByText('Active Aardvarks')).toHaveLength(0)
expect(screen.getAllByText('The Essential Letdowns').length).toBeGreaterThan(0)
})
})
38 changes: 24 additions & 14 deletions functions/api/admin/bands.js
Original file line number Diff line number Diff line change
Expand Up @@ -151,10 +151,11 @@ export async function onRequestGet(context) {
// limit/offset only apply to the no-event_id (roster) branch below — the
// event_id branch returns a single event's full lineup, unpaginated.
// Default is 500 (the existing cap), not 200: since #618, this branch
// returns one row per active band_profile, and prod already has ~220
// active profiles — a 200 default would silently truncate the roster
// again. Callers (RosterTab, LineupTab's ArtistPicker) request no limit,
// so they get whatever the default is.
// returns one row per band_profile (active and inactive, #619), and prod
// already has ~218 active profiles plus a handful of inactive ones — a 200
// default would silently truncate the roster again. Callers (RosterTab,
// LineupTab's ArtistPicker) request no limit, so they get whatever the
// default is.
const requestedLimit = Number.parseInt(url.searchParams.get("limit") || "500", 10);
const requestedOffset = Number.parseInt(url.searchParams.get("offset") || "0", 10);
const limit = Number.isFinite(requestedLimit) ? Math.min(Math.max(requestedLimit, 1), 500) : 500;
Expand Down Expand Up @@ -202,17 +203,27 @@ export async function onRequestGet(context) {
.bind(eventId)
.all();
} else {
// List all active band PROFILES (roster view), one row per profile (#618).
// A profile can have zero, one, or many performances across many events;
// the old query LEFT JOINed to performances with no GROUP BY, so a band
// with N performances produced N rows. Combined with `ORDER BY e.date
// DESC` + `LIMIT`, any profile whose only performances were on older
// events could sort past the limit and vanish entirely (#618 — Adelleda,
// whose sole performance was a 2024 event, was missing from the roster).
// GROUP BY bp.id collapses that back to one row per profile, so the
// LIMIT now bounds roster size (~220 active profiles in prod), not
// List ALL band PROFILES — active and inactive (#619) — one row per
// profile (#618). A profile can have zero, one, or many performances
// across many events; the old query LEFT JOINed to performances with no
// GROUP BY, so a band with N performances produced N rows. Combined
// with `ORDER BY e.date DESC` + `LIMIT`, any profile whose only
// performances were on older events could sort past the limit and
// vanish entirely (#618 — Adelleda, whose sole performance was a 2024
// event, was missing from the roster). GROUP BY bp.id collapses that
// back to one row per profile, so the LIMIT now bounds roster size
// (~218 active + a handful of inactive profiles in prod), not
// performance-row count.
//
// Deliberately no `WHERE bp.is_active = 1` here (#619): the admin
// roster is the tool that manages profiles, so it must be able to see
// and edit retired ones too — filtering them out made them completely
// uneditable through the UI. The row already selects bp.is_active, so
// the client (RosterTab) badges and filters on it instead of the
// server hiding rows. The public /artists archive was already correct
// (it never filtered on is_active); this brings the admin roster in
// line with it.
//
// The per-performance columns (event/venue name, event date, etc.) are
// kept for a "last played" display, sourced from the band's MOST RECENT
// performance: SQLite's bare-column-with-MAX() semantics say that when a
Expand Down Expand Up @@ -256,7 +267,6 @@ export async function onRequestGet(context) {
LEFT JOIN performances p ON bp.id = p.band_profile_id
LEFT JOIN venues v ON p.venue_id = v.id
LEFT JOIN events e ON p.event_id = e.id
WHERE bp.is_active = 1
GROUP BY bp.id
ORDER BY event_date DESC, bp.name
LIMIT ?
Expand Down
22 changes: 16 additions & 6 deletions functions/api/admin/bands/__tests__/bands.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ describe("Admin bands API - CRUD operations", () => {
expect(row.is_active).toBe(0);
});

it("GET without event_id excludes inactive bands from the lineup-builder picker", async () => {
it("GET without event_id includes inactive bands the lineup-builder picker now filters them client-side (#619)", async () => {
const { env, rawDb, headers } = createTestEnv({ role: "editor" });

rawDb
Expand All @@ -134,9 +134,13 @@ describe("Admin bands API - CRUD operations", () => {
expect(getRes.status).toBe(200);
const data = await getRes.json();

// This endpoint now returns every profile so the admin roster (RosterTab)
// can see and edit retired ones (#619). LineupTab's ArtistPicker consumes
// the same response, so it filters `is_active` back out client-side
// (LineupTab.jsx's `activeBands`) to keep retired bands unschedulable.
const names = data.bands.map((b) => b.name);
expect(names).toContain("Active Band");
expect(names).not.toContain("Inactive Band");
expect(names).toContain("Inactive Band");
});

it("PUT /api/admin/bands/{id} still rejects set-time edits for a performance in an archived event", async () => {
Expand Down Expand Up @@ -242,7 +246,7 @@ describe("Admin bands API - GET without event_id returns one row per profile (#6
expect(matches[0].event_date).toBe("2025-12-01");
});

it("excludes inactive profiles from the roster branch (#619 deferred — surfacing them is out of scope here)", async () => {
it("includes inactive profiles in the roster branch with is_active: 0 on the row (#619)", async () => {
const { env, rawDb, headers } = createTestEnv({ role: "editor" });
rawDb
.prepare("INSERT INTO band_profiles (name, name_normalized, is_active) VALUES (?, ?, ?)")
Expand All @@ -256,9 +260,15 @@ describe("Admin bands API - GET without event_id returns one row per profile (#6
expect(getRes.status).toBe(200);
const data = await getRes.json();

const names = data.bands.map((b) => b.name);
expect(names).toContain("Active Roster Band 618");
expect(names).not.toContain("Retired Roster Band 618");
// The admin roster manages profiles including retired ones (#619), so both
// rows must be present — with is_active reflecting each profile's real state
// so the client can badge/filter on it instead of the server hiding rows.
const active = data.bands.find((b) => b.name === "Active Roster Band 618");
const retired = data.bands.find((b) => b.name === "Retired Roster Band 618");
expect(active).toBeDefined();
expect(active.is_active).toBe(1);
expect(retired).toBeDefined();
expect(retired.is_active).toBe(0);
});
});

Expand Down
Loading