Removing collection metadata.

This commit is contained in:
Roland 2025-12-05 17:12:21 -07:00
parent 9cb6594ca5
commit a6314bb71c
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
17 changed files with 493 additions and 533 deletions

View file

@ -71,6 +71,45 @@ def upgrade():
op.drop_column('download', 'channel_id')
print("Done\n")
# Step 6: Fix the update_channel_minimum_frequency trigger function
# It was referencing channel_id which no longer exists
print("Step 6: Updating update_channel_minimum_frequency trigger function...")
op.execute("""
CREATE OR REPLACE FUNCTION update_channel_minimum_frequency()
RETURNS TRIGGER AS
$$
DECLARE
v_channel_id INTEGER;
BEGIN
-- Handle INSERT and UPDATE: use NEW record
IF (TG_OP = 'DELETE') THEN
-- For DELETE, we need to find the channel from OLD record
IF OLD.collection_id IS NOT NULL THEN
SELECT id INTO v_channel_id FROM channel WHERE collection_id = OLD.collection_id;
IF v_channel_id IS NOT NULL THEN
UPDATE channel
SET minimum_frequency = (SELECT MIN(frequency) FROM download WHERE collection_id = OLD.collection_id)
WHERE id = v_channel_id;
END IF;
END IF;
RETURN OLD;
ELSE
-- For INSERT and UPDATE, use NEW record
IF NEW.collection_id IS NOT NULL THEN
SELECT id INTO v_channel_id FROM channel WHERE collection_id = NEW.collection_id;
IF v_channel_id IS NOT NULL THEN
UPDATE channel
SET minimum_frequency = (SELECT MIN(frequency) FROM download WHERE collection_id = NEW.collection_id)
WHERE id = v_channel_id;
END IF;
END IF;
RETURN NEW;
END IF;
END;
$$ LANGUAGE plpgsql;
""")
print("Done\n")
print("=" * 60)
print("Migration Complete")
print("=" * 60 + "\n")
@ -83,6 +122,20 @@ def downgrade():
bind = op.get_bind()
session = Session(bind=bind)
# Restore the old trigger function that uses channel_id
op.execute("""
CREATE OR REPLACE FUNCTION update_channel_minimum_frequency()
RETURNS TRIGGER AS
$$
BEGIN
UPDATE channel
SET minimum_frequency = (SELECT MIN(frequency) FROM download WHERE channel_id = NEW.channel_id)
WHERE id = NEW.channel_id;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
""")
# Add back channel_id column
op.add_column('download', sa.Column('channel_id', sa.Integer(), nullable=True))

View file

@ -722,7 +722,7 @@ export async function fetchDomains() {
const response = await apiGet(`${COLLECTIONS_API}?kind=domain`);
if (response.ok) {
let data = await response.json();
return [data['collections'], data['totals']['collections'], data['metadata']];
return [data['collections'], data['totals']['collections']];
} else {
const message = await getErrorMessage(response, 'Unable to fetch Domains. See server logs.');
toast({
@ -738,7 +738,7 @@ export async function fetchChannels() {
const response = await apiGet(`${COLLECTIONS_API}?kind=channel`);
if (response.ok) {
let data = await response.json();
return [data['collections'], data['totals']['collections'], data['metadata']];
return [data['collections'], data['totals']['collections']];
} else {
const message = await getErrorMessage(response, 'Unable to fetch Channels. See server logs.');
toast({

View file

@ -38,7 +38,6 @@ import {Link, Route, Routes, useLocation, useNavigate, useParams} from "react-ro
import Message from "semantic-ui-react/dist/commonjs/collections/Message";
import {
useArchive,
useCollectionMetadata,
useDomain,
useDomains,
useOneQuery,
@ -356,10 +355,27 @@ export function ArchiveCard({file}) {
</Card>
}
// Domain table column configuration
const DOMAIN_COLUMNS = [
{key: 'domain', label: 'Domain', sortable: true, width: 7},
{key: 'tag_name', label: 'Tag', sortable: true, width: 2},
{key: 'archive_count', label: 'Archives', sortable: true, align: 'right', width: 2},
{key: 'min_download_frequency', label: 'Download Frequency', sortable: true, format: 'frequency', width: 2, hideOnMobile: true},
{key: 'size', label: 'Size', sortable: true, align: 'right', format: 'bytes', width: 2, hideOnMobile: true},
{key: 'actions', label: 'Manage', sortable: false, type: 'actions', width: 1}
];
const DOMAIN_ROUTES = {
list: '/archive/domains',
edit: '/archive/domain/:id/edit',
search: '/archive',
searchParam: 'domain'
};
export function DomainsPage() {
useTitle('Archive Domains');
const [domains, total, metadata] = useDomains();
const [domains] = useDomains();
const [searchStr, setSearchStr] = useOneQuery('domain');
// Header section matching ChannelsPage pattern
@ -406,30 +422,33 @@ export function DomainsPage() {
{header}
<CollectionTable
collections={domains}
metadata={metadata}
columns={DOMAIN_COLUMNS}
routes={DOMAIN_ROUTES}
searchStr={searchStr}
/>
</>;
}
// Domain form field configuration
const DOMAIN_FIELDS = [
{key: 'directory', label: 'Directory', type: 'directory', placeholder: 'Optional directory path'},
// tag_name field is handled via modal button, not inline in form
{key: 'description', label: 'Description', type: 'textarea', placeholder: 'Optional description'}
];
const DOMAIN_MESSAGES = {
no_directory: 'Set a directory to enable tagging',
tag_will_move: 'Tagging will move files to a new directory'
};
export function DomainEditPage() {
const {domainId} = useParams();
const navigate = useNavigate();
const {domain, form, fetchDomain} = useDomain(parseInt(domainId));
const {metadata} = useCollectionMetadata('domain');
// Modal state for tagging
const [tagEditModalOpen, setTagEditModalOpen] = useState(false);
// Filter out tag_name field - we use the modal button instead of inline selector
const filteredMetadata = React.useMemo(() => {
if (!metadata) return metadata;
return {
...metadata,
fields: metadata.fields.filter(field => field.key !== 'tag_name')
};
}, [metadata]);
useTitle(`Edit Domain: ${domain?.domain || '...'}`);
// Wrap form.onSubmit to add toast and refresh domain data
@ -546,7 +565,8 @@ export function DomainEditPage() {
<BackButton/>
<CollectionEditForm
form={form}
metadata={filteredMetadata}
fields={DOMAIN_FIELDS}
messages={DOMAIN_MESSAGES}
title={`Edit Domain: ${domain?.domain || '...'}`}
wrolModeContent='Domain editing is disabled while in WROL Mode.'
actionButtons={actionButtons}

View file

@ -39,6 +39,22 @@ import {InputForm, ToggleForm} from "../hooks/useForm";
import {ChannelDownloadForm, DestinationForm, DownloadTagsSelector} from "./Download";
import {CollectionTable} from "./collections/CollectionTable";
// Channel table column configuration
const CHANNEL_COLUMNS = [
{key: 'name', label: 'Name', sortable: true, width: 7},
{key: 'tag_name', label: 'Tag', sortable: true, width: 2},
{key: 'video_count', label: 'Videos', sortable: true, align: 'right', width: 2},
{key: 'min_download_frequency', label: 'Download Frequency', sortable: true, format: 'frequency', width: 2, hideOnMobile: true},
{key: 'total_size', label: 'Size', sortable: true, align: 'right', format: 'bytes', width: 2, hideOnMobile: true},
{key: 'actions', label: 'Manage', sortable: false, type: 'actions', width: 1}
];
const CHANNEL_ROUTES = {
list: '/videos/channel',
edit: '/videos/channel/:id/edit',
search: '/videos/channel/:id/video',
id_field: 'channel_id'
};
function ChannelStatistics({statistics}) {
if (!statistics) {
@ -451,7 +467,7 @@ export function ChannelNewPage(props) {
export function ChannelsPage() {
useTitle('Channels');
const [channels, total, metadata] = useChannels();
const [channels] = useChannels();
const [searchStr, setSearchStr] = useOneQuery('name');
// Header section matching DomainsPage pattern
@ -501,7 +517,8 @@ export function ChannelsPage() {
{header}
<CollectionTable
collections={channels}
metadata={metadata}
columns={CHANNEL_COLUMNS}
routes={CHANNEL_ROUTES}
searchStr={searchStr}
/>
</>;

View file

@ -2,7 +2,7 @@ import React from 'react';
import {render, renderInDarkMode, screen, waitFor, createTestForm} from '../test-utils';
import userEvent from '@testing-library/user-event';
import {DomainEditPage} from './Archive';
import {createMockDomain, createMockMetadata} from '../test-utils';
import {createMockDomain} from '../test-utils';
// Mock useParams to return domain ID
jest.mock('react-router-dom', () => ({
@ -13,12 +13,10 @@ jest.mock('react-router-dom', () => ({
// Mock the useDomain hook
const mockUseDomain = jest.fn();
const mockUseCollectionMetadata = jest.fn();
jest.mock('../hooks/customHooks', () => ({
...jest.requireActual('../hooks/customHooks'),
useDomain: (...args) => mockUseDomain(...args),
useCollectionMetadata: (...args) => mockUseCollectionMetadata(...args),
}));
// Mock useTitle
@ -29,12 +27,12 @@ jest.mock('./Common', () => ({
// Mock CollectionEditForm
jest.mock('./collections/CollectionEditForm', () => ({
CollectionEditForm: ({form, metadata, title, actionButtons}) => (
CollectionEditForm: ({form, fields, title, actionButtons}) => (
<div data-testid="collection-edit-form">
{title && <h1>{title}</h1>}
{form?.loading && <div data-testid="loading-indicator">Loading...</div>}
{form?.formData && <div data-testid="collection-data">Collection data loaded</div>}
{metadata && <div data-testid="metadata-present">Metadata loaded</div>}
{fields && <div data-testid="fields-present">Fields configured</div>}
{actionButtons && <div data-testid="action-buttons">{actionButtons}</div>}
</div>
),
@ -64,11 +62,8 @@ jest.mock('../api', () => ({
}));
describe('DomainEditPage', () => {
const mockMetadata = createMockMetadata();
beforeEach(() => {
jest.clearAllMocks();
mockUseCollectionMetadata.mockReturnValue({metadata: mockMetadata});
});
describe('Loading States', () => {

View file

@ -2,7 +2,7 @@ import React from 'react';
import {render, screen, createTestForm} from '../test-utils';
import {CollectionEditForm} from './collections/CollectionEditForm';
import {DomainsPage} from './Archive';
import {createMockDomain, createMockMetadata, createMockDomains} from '../test-utils';
import {createMockDomain, createMockDomains} from '../test-utils';
// Mock the TagsSelector component and TagsContext
jest.mock('../Tags', () => ({
@ -90,9 +90,19 @@ jest.mock('./collections/CollectionTable', () => ({
),
}));
describe('Domain Tagging Logic', () => {
const mockMetadata = createMockMetadata();
// Test field configurations
const DOMAIN_FIELDS = [
{key: 'directory', label: 'Directory', type: 'directory', placeholder: 'Optional directory path'},
{key: 'tag_name', label: 'Tag', type: 'tag', placeholder: 'Select or create tag', depends_on: 'directory'},
{key: 'description', label: 'Description', type: 'textarea', placeholder: 'Optional description'}
];
const DOMAIN_MESSAGES = {
no_directory: 'Set a directory to enable tagging',
tag_will_move: 'Tagging will move files to a new directory'
};
describe('Domain Tagging Logic', () => {
describe('Tag Field Dependency on Directory', () => {
it('prevents tagging domain without directory', () => {
const domainWithoutDirectory = createMockDomain({
@ -106,7 +116,8 @@ describe('Domain Tagging Logic', () => {
render(
<CollectionEditForm
form={form}
metadata={mockMetadata}
fields={DOMAIN_FIELDS}
messages={DOMAIN_MESSAGES}
/>
);
@ -133,7 +144,8 @@ describe('Domain Tagging Logic', () => {
render(
<CollectionEditForm
form={form}
metadata={mockMetadata}
fields={DOMAIN_FIELDS}
messages={DOMAIN_MESSAGES}
/>
);
@ -160,7 +172,8 @@ describe('Domain Tagging Logic', () => {
render(
<CollectionEditForm
form={form}
metadata={mockMetadata}
fields={DOMAIN_FIELDS}
messages={DOMAIN_MESSAGES}
/>
);
@ -205,7 +218,7 @@ describe('Domain Tagging Logic', () => {
}),
];
mockUseDomains.mockReturnValue([mockDomains, 2, mockMetadata]);
mockUseDomains.mockReturnValue([mockDomains, 2]);
render(<DomainsPage />);
@ -241,7 +254,7 @@ describe('Domain Tagging Logic', () => {
}),
];
mockUseDomains.mockReturnValue([mockDomains, 3, mockMetadata]);
mockUseDomains.mockReturnValue([mockDomains, 3]);
render(<DomainsPage />);
@ -265,7 +278,8 @@ describe('Domain Tagging Logic', () => {
render(
<CollectionEditForm
form={form}
metadata={mockMetadata}
fields={DOMAIN_FIELDS}
messages={DOMAIN_MESSAGES}
/>
);
@ -285,7 +299,8 @@ describe('Domain Tagging Logic', () => {
render(
<CollectionEditForm
form={form}
metadata={mockMetadata}
fields={DOMAIN_FIELDS}
messages={DOMAIN_MESSAGES}
/>
);

View file

@ -1,7 +1,7 @@
import React from 'react';
import {render, screen, waitFor} from '../test-utils';
import {DomainsPage} from './Archive';
import {createMockDomains, createMockMetadata} from '../test-utils';
import {createMockDomains} from '../test-utils';
// Mock the custom hooks
jest.mock('../hooks/customHooks', () => ({
@ -12,10 +12,11 @@ jest.mock('../hooks/customHooks', () => ({
// Mock CollectionTable component
jest.mock('./collections/CollectionTable', () => ({
CollectionTable: ({collections, metadata, searchStr}) => (
CollectionTable: ({collections, columns, routes, searchStr}) => (
<div data-testid="collection-table">
<div data-testid="collection-count">{collections?.length || 0}</div>
<div data-testid="search-filter">{searchStr}</div>
<div data-testid="columns-count">{columns?.length || 0}</div>
{collections?.map((domain) => (
<div key={domain.id} data-testid={`domain-${domain.id}`}>
<span data-testid={`domain-name-${domain.id}`}>{domain.domain}</span>
@ -47,7 +48,6 @@ jest.mock('./Common', () => ({
describe('DomainsPage', () => {
const {useDomains, useOneQuery} = require('../hooks/customHooks');
const {useTitle} = require('./Common');
const mockMetadata = createMockMetadata();
beforeEach(() => {
// Reset mocks before each test
@ -61,7 +61,7 @@ describe('DomainsPage', () => {
describe('Page Rendering', () => {
it('displays domains page without errors', () => {
const mockDomains = createMockDomains(3);
useDomains.mockReturnValue([mockDomains, 3, mockMetadata]);
useDomains.mockReturnValue([mockDomains, 3]);
render(<DomainsPage />);
@ -70,18 +70,20 @@ describe('DomainsPage', () => {
expect(screen.getByTestId('collection-table')).toBeInTheDocument();
});
it('renders CollectionTable component', () => {
it('renders CollectionTable component with columns config', () => {
const mockDomains = createMockDomains(2);
useDomains.mockReturnValue([mockDomains, 2, mockMetadata]);
useDomains.mockReturnValue([mockDomains, 2]);
render(<DomainsPage />);
expect(screen.getByTestId('collection-table')).toBeInTheDocument();
// Should have 6 columns defined in DOMAIN_COLUMNS
expect(screen.getByTestId('columns-count')).toHaveTextContent('6');
});
it('shows search input', () => {
const mockDomains = createMockDomains(1);
useDomains.mockReturnValue([mockDomains, 1, mockMetadata]);
useDomains.mockReturnValue([mockDomains, 1]);
render(<DomainsPage />);
@ -94,7 +96,7 @@ describe('DomainsPage', () => {
describe('Domain Display', () => {
it('shows all domains from API', () => {
const mockDomains = createMockDomains(5);
useDomains.mockReturnValue([mockDomains, 5, mockMetadata]);
useDomains.mockReturnValue([mockDomains, 5]);
render(<DomainsPage />);
@ -112,7 +114,7 @@ describe('DomainsPage', () => {
{id: 2, domain: 'example2.com', archive_count: 20, size: 2000},
{id: 3, domain: 'example3.com', archive_count: 30, size: 3000},
];
useDomains.mockReturnValue([mockDomains, 3, mockMetadata]);
useDomains.mockReturnValue([mockDomains, 3]);
render(<DomainsPage />);
@ -123,7 +125,7 @@ describe('DomainsPage', () => {
it('displays Edit buttons in Manage column', () => {
const mockDomains = createMockDomains(3);
useDomains.mockReturnValue([mockDomains, 3, mockMetadata]);
useDomains.mockReturnValue([mockDomains, 3]);
render(<DomainsPage />);
@ -135,7 +137,7 @@ describe('DomainsPage', () => {
it('Edit button has correct styling', () => {
const mockDomains = createMockDomains(1);
useDomains.mockReturnValue([mockDomains, 1, mockMetadata]);
useDomains.mockReturnValue([mockDomains, 1]);
render(<DomainsPage />);
@ -150,7 +152,7 @@ describe('DomainsPage', () => {
describe('Empty and Error States', () => {
it('shows "No items yet" message when no domains', () => {
// Empty array indicates no domains
useDomains.mockReturnValue([[], 0, mockMetadata]);
useDomains.mockReturnValue([[], 0]);
render(<DomainsPage />);
@ -164,7 +166,7 @@ describe('DomainsPage', () => {
it('shows error message when fetch fails', () => {
// undefined indicates error state
useDomains.mockReturnValue([undefined, 0, mockMetadata]);
useDomains.mockReturnValue([undefined, 0]);
render(<DomainsPage />);
@ -178,7 +180,7 @@ describe('DomainsPage', () => {
it('does not show "New Domain" button', () => {
const mockDomains = createMockDomains(2);
useDomains.mockReturnValue([mockDomains, 2, mockMetadata]);
useDomains.mockReturnValue([mockDomains, 2]);
render(<DomainsPage />);
@ -191,7 +193,7 @@ describe('DomainsPage', () => {
describe('Search Integration', () => {
it('disables search when no domains', () => {
useDomains.mockReturnValue([[], 0, mockMetadata]);
useDomains.mockReturnValue([[], 0]);
render(<DomainsPage />);
@ -201,7 +203,7 @@ describe('DomainsPage', () => {
it('enables search when domains exist', () => {
const mockDomains = createMockDomains(3);
useDomains.mockReturnValue([mockDomains, 3, mockMetadata]);
useDomains.mockReturnValue([mockDomains, 3]);
render(<DomainsPage />);
@ -211,7 +213,7 @@ describe('DomainsPage', () => {
it('passes search string to CollectionTable', () => {
const mockDomains = createMockDomains(2);
useDomains.mockReturnValue([mockDomains, 2, mockMetadata]);
useDomains.mockReturnValue([mockDomains, 2]);
const mockSetSearchStr = jest.fn();
useOneQuery.mockReturnValue(['example', mockSetSearchStr]);
@ -226,7 +228,7 @@ describe('DomainsPage', () => {
describe('Page Title', () => {
it('sets page title correctly', () => {
const mockDomains = createMockDomains(1);
useDomains.mockReturnValue([mockDomains, 1, mockMetadata]);
useDomains.mockReturnValue([mockDomains, 1]);
render(<DomainsPage />);

View file

@ -8,13 +8,12 @@ import {InputForm} from '../../hooks/useForm';
/**
* Renders a single form field based on field configuration.
* Extracted outside the component to avoid recreation on every render.
*
* @param {Object} field - Field configuration from metadata
* @param {Object} field - Field configuration
* @param {Object} form - Form object from useForm hook
* @param {Object} metadata - Backend-provided metadata containing messages
* @param {Object} messages - Messages for UI hints (optional)
*/
function renderField(field, form, metadata) {
function renderField(field, form, messages = {}) {
const disabled = field.depends_on && !form.formData[field.depends_on];
switch (field.type) {
@ -59,7 +58,7 @@ function renderField(field, form, metadata) {
{field.required && <span className="required-indicator"> *</span>}
</label>
{disabled && <Message info size='small'>
{metadata.messages?.no_directory || 'Set a directory to enable tagging'}
{messages.no_directory || 'Set a directory to enable tagging'}
</Message>}
<TagsSelector
selectedTagNames={tagProps.value ? [tagProps.value] : []}
@ -67,8 +66,8 @@ function renderField(field, form, metadata) {
single={true}
disabled={disabled || tagProps.disabled}
/>
{!disabled && tagProps.value && metadata.messages?.tag_will_move && <Message warning size='small'>
{metadata.messages.tag_will_move}
{!disabled && tagProps.value && messages.tag_will_move && <Message warning size='small'>
{messages.tag_will_move}
</Message>}
</Form.Field>;
@ -100,7 +99,8 @@ function FormSkeleton({fieldCount = 3}) {
* Reusable form component for editing collections (Domains, Channels, etc).
*
* @param {Object} form - Form object from useForm hook
* @param {Object} metadata - Backend-provided metadata containing fields configuration
* @param {Array} fields - Array of field configurations
* @param {Object} messages - Messages for UI hints (optional)
* @param {Function} onCancel - Optional callback when cancel is clicked
* @param {String} title - Page title to display in header
* @param {String} wrolModeContent - Content to show in WROL mode message (optional)
@ -109,7 +109,8 @@ function FormSkeleton({fieldCount = 3}) {
*/
export function CollectionEditForm({
form,
metadata,
fields,
messages = {},
onCancel,
title,
wrolModeContent,
@ -118,9 +119,9 @@ export function CollectionEditForm({
}) {
const {SingleTag} = React.useContext(TagsContext);
if (!metadata) {
if (!fields || fields.length === 0) {
return <Message warning>
<Message.Header>No metadata available</Message.Header>
<Message.Header>No fields configured</Message.Header>
</Message>;
}
@ -143,14 +144,14 @@ export function CollectionEditForm({
</Message>}
{showSkeleton ? (
<FormSkeleton fieldCount={metadata.fields.length}/>
<FormSkeleton fieldCount={fields.length}/>
) : (
<Form onSubmit={handleSubmit} loading={form.loading} autoComplete="off">
<Grid stackable>
{metadata.fields.map(field => (
{fields.map(field => (
<Grid.Row key={field.key}>
<Grid.Column>
{renderField(field, form, metadata)}
{renderField(field, form, messages)}
</Grid.Column>
</Grid.Row>
))}

View file

@ -1,7 +1,7 @@
import React from 'react';
import {render, renderInDarkMode, renderInLightMode, hasInvertedStyling, screen, waitFor, createTestForm} from '../../test-utils';
import {CollectionEditForm} from './CollectionEditForm';
import {createMockMetadata, createMockDomain} from '../../test-utils';
import {createMockDomain} from '../../test-utils';
// Mock the TagsSelector component and TagsContext
jest.mock('../../Tags', () => ({
@ -55,8 +55,19 @@ jest.mock('../../hooks/useForm', () => ({
),
}));
// Test field configurations
const DOMAIN_FIELDS = [
{key: 'directory', label: 'Directory', type: 'directory', placeholder: 'Optional directory path'},
{key: 'tag_name', label: 'Tag', type: 'tag', placeholder: 'Select or create tag', depends_on: 'directory'},
{key: 'description', label: 'Description', type: 'textarea', placeholder: 'Optional description'}
];
const DOMAIN_MESSAGES = {
no_directory: 'Set a directory to enable tagging',
tag_will_move: 'Tagging will move files to a new directory'
};
describe('CollectionEditForm', () => {
const mockMetadata = createMockMetadata();
const mockCollection = createMockDomain();
describe('Form Rendering', () => {
@ -66,11 +77,12 @@ describe('CollectionEditForm', () => {
render(
<CollectionEditForm
form={form}
metadata={mockMetadata}
fields={DOMAIN_FIELDS}
messages={DOMAIN_MESSAGES}
/>
);
// Check that all fields from metadata are rendered
// Check that all fields from config are rendered
expect(screen.getByTestId('directory-search')).toBeInTheDocument();
expect(screen.getByTestId('tags-selector')).toBeInTheDocument();
expect(screen.getByPlaceholderText(/optional description/i)).toBeInTheDocument();
@ -90,7 +102,8 @@ describe('CollectionEditForm', () => {
render(
<CollectionEditForm
form={form}
metadata={mockMetadata}
fields={DOMAIN_FIELDS}
messages={DOMAIN_MESSAGES}
/>
);
@ -110,7 +123,8 @@ describe('CollectionEditForm', () => {
render(
<CollectionEditForm
form={form}
metadata={mockMetadata}
fields={DOMAIN_FIELDS}
messages={DOMAIN_MESSAGES}
/>
);
@ -125,7 +139,8 @@ describe('CollectionEditForm', () => {
render(
<CollectionEditForm
form={form}
metadata={mockMetadata}
fields={DOMAIN_FIELDS}
messages={DOMAIN_MESSAGES}
/>
);
@ -139,7 +154,8 @@ describe('CollectionEditForm', () => {
render(
<CollectionEditForm
form={form}
metadata={mockMetadata}
fields={DOMAIN_FIELDS}
messages={DOMAIN_MESSAGES}
/>
);
@ -152,7 +168,8 @@ describe('CollectionEditForm', () => {
render(
<CollectionEditForm
form={form}
metadata={mockMetadata}
fields={DOMAIN_FIELDS}
messages={DOMAIN_MESSAGES}
/>
);
@ -172,7 +189,8 @@ describe('CollectionEditForm', () => {
render(
<CollectionEditForm
form={form}
metadata={mockMetadata}
fields={DOMAIN_FIELDS}
messages={DOMAIN_MESSAGES}
/>
);
@ -195,7 +213,8 @@ describe('CollectionEditForm', () => {
render(
<CollectionEditForm
form={form}
metadata={mockMetadata}
fields={DOMAIN_FIELDS}
messages={DOMAIN_MESSAGES}
/>
);
@ -219,7 +238,8 @@ describe('CollectionEditForm', () => {
render(
<CollectionEditForm
form={form}
metadata={mockMetadata}
fields={DOMAIN_FIELDS}
messages={DOMAIN_MESSAGES}
/>
);
@ -236,7 +256,8 @@ describe('CollectionEditForm', () => {
render(
<CollectionEditForm
form={form}
metadata={mockMetadata}
fields={DOMAIN_FIELDS}
messages={DOMAIN_MESSAGES}
onCancel={mockOnCancel}
/>
);
@ -252,7 +273,8 @@ describe('CollectionEditForm', () => {
render(
<CollectionEditForm
form={form}
metadata={mockMetadata}
fields={DOMAIN_FIELDS}
messages={DOMAIN_MESSAGES}
/>
);
@ -263,18 +285,34 @@ describe('CollectionEditForm', () => {
});
describe('Error States', () => {
it('handles missing metadata gracefully', () => {
it('handles missing fields gracefully', () => {
const form = createTestForm(mockCollection);
render(
<CollectionEditForm
form={form}
metadata={null}
fields={null}
messages={DOMAIN_MESSAGES}
/>
);
// Should show warning about missing metadata
expect(screen.getByText(/no metadata available/i)).toBeInTheDocument();
// Should show warning about missing fields
expect(screen.getByText(/no fields configured/i)).toBeInTheDocument();
});
it('handles empty fields array gracefully', () => {
const form = createTestForm(mockCollection);
render(
<CollectionEditForm
form={form}
fields={[]}
messages={DOMAIN_MESSAGES}
/>
);
// Should show warning about missing fields
expect(screen.getByText(/no fields configured/i)).toBeInTheDocument();
});
it('displays form-level errors', () => {
@ -285,7 +323,8 @@ describe('CollectionEditForm', () => {
render(
<CollectionEditForm
form={form}
metadata={mockMetadata}
fields={DOMAIN_FIELDS}
messages={DOMAIN_MESSAGES}
/>
);
@ -303,7 +342,8 @@ describe('CollectionEditForm', () => {
const {container} = render(
<CollectionEditForm
form={form}
metadata={mockMetadata}
fields={DOMAIN_FIELDS}
messages={DOMAIN_MESSAGES}
/>
);
@ -320,7 +360,8 @@ describe('CollectionEditForm', () => {
const {container} = render(
<CollectionEditForm
form={form}
metadata={mockMetadata}
fields={DOMAIN_FIELDS}
messages={DOMAIN_MESSAGES}
/>
);
@ -330,7 +371,7 @@ describe('CollectionEditForm', () => {
expect(screen.getByTestId('directory-search')).toBeInTheDocument();
});
it('renders correct number of skeleton fields based on metadata', () => {
it('renders correct number of skeleton fields based on fields config', () => {
const form = createTestForm({}, {
overrides: {loading: true}
});
@ -338,13 +379,14 @@ describe('CollectionEditForm', () => {
const {container} = render(
<CollectionEditForm
form={form}
metadata={mockMetadata}
fields={DOMAIN_FIELDS}
messages={DOMAIN_MESSAGES}
/>
);
// Should have skeleton fields equal to metadata.fields.length (3)
// Should have skeleton fields equal to fields.length (3)
const skeletonFields = container.querySelectorAll('.form-skeleton-field');
expect(skeletonFields).toHaveLength(mockMetadata.fields.length);
expect(skeletonFields).toHaveLength(DOMAIN_FIELDS.length);
});
});
@ -355,7 +397,8 @@ describe('CollectionEditForm', () => {
const {container} = renderInDarkMode(
<CollectionEditForm
form={form}
metadata={mockMetadata}
fields={DOMAIN_FIELDS}
messages={DOMAIN_MESSAGES}
/>
);
@ -371,7 +414,8 @@ describe('CollectionEditForm', () => {
const {container} = renderInLightMode(
<CollectionEditForm
form={form}
metadata={mockMetadata}
fields={DOMAIN_FIELDS}
messages={DOMAIN_MESSAGES}
/>
);
@ -387,7 +431,8 @@ describe('CollectionEditForm', () => {
const {container} = renderInDarkMode(
<CollectionEditForm
form={form}
metadata={mockMetadata}
fields={DOMAIN_FIELDS}
messages={DOMAIN_MESSAGES}
title="Test Collection"
/>
);
@ -404,7 +449,8 @@ describe('CollectionEditForm', () => {
const {container} = render(
<CollectionEditForm
form={form}
metadata={mockMetadata}
fields={DOMAIN_FIELDS}
messages={DOMAIN_MESSAGES}
/>
);
@ -417,19 +463,18 @@ describe('CollectionEditForm', () => {
describe('CSS Classes', () => {
it('uses required-indicator class for required fields', () => {
const metadataWithRequired = createMockMetadata('domain', {
fields: [
{key: 'name', label: 'Name', type: 'text', required: true},
{key: 'description', label: 'Description', type: 'textarea', required: true},
]
});
const fieldsWithRequired = [
{key: 'name', label: 'Name', type: 'text', required: true},
{key: 'description', label: 'Description', type: 'textarea', required: true},
];
const form = createTestForm(mockCollection);
const {container} = render(
<CollectionEditForm
form={form}
metadata={metadataWithRequired}
fields={fieldsWithRequired}
messages={DOMAIN_MESSAGES}
/>
);
@ -445,7 +490,8 @@ describe('CollectionEditForm', () => {
const {container} = render(
<CollectionEditForm
form={form}
metadata={mockMetadata}
fields={DOMAIN_FIELDS}
messages={DOMAIN_MESSAGES}
onCancel={mockOnCancel}
/>
);

View file

@ -8,62 +8,36 @@ import {ThemeContext, Media} from '../../contexts/contexts';
import {TagsContext} from '../../Tags';
/**
* Get the ID field from metadata routes configuration.
* @param {Object} metadata - Collection metadata
* Get the ID field from routes configuration.
* @param {Object} routes - Routes configuration
* @returns {string} The ID field name
*/
function getIdField(metadata) {
return metadata?.routes?.id_field || 'id';
function getIdField(routes) {
return routes?.id_field || 'id';
}
/**
* Calculate column widths based on column metadata.
* Uses Semantic UI's 16-column grid system.
*
* @param {Array} columns - Array of column configurations
* @returns {Array} Array of width values (1-16)
*/
function calculateColumnWidths(columns) {
const TOTAL = 16;
const widths = columns.map(col => {
if (col.type === 'actions') return 1;
if (col.format === 'bytes' || col.format === 'frequency') return 2;
if (col.align === 'right') return 2; // numeric columns
if (col.key === 'tag_name') return 2;
return null; // grow column - calculate later
});
// Distribute remaining space to null (grow) columns
const fixedWidth = widths.filter(w => w !== null).reduce((a, b) => a + b, 0);
const growColumns = widths.filter(w => w === null).length;
const growWidth = Math.floor((TOTAL - fixedWidth) / (growColumns || 1));
return widths.map(w => w === null ? growWidth : w);
}
/**
* Generate a search link for a collection based on metadata routing configuration.
* Generate a search link for a collection based on routes configuration.
* Supports both query parameter-based (e.g., ?domain=...) and route-based (e.g., /channel/:id/video) linking.
*
* @param {Object} collection - The collection object
* @param {Object} metadata - Collection metadata with routes configuration
* @param {Object} routes - Routes configuration
* @param {string} primaryKey - The primary column key to use for the value
* @returns {string|null} The generated link, or null if no link can be generated
*/
function getCollectionSearchLink(collection, metadata, primaryKey) {
const searchRoute = metadata.routes?.search;
function getCollectionSearchLink(collection, routes, primaryKey) {
const searchRoute = routes?.search;
if (!searchRoute) {
return null;
}
// Check if metadata specifies a query parameter to use
if (metadata.routes.searchParam) {
// Check if routes specifies a query parameter to use
if (routes.searchParam) {
// Query parameter-based linking (e.g., /archive?domain=example.com)
return `${searchRoute}?${metadata.routes.searchParam}=${collection[primaryKey]}`;
return `${searchRoute}?${routes.searchParam}=${collection[primaryKey]}`;
} else if (searchRoute.includes(':id')) {
// Route parameter-based linking (e.g., /videos/channel/123/video)
const idField = getIdField(metadata);
const idField = getIdField(routes);
return searchRoute.replace(':id', collection[idField]);
}
@ -75,22 +49,22 @@ function getCollectionSearchLink(collection, metadata, primaryKey) {
* Render a single table row for desktop view.
*
* @param {Object} collection - The collection data
* @param {Object} metadata - Collection metadata
* @param {Array} columns - Column configurations
* @param {Object} routes - Routes configuration
* @param {string} inverted - Inverted theme class
* @param {Function} SingleTag - Tag component from context
* @param {Function} onRowClick - Optional click handler
* @param {Array} columnWidths - Calculated column widths
*/
function renderRow(collection, metadata, inverted, SingleTag, onRowClick, columnWidths) {
const cells = metadata.columns.map((col, index) => {
function renderRow(collection, columns, routes, inverted, SingleTag, onRowClick) {
const cells = columns.map((col) => {
let value = collection[col.key];
// Handle actions column - render action buttons
if (col.type === 'actions') {
const idField = getIdField(metadata);
const editRoute = metadata.routes?.edit?.replace(':id', collection[idField]);
const idField = getIdField(routes);
const editRoute = routes?.edit?.replace(':id', collection[idField]);
const buttonClass = `ui button secondary ${inverted}`;
return <Table.Cell key={col.key} textAlign={col.align || 'right'} width={columnWidths[index]}>
return <Table.Cell key={col.key} textAlign={col.align || 'right'} width={col.width}>
{editRoute && <Link className={buttonClass} to={editRoute}>Edit</Link>}
</Table.Cell>;
}
@ -108,14 +82,14 @@ function renderRow(collection, metadata, inverted, SingleTag, onRowClick, column
}
// Special handling for the primary column (usually domain/name)
if (col.key === metadata.columns[0].key) {
const searchLink = getCollectionSearchLink(collection, metadata, col.key);
if (col.key === columns[0].key) {
const searchLink = getCollectionSearchLink(collection, routes, col.key);
if (searchLink) {
value = <Link to={searchLink}>{value}</Link>;
}
}
return <Table.Cell key={col.key} textAlign={col.align || 'left'} width={columnWidths[index]}>
return <Table.Cell key={col.key} textAlign={col.align || 'left'} width={col.width}>
{value || '-'}
</Table.Cell>;
});
@ -130,31 +104,26 @@ function renderRow(collection, metadata, inverted, SingleTag, onRowClick, column
}
/**
* Mobile row component for collections
* Mobile row component for collections - stacked layout for better wrapping of long names
*/
function MobileCollectionRow({collection, metadata}) {
function MobileCollectionRow({collection, mobileColumns, routes}) {
const {SingleTag} = useContext(TagsContext);
const primaryColumn = metadata.columns[0];
const idField = getIdField(metadata);
const editRoute = metadata.routes?.edit?.replace(':id', collection[idField]);
const searchLink = getCollectionSearchLink(collection, metadata, primaryColumn.key);
const primaryColumn = mobileColumns[0];
const idField = getIdField(routes);
const editRoute = routes?.edit?.replace(':id', collection[idField]);
const searchLink = getCollectionSearchLink(collection, routes, primaryColumn.key);
return <TableRow verticalAlign='top'>
<TableCell width={10} colSpan={2}>
<TableCell>
{searchLink ? (
<Link as='h3' to={searchLink}>
<h3>
{collection[primaryColumn.key]}
</h3>
{collection.tag_name && <SingleTag name={collection.tag_name}/>}
<Link to={searchLink}>
<strong>{collection[primaryColumn.key]}</strong>
</Link>
) : (
<>
<h3>{collection[primaryColumn.key]}</h3>
{collection.tag_name && <SingleTag name={collection.tag_name}/>}
</>
<strong>{collection[primaryColumn.key]}</strong>
)}
{metadata.columns
{collection.tag_name && <> <SingleTag name={collection.tag_name}/></>}
{mobileColumns
.filter(col => col.type !== 'actions' && col.key !== primaryColumn.key && col.key !== 'tag_name')
.map(col => {
let value = collection[col.key];
@ -164,17 +133,15 @@ function MobileCollectionRow({collection, metadata}) {
value = formatFrequency(value);
}
return (
<p key={col.key}>
<div key={col.key}>
{col.label}: {value || '-'}
</p>
</div>
);
})
}
</TableCell>
<TableCell width={6} colSpan={2} textAlign='right'>
<p>
{editRoute && <Link className="ui button secondary" to={editRoute}>Edit</Link>}
</p>
<TableCell textAlign='right'>
{editRoute && <Link className="ui button secondary" to={editRoute}>Edit</Link>}
</TableCell>
</TableRow>;
}
@ -183,12 +150,13 @@ function MobileCollectionRow({collection, metadata}) {
* Reusable table component for displaying collections (Domains, Channels, etc).
*
* @param {Array} collections - Array of collection objects
* @param {Object} metadata - Backend-provided metadata containing columns, routes, etc.
* @param {Array} columns - Column configurations for the table
* @param {Object} routes - Routes configuration for navigation (edit, search, etc.)
* @param {String} searchStr - Search filter string (managed by parent)
* @param {Function} onRowClick - Optional callback when a row is clicked
* @param {String} emptyMessage - Message to display when there are no collections
*/
export function CollectionTable({collections, metadata, searchStr = '', onRowClick, emptyMessage = 'No items yet'}) {
export function CollectionTable({collections, columns, routes = {}, searchStr = '', onRowClick, emptyMessage = 'No items yet'}) {
const {inverted} = useContext(ThemeContext);
const {SingleTag} = useContext(TagsContext);
@ -216,10 +184,10 @@ export function CollectionTable({collections, metadata, searchStr = '', onRowCli
</Message>;
}
// No metadata available (backward compatibility)
if (!metadata) {
// No columns configured
if (!columns || columns.length === 0) {
return <Message warning>
<Message.Header>No metadata available</Message.Header>
<Message.Header>No columns configured</Message.Header>
</Message>;
}
@ -238,19 +206,17 @@ export function CollectionTable({collections, metadata, searchStr = '', onRowCli
});
}
// Calculate column widths dynamically
const columnWidths = calculateColumnWidths(metadata.columns);
// Build table headers from metadata (desktop)
const headers = metadata.columns.map((col, index) => ({
// Build table headers from columns (desktop)
const headers = columns.map((col) => ({
key: col.key,
text: col.label,
sortBy: col.sortable ? col.key : null,
width: columnWidths[index],
width: col.width,
}));
// Build mobile headers - simplified columns
const primaryColumn = metadata.columns[0];
// Build mobile columns (exclude hideOnMobile) and simplified 2-column headers
const mobileColumns = columns.filter(col => !col.hideOnMobile);
const primaryColumn = columns[0];
const mobileHeaders = [
{
key: primaryColumn.key,
@ -263,8 +229,8 @@ export function CollectionTable({collections, metadata, searchStr = '', onRowCli
}
];
// Get default sort column from metadata (first column key)
const defaultSortColumn = metadata.columns[0]?.key || 'id';
// Get default sort column (first column key)
const defaultSortColumn = columns[0]?.key || 'id';
return <>
@ -272,7 +238,7 @@ export function CollectionTable({collections, metadata, searchStr = '', onRowCli
<SortableTable
tableProps={{striped: true, size: 'small', unstackable: true}}
data={filteredCollections}
rowFunc={(collection) => <MobileCollectionRow key={collection.id} collection={collection} metadata={metadata}/>}
rowFunc={(collection) => <MobileCollectionRow key={collection.id} collection={collection} mobileColumns={mobileColumns} routes={routes}/>}
rowKey='id'
tableHeaders={mobileHeaders}
defaultSortColumn={defaultSortColumn}
@ -282,7 +248,7 @@ export function CollectionTable({collections, metadata, searchStr = '', onRowCli
<SortableTable
tableProps={{striped: true, size: 'large', unstackable: true, compact: true}}
data={filteredCollections}
rowFunc={(collection) => renderRow(collection, metadata, inverted, SingleTag, onRowClick, columnWidths)}
rowFunc={(collection) => renderRow(collection, columns, routes, inverted, SingleTag, onRowClick)}
rowKey='id'
tableHeaders={headers}
defaultSortColumn={defaultSortColumn}

View file

@ -1,7 +1,7 @@
import React from 'react';
import {render, screen, renderInDarkMode, renderInLightMode, hasInvertedStyling} from '../../test-utils';
import {CollectionTable} from './CollectionTable';
import {createMockMetadata, createMockDomains} from '../../test-utils';
import {createMockDomains} from '../../test-utils';
// Mock the TagsContext
jest.mock('../../Tags', () => ({
@ -45,8 +45,23 @@ jest.mock('../SortableTable', () => ({
),
}));
// Test column and routes configurations
const DOMAIN_COLUMNS = [
{key: 'domain', label: 'Domain', sortable: true, width: 7},
{key: 'archive_count', label: 'Archives', sortable: true, align: 'right', width: 2},
{key: 'size', label: 'Size', sortable: true, align: 'right', format: 'bytes', width: 2, hideOnMobile: true},
{key: 'tag_name', label: 'Tag', sortable: true, width: 2},
{key: 'actions', label: 'Manage', sortable: false, type: 'actions', width: 1}
];
const DOMAIN_ROUTES = {
list: '/archive/domains',
edit: '/archive/domain/:id/edit',
search: '/archive',
searchParam: 'domain'
};
describe('CollectionTable', () => {
const mockMetadata = createMockMetadata();
const mockCollections = createMockDomains(3);
describe('Loading and Error States', () => {
@ -54,7 +69,8 @@ describe('CollectionTable', () => {
const {container} = render(
<CollectionTable
collections={null}
metadata={mockMetadata}
columns={DOMAIN_COLUMNS}
routes={DOMAIN_ROUTES}
/>
);
@ -65,7 +81,8 @@ describe('CollectionTable', () => {
render(
<CollectionTable
collections={undefined}
metadata={mockMetadata}
columns={DOMAIN_COLUMNS}
routes={DOMAIN_ROUTES}
/>
);
@ -76,7 +93,8 @@ describe('CollectionTable', () => {
render(
<CollectionTable
collections={[]}
metadata={mockMetadata}
columns={DOMAIN_COLUMNS}
routes={DOMAIN_ROUTES}
/>
);
@ -87,7 +105,8 @@ describe('CollectionTable', () => {
render(
<CollectionTable
collections={[]}
metadata={mockMetadata}
columns={DOMAIN_COLUMNS}
routes={DOMAIN_ROUTES}
emptyMessage="No domains found"
/>
);
@ -95,15 +114,28 @@ describe('CollectionTable', () => {
expect(screen.getByText(/no domains found/i)).toBeInTheDocument();
});
it('renders warning when metadata is null', () => {
it('renders warning when columns is null', () => {
render(
<CollectionTable
collections={mockCollections}
metadata={null}
columns={null}
routes={DOMAIN_ROUTES}
/>
);
expect(screen.getByText(/no metadata available/i)).toBeInTheDocument();
expect(screen.getByText(/no columns configured/i)).toBeInTheDocument();
});
it('renders warning when columns is empty', () => {
render(
<CollectionTable
collections={mockCollections}
columns={[]}
routes={DOMAIN_ROUTES}
/>
);
expect(screen.getByText(/no columns configured/i)).toBeInTheDocument();
});
});
@ -112,7 +144,8 @@ describe('CollectionTable', () => {
render(
<CollectionTable
collections={mockCollections}
metadata={mockMetadata}
columns={DOMAIN_COLUMNS}
routes={DOMAIN_ROUTES}
/>
);
@ -124,7 +157,8 @@ describe('CollectionTable', () => {
render(
<CollectionTable
collections={mockCollections}
metadata={mockMetadata}
columns={DOMAIN_COLUMNS}
routes={DOMAIN_ROUTES}
/>
);
@ -138,7 +172,8 @@ describe('CollectionTable', () => {
render(
<CollectionTable
collections={mockCollections}
metadata={mockMetadata}
columns={DOMAIN_COLUMNS}
routes={DOMAIN_ROUTES}
/>
);
@ -153,7 +188,8 @@ describe('CollectionTable', () => {
render(
<CollectionTable
collections={mockCollections}
metadata={mockMetadata}
columns={DOMAIN_COLUMNS}
routes={DOMAIN_ROUTES}
searchStr="example1"
/>
);
@ -168,7 +204,8 @@ describe('CollectionTable', () => {
render(
<CollectionTable
collections={mockCollections}
metadata={mockMetadata}
columns={DOMAIN_COLUMNS}
routes={DOMAIN_ROUTES}
searchStr=""
/>
);
@ -182,7 +219,8 @@ describe('CollectionTable', () => {
render(
<CollectionTable
collections={mockCollections}
metadata={mockMetadata}
columns={DOMAIN_COLUMNS}
routes={DOMAIN_ROUTES}
searchStr="EXAMPLE1"
/>
);
@ -193,77 +231,50 @@ describe('CollectionTable', () => {
});
});
describe('Column Width Calculation', () => {
it('calculates widths for 5-column metadata', () => {
describe('Column Widths', () => {
it('uses explicit widths from column config', () => {
render(
<CollectionTable
collections={mockCollections}
metadata={mockMetadata}
columns={DOMAIN_COLUMNS}
routes={DOMAIN_ROUTES}
/>
);
const desktopView = screen.getByTestId('desktop-view');
const headers = desktopView.querySelectorAll('th');
// Mock metadata has 5 columns: domain, archive_count, size, tag_name, actions
// archive_count (right-aligned)=2, size (bytes)=2, tag_name=2, actions=1
// Total fixed = 7, remaining = 16 - 7 = 9 for domain
expect(headers[0]).toHaveAttribute('data-width', '9');
// Actions column should be 1
const actionsHeader = Array.from(headers).find(h => h.textContent === 'Manage');
expect(actionsHeader).toHaveAttribute('data-width', '1');
});
it('calculates widths for 4-column metadata', () => {
const fourColumnMetadata = {
...mockMetadata,
columns: [
{key: 'name', label: 'Name', sortable: true},
{key: 'count', label: 'Count', sortable: true, align: 'right'},
{key: 'size', label: 'Size', sortable: true, format: 'bytes'},
{key: 'actions', label: 'Manage', type: 'actions'},
]
};
render(
<CollectionTable
collections={mockCollections}
metadata={fourColumnMetadata}
/>
);
const desktopView = screen.getByTestId('desktop-view');
const headers = desktopView.querySelectorAll('th');
// count (right-aligned)=2, size (bytes)=2, actions=1
// Total fixed = 5, remaining = 16 - 5 = 11 for name
expect(headers[0]).toHaveAttribute('data-width', '11');
});
it('distributes space among multiple grow columns', () => {
const twoGrowMetadata = {
...mockMetadata,
columns: [
{key: 'name', label: 'Name', sortable: true},
{key: 'description', label: 'Description', sortable: false},
{key: 'actions', label: 'Manage', type: 'actions'},
]
};
render(
<CollectionTable
collections={mockCollections}
metadata={twoGrowMetadata}
/>
);
const desktopView = screen.getByTestId('desktop-view');
const headers = desktopView.querySelectorAll('th');
// actions=1, remaining = 15 / 2 grow columns = 7 each
// DOMAIN_COLUMNS has explicit widths: domain=7, archive_count=2, size=2, tag_name=2, actions=1
expect(headers[0]).toHaveAttribute('data-width', '7');
expect(headers[1]).toHaveAttribute('data-width', '7');
expect(headers[1]).toHaveAttribute('data-width', '2');
expect(headers[2]).toHaveAttribute('data-width', '2');
expect(headers[3]).toHaveAttribute('data-width', '2');
expect(headers[4]).toHaveAttribute('data-width', '1');
});
it('uses widths from 4-column config', () => {
const fourColumns = [
{key: 'name', label: 'Name', sortable: true, width: 9},
{key: 'count', label: 'Count', sortable: true, align: 'right', width: 2},
{key: 'size', label: 'Size', sortable: true, format: 'bytes', width: 2},
{key: 'actions', label: 'Manage', type: 'actions', width: 1},
];
render(
<CollectionTable
collections={mockCollections}
columns={fourColumns}
routes={DOMAIN_ROUTES}
/>
);
const desktopView = screen.getByTestId('desktop-view');
const headers = desktopView.querySelectorAll('th');
expect(headers[0]).toHaveAttribute('data-width', '9');
expect(headers[1]).toHaveAttribute('data-width', '2');
expect(headers[2]).toHaveAttribute('data-width', '2');
expect(headers[3]).toHaveAttribute('data-width', '1');
});
});
@ -274,7 +285,8 @@ describe('CollectionTable', () => {
render(
<CollectionTable
collections={mockCollections}
metadata={mockMetadata}
columns={DOMAIN_COLUMNS}
routes={DOMAIN_ROUTES}
onRowClick={mockOnRowClick}
/>
);
@ -292,7 +304,8 @@ describe('CollectionTable', () => {
render(
<CollectionTable
collections={mockCollections}
metadata={mockMetadata}
columns={DOMAIN_COLUMNS}
routes={DOMAIN_ROUTES}
onRowClick={mockOnRowClick}
/>
);
@ -306,7 +319,8 @@ describe('CollectionTable', () => {
render(
<CollectionTable
collections={mockCollections}
metadata={mockMetadata}
columns={DOMAIN_COLUMNS}
routes={DOMAIN_ROUTES}
/>
);
@ -328,7 +342,8 @@ describe('CollectionTable', () => {
render(
<CollectionTable
collections={collectionsWithSize}
metadata={mockMetadata}
columns={DOMAIN_COLUMNS}
routes={DOMAIN_ROUTES}
/>
);
@ -350,7 +365,8 @@ describe('CollectionTable', () => {
render(
<CollectionTable
collections={collectionsWithTag}
metadata={mockMetadata}
columns={DOMAIN_COLUMNS}
routes={DOMAIN_ROUTES}
/>
);
@ -358,20 +374,47 @@ describe('CollectionTable', () => {
});
});
describe('Links', () => {
it('renders search link on primary column when searchParam is configured', () => {
const metadataWithSearchParam = {
...mockMetadata,
routes: {
...mockMetadata.routes,
searchParam: 'domain'
}
};
describe('Mobile View', () => {
it('hides columns with hideOnMobile in mobile view', () => {
const collectionsWithData = [{
id: 1,
domain: 'test.com',
archive_count: 5,
size: 1048576,
tag_name: 'News',
}];
render(
<CollectionTable
collections={collectionsWithData}
columns={DOMAIN_COLUMNS}
routes={DOMAIN_ROUTES}
/>
);
const mobileView = screen.getByTestId('mobile-view');
const mobileHeaders = mobileView.querySelectorAll('th');
// Mobile uses simplified 2-column layout: primary column + Manage
expect(mobileHeaders).toHaveLength(2);
expect(mobileHeaders[0]).toHaveTextContent('Domain');
expect(mobileHeaders[1]).toHaveTextContent('Manage');
// Archives should be visible in row content (no hideOnMobile)
expect(mobileView).toHaveTextContent('Archives:');
// Size should be hidden in row content (hideOnMobile: true)
expect(mobileView).not.toHaveTextContent('Size:');
});
});
describe('Links', () => {
it('renders search link on primary column when searchParam is configured', () => {
render(
<CollectionTable
collections={mockCollections}
metadata={metadataWithSearchParam}
columns={DOMAIN_COLUMNS}
routes={DOMAIN_ROUTES}
/>
);
@ -385,7 +428,8 @@ describe('CollectionTable', () => {
render(
<CollectionTable
collections={mockCollections}
metadata={mockMetadata}
columns={DOMAIN_COLUMNS}
routes={DOMAIN_ROUTES}
/>
);

View file

@ -194,21 +194,17 @@ export const useAllQuery = (name) => {
export const useDomains = () => {
const [domains, setDomains] = useState(null);
const [total, setTotal] = useState(null);
const [metadata, setMetadata] = useState(null);
const _fetchDomains = async () => {
setDomains(null);
setTotal(0);
setMetadata(null);
try {
let [domains, total, metadata] = await fetchDomains();
let [domains, total] = await fetchDomains();
setDomains(domains);
setTotal(total);
setMetadata(metadata);
} catch (e) {
setDomains(undefined); // Display error.
setTotal(0);
setMetadata(undefined);
}
}
@ -216,39 +212,7 @@ export const useDomains = () => {
_fetchDomains();
}, []);
return [domains, total, metadata];
}
export const useCollectionMetadata = (kind) => {
const [metadata, setMetadata] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const fetchMetadata = async () => {
setLoading(true);
setError(null);
try {
if (kind === 'domain') {
const [, , metadata] = await fetchDomains();
setMetadata(metadata);
} else {
// Future: Support channels metadata
setMetadata(null);
}
} catch (e) {
console.error(e);
setError(e);
setMetadata(null);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchMetadata();
}, [kind]);
return {metadata, loading, error, fetchMetadata};
return [domains, total];
}
export const useDomain = (domainId) => {

View file

@ -58,37 +58,6 @@ export function renderWithProviders(
return render(ui, {wrapper: Wrapper, ...renderOptions});
}
/**
* Creates a mock collection metadata object for testing
*/
export function createMockMetadata(kind = 'domain', overrides = {}) {
return {
kind,
columns: [
{key: 'domain', label: 'Domain', sortable: true},
{key: 'archive_count', label: 'Archives', sortable: true, align: 'right'},
{key: 'size', label: 'Size', sortable: true, align: 'right', format: 'bytes'},
{key: 'tag_name', label: 'Tag', sortable: true},
{key: 'actions', label: 'Manage', sortable: false, type: 'actions'},
],
fields: [
{key: 'directory', label: 'Directory', type: 'directory', placeholder: 'Optional directory path'},
{key: 'tag_name', label: 'Tag', type: 'tag', placeholder: 'Select or create tag', depends_on: 'directory'},
{key: 'description', label: 'Description', type: 'textarea', placeholder: 'Optional description'},
],
routes: {
list: '/archive/domains',
edit: '/archive/domain/:id/edit',
search: '/archive',
},
messages: {
no_directory: 'Set a directory to enable tagging',
tag_will_move: 'Tagging will move files to a new directory'
},
...overrides
};
}
/**
* Creates a mock domain collection object for testing
*/

View file

@ -44,15 +44,11 @@ async def get_collections_endpoint(request: Request):
collections = lib.get_collections(kind=kind)
# Include metadata if a specific kind is requested
response_data = {
'collections': collections,
'totals': {'collections': len(collections)}
}
if kind:
response_data['metadata'] = lib.get_collection_metadata(kind)
return json_response(response_data)

View file

@ -23,7 +23,6 @@ logger = logger.getChild(__name__)
__all__ = [
'get_collections',
'get_collection_with_stats',
'get_collection_metadata',
'update_collection',
'refresh_collection',
'tag_collection',
@ -193,112 +192,6 @@ def get_collection_with_stats(collection_id: int, session: Session = None) -> di
return data
def get_collection_metadata(kind: str) -> dict:
"""
Get metadata for a collection kind.
Metadata includes:
- Column definitions for table display
- Field configurations for edit forms
- Route information for navigation
- User-facing messages
Args:
kind: Collection kind ('domain', 'channel', etc.)
Returns:
Metadata dict with columns, fields, routes, and messages
"""
if kind == 'domain':
return {
'kind': 'domain',
'columns': [
{'key': 'domain', 'label': 'Domain', 'sortable': True},
{'key': 'tag_name', 'label': 'Tag', 'sortable': True},
{'key': 'archive_count', 'label': 'Archives', 'sortable': True, 'align': 'right'},
{'key': 'min_download_frequency', 'label': 'Download Frequency', 'sortable': True, 'format': 'frequency'},
{'key': 'size', 'label': 'Size', 'sortable': True, 'align': 'right', 'format': 'bytes'},
{'key': 'actions', 'label': 'Manage', 'sortable': False, 'type': 'actions'}
],
'fields': [
{'key': 'directory', 'label': 'Directory', 'type': 'directory',
'placeholder': 'Optional directory path'},
{'key': 'tag_name', 'label': 'Tag', 'type': 'tag',
'placeholder': 'Select or create tag', 'depends_on': 'directory'},
{'key': 'description', 'label': 'Description', 'type': 'textarea',
'placeholder': 'Optional description'}
],
'routes': {
'list': '/archive/domains',
'edit': '/archive/domain/:id/edit',
'search': '/archive',
'searchParam': 'domain'
},
'messages': {
'no_directory': 'Set a directory to enable tagging',
'tag_will_move': 'Tagging will move files to a new directory'
}
}
elif kind == 'channel':
return {
'kind': 'channel',
'columns': [
{'key': 'name', 'label': 'Name', 'sortable': True},
{'key': 'tag_name', 'label': 'Tag', 'sortable': True},
{'key': 'video_count', 'label': 'Videos', 'sortable': True, 'align': 'right'},
{'key': 'min_download_frequency', 'label': 'Download Frequency', 'sortable': True, 'format': 'frequency'},
{'key': 'total_size', 'label': 'Size', 'sortable': True, 'align': 'right', 'format': 'bytes'},
{'key': 'actions', 'label': 'Manage', 'sortable': False, 'type': 'actions'}
],
'fields': [
{'key': 'name', 'label': 'Name', 'type': 'text', 'required': True},
{'key': 'url', 'label': 'URL', 'type': 'text', 'placeholder': 'Channel URL'},
{'key': 'directory', 'label': 'Directory', 'type': 'directory', 'required': True},
{'key': 'tag_name', 'label': 'Tag', 'type': 'tag',
'placeholder': 'Select or create tag'},
{'key': 'description', 'label': 'Description', 'type': 'textarea',
'placeholder': 'Optional description'}
],
'routes': {
'list': '/videos/channels',
'edit': '/videos/channel/:id/edit',
'search': '/videos/channel/:id/video',
'id_field': 'channel_id' # Use channel_id instead of collection id for URLs
},
'messages': {
'no_directory': 'Directory is required for channels',
'tag_will_move': 'Tagging will move files to a new directory'
}
}
else:
# Generic metadata for unknown kinds
return {
'kind': kind or 'collection',
'columns': [
{'key': 'name', 'label': 'Name', 'sortable': True},
{'key': 'item_count', 'label': 'Items', 'sortable': True, 'align': 'right'},
{'key': 'total_size', 'label': 'Size', 'sortable': True, 'align': 'right', 'format': 'bytes'},
{'key': 'tag_name', 'label': 'Tag', 'sortable': True},
{'key': 'actions', 'label': 'Manage', 'sortable': False, 'type': 'actions'}
],
'fields': [
{'key': 'name', 'label': 'Name', 'type': 'text', 'required': True},
{'key': 'directory', 'label': 'Directory', 'type': 'directory'},
{'key': 'tag_name', 'label': 'Tag', 'type': 'tag'},
{'key': 'description', 'label': 'Description', 'type': 'textarea'}
],
'routes': {
'list': '/collections',
'edit': '/collection/:id/edit',
'search': '/search'
},
'messages': {
'no_directory': 'Set a directory to enable tagging',
'tag_will_move': 'Tagging will move files to a new directory'
}
}
@optional_session
def update_collection(
collection_id: int,

View file

@ -10,10 +10,10 @@ class TestCollectionsAPI:
"""Test the GET /api/collections endpoint."""
@pytest.mark.asyncio
async def test_get_collections_returns_metadata_for_domains(
async def test_get_collections_for_domains(
self, async_client, test_session
):
"""Test that GET /api/collections?kind=domain returns metadata."""
"""Test that GET /api/collections?kind=domain returns domain collections."""
# Create a test domain collection
collection = Collection(name='example.com', kind='domain')
test_session.add(collection)
@ -29,7 +29,6 @@ class TestCollectionsAPI:
# Check basic response structure
assert 'collections' in data
assert 'totals' in data
assert 'metadata' in data
# Check collections
assert len(data['collections']) == 1
@ -39,48 +38,11 @@ class TestCollectionsAPI:
# Check totals
assert data['totals']['collections'] == 1
# Check metadata structure
metadata = data['metadata']
assert metadata['kind'] == 'domain'
assert 'columns' in metadata
assert 'fields' in metadata
assert 'routes' in metadata
assert 'messages' in metadata
# Check metadata columns
columns = metadata['columns']
assert len(columns) > 0
column_keys = [col['key'] for col in columns]
assert 'domain' in column_keys
assert 'archive_count' in column_keys
assert 'size' in column_keys
assert 'tag_name' in column_keys
assert 'actions' in column_keys
# Check metadata fields
fields = metadata['fields']
assert len(fields) > 0
field_keys = [field['key'] for field in fields]
assert 'directory' in field_keys
assert 'tag_name' in field_keys
assert 'description' in field_keys
# Check metadata routes
routes = metadata['routes']
assert routes['list'] == '/archive/domains'
assert routes['edit'] == '/archive/domain/:id/edit'
assert routes['search'] == '/archive'
# Check metadata messages
messages = metadata['messages']
assert 'no_directory' in messages
assert 'tag_will_move' in messages
@pytest.mark.asyncio
async def test_get_collections_returns_metadata_for_channels(
async def test_get_collections_for_channels(
self, async_client, test_session
):
"""Test that GET /api/collections?kind=channel returns channel metadata."""
"""Test that GET /api/collections?kind=channel returns channel collections."""
from pathlib import Path
# Create a test channel collection
@ -99,27 +61,16 @@ class TestCollectionsAPI:
assert response.status_code == HTTPStatus.OK
data = response.json
# Check metadata structure
assert 'metadata' in data
metadata = data['metadata']
assert metadata['kind'] == 'channel'
# Check channel-specific metadata
column_keys = [col['key'] for col in metadata['columns']]
assert 'name' in column_keys
assert 'video_count' in column_keys
assert 'total_size' in column_keys
# Check channel routes
routes = metadata['routes']
assert routes['list'] == '/videos/channels'
assert routes['edit'] == '/videos/channel/:id/edit'
# Check collections
assert len(data['collections']) == 1
assert data['collections'][0]['name'] == 'Test Channel'
assert data['collections'][0]['kind'] == 'channel'
@pytest.mark.asyncio
async def test_get_collections_no_metadata_without_kind(
async def test_get_collections_without_kind(
self, async_client, test_session
):
"""Test that GET /api/collections without kind parameter does not include metadata."""
"""Test that GET /api/collections without kind parameter returns all collections."""
# Create test collections of different kinds
domain = Collection(name='example.com', kind='domain')
channel = Collection(name='Test Channel', kind='channel')
@ -137,14 +88,11 @@ class TestCollectionsAPI:
assert len(data['collections']) == 2
assert data['totals']['collections'] == 2
# Should NOT have metadata (since no specific kind was requested)
assert 'metadata' not in data
@pytest.mark.asyncio
async def test_get_empty_collections_with_metadata(
async def test_get_empty_collections(
self, async_client, test_session
):
"""Test that metadata is returned even when no collections exist."""
"""Test that GET /api/collections returns empty when no collections exist."""
# Don't create any collections
# Make the request
@ -158,10 +106,6 @@ class TestCollectionsAPI:
assert len(data['collections']) == 0
assert data['totals']['collections'] == 0
# Should still have metadata
assert 'metadata' in data
assert data['metadata']['kind'] == 'domain'
@pytest.mark.asyncio
async def test_get_collections_includes_video_count_for_channels(
self, async_client, test_session, test_directory, channel_factory, video_factory

View file

@ -533,3 +533,38 @@ async def test_download_excluded_urls(test_session, test_download_manager, test_
assert len(downloads) == 3, 'Domain was not ignored'
assert [i.url for i in downloads] == \
['https://example.com/feed', 'https://example.com/a', 'https://example.com/c'], f'Domain was not ignored'
@pytest.mark.asyncio
async def test_download_with_collection_id(test_session, test_download_manager, test_downloader):
"""Creating a Download with a collection_id should work correctly.
This test verifies that downloads can be created and associated with collections.
Note: The trigger update_channel_minimum_frequency is not tested here because
pytest test DBs use Base.metadata.create_all() which doesn't run migrations.
The trigger fix is tested by running against a real DB with migrations applied.
"""
from wrolpi.collections import Collection
# Create a Collection
collection = Collection(
name='Test Collection',
kind='channel',
directory='/tmp/test',
)
test_session.add(collection)
test_session.flush([collection])
# Creating a recurring download with collection_id should work
download = test_download_manager.recurring_download(
'https://example.com/test',
DownloadFrequency.weekly,
test_downloader.name,
collection_id=collection.id,
)
test_session.commit()
# Verify the download was created successfully
assert download.id is not None
assert download.collection_id == collection.id
assert download.frequency == DownloadFrequency.weekly