mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
Removing collection metadata.
This commit is contained in:
parent
9cb6594ca5
commit
a6314bb71c
17 changed files with 493 additions and 533 deletions
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</>;
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 />);
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue