mcp-web-ui
Version:
Ultra-lightweight vanilla JavaScript framework for MCP servers - Zero dependencies, perfect security, 2-3KB bundle size
1,425 lines (1,254 loc) • 67.4 kB
JavaScript
/**
* ListComponent - Generic, Configurable List Implementation
*
* This component provides a flexible list interface that can be configured for different use cases:
* - Configurable CRUD operations (add, edit, delete)
* - Multiple layout modes (list, grid, table)
* - Search and filtering capabilities
* - Sorting and pagination
* - Bulk actions and selection
* - Form handling with validation
* - Customizable item rendering
* - Multi-section support with section-specific actions
*
* SECURITY FEATURES:
* - All data is sanitized through BaseComponent
* - XSS protection for all displayed values
* - Safe event handling with data attributes
* - Input validation for all forms
*
* CONFIGURATION-DRIVEN:
* - Server can customize behavior through config
* - Progressive enhancement (works with minimal config)
* - Extensible through configuration rather than inheritance
*
* Usage Examples:
*
* // Minimal todo list
* const todoList = new ListComponent(element, data, {
* list: {
* itemType: 'todo',
* itemFields: ['text', 'priority', 'category']
* }
* });
*
* // Grocery list
* const groceryList = new ListComponent(element, data, {
* list: {
* itemType: 'grocery',
* itemFields: ['name', 'quantity', 'category'],
* layout: 'grid'
* }
* });
*
* // Data table
* const dataTable = new ListComponent(element, data, {
* list: {
* layout: 'table',
* enableSorting: true,
* enablePagination: true,
* columns: [
* { key: 'name', label: 'Name', sortable: true },
* { key: 'status', label: 'Status', type: 'badge' },
* { key: 'actions', label: 'Actions', type: 'actions' }
* ]
* }
* });
*
* // Multi-section list with section-specific actions
* const multiSectionList = new ListComponent(element, data, {
* list: {
* mode: 'multi',
* groupBy: 'completed',
* sections: {
* 'false': { name: 'Active', icon: '📋', collapsible: false },
* 'true': { name: 'Completed', icon: '✅', collapsible: true }
* },
* sectionActions: {
* 'false': { global: ['add', 'import'] },
* 'true': { global: ['clear_completed'] }
* }
* }
* });
*/
class ListComponent extends BaseComponent {
/**
* Initialize ListComponent with list-specific configuration
* CRITICAL: Follow inheritance timing pattern exactly
* @param {HTMLElement} element - DOM element to attach to
* @param {Array} data - Initial list data
* @param {Object} config - Configuration options
*/
constructor(element, data, config) {
// 1. ALWAYS call super() FIRST
super(element, data, config);
// 2. Set component properties AFTER super()
this.listConfig = {
// Item configuration
itemType: 'item',
itemIdField: 'id',
itemTextField: 'text',
itemFields: ['text'], // Fields to display/edit
// Layout options
layout: 'list', // 'list', 'grid', 'table'
itemsPerPage: 20,
// Multi-section configuration
mode: 'single', // 'single' | 'multi'
groupBy: null, // Field name to group by (e.g., 'completed')
sections: {}, // Section configuration for simple groupBy mode
advancedSections: [], // Advanced section configuration with filter functions
sectionTransitions: {
enabled: true,
duration: 300,
easing: 'ease-in-out'
},
// Feature flags
enableCRUD: true,
enableSearch: false,
enableFilters: false,
enableSorting: false,
enablePagination: false,
enableBulkActions: false,
enableToggle: false, // Boolean or { field: 'completed', label: 'Mark complete' }
// Actions configuration
actions: {
item: ['edit', 'delete'], // Available item actions
bulk: ['delete'], // Available bulk actions
global: ['add'] // Available global actions
},
// Section-specific actions configuration (for multi-section mode)
sectionActions: {
// Example: 'sectionId': {
// global: ['action1'],
// item: ['edit'],
// bulk: ['delete'],
// actionConfigs: {
// 'action1': { label: 'Custom Label', icon: '🎯', type: 'primary' }
// }
// }
},
// Form configuration
forms: {
add: {
title: 'Add New Item',
fields: [] // Auto-generated from itemFields if empty
},
edit: {
title: 'Edit Item',
fields: [] // Auto-generated from itemFields if empty
}
},
// Display configuration
emptyStateMessage: 'No items found',
confirmDeletes: true,
showItemCount: true,
showStats: false,
// Search configuration
search: {
placeholder: 'Search items...',
debounceMs: 300,
searchFields: ['text'] // Fields to search in
},
// Table-specific configuration (when layout === 'table')
columns: [], // Auto-generated from itemFields if empty
// Grid-specific configuration (when layout === 'grid')
gridColumns: 'auto-fit',
gridMinWidth: '200px',
// Merge user configuration
...config.list
};
// 2.5. Intelligent configuration enhancement based on schema fields
this.enhanceConfigurationFromSchema(config);
// 2.6. Validate multi-section configuration
this.validateMultiSectionConfig();
// 3. Initialize component state
this.listState = {
// Display state
currentPage: 1,
sortColumn: null,
sortDirection: 'asc',
filterQuery: '',
activeFilters: new Map(),
selectedItems: new Set(),
// Interaction state
isEditing: null,
editingData: {},
showBulkActions: false,
// Multi-section state
sectionStates: new Map(), // section id -> { collapsed: boolean }
itemTransitions: new Map(), // item id -> { from: section, to: section, startTime: timestamp }
sectionsData: new Map() // section id -> filtered items array
};
// 4. Re-render manually AFTER properties are set
this.render();
this.log('INFO', 'ListComponent initialized');
}
/**
* 5. ALWAYS override init() to prevent premature rendering
*/
init() {
if (this.isDestroyed) return;
// DON'T call render() here - constructor handles it
this.bindEvents();
this.startPolling();
this.log('INFO', 'ListComponent events bound');
}
/**
* Enhance configuration based on schema field analysis
* This method detects common patterns and auto-configures appropriate features
* @param {Object} originalConfig - The original user configuration passed to constructor
*/
enhanceConfigurationFromSchema(originalConfig) {
const fields = this.listConfig.fields || [];
// Detect common field patterns
const hasTextField = fields.some(f => f.key === 'text' || f.key === 'task' || f.key === 'title' || f.key === 'name');
const hasCompletedField = fields.some(f => f.key === 'completed' || f.key === 'done' || f.key === 'finished');
const hasPriorityField = fields.some(f => f.key === 'priority');
const hasCategoryField = fields.some(f => f.key === 'category' || f.key === 'type');
const hasDateField = fields.some(f => f.key === 'dueDate' || f.key === 'date' || f.type === 'date');
// Auto-detect data type and configure accordingly
if (hasTextField && (hasCompletedField || hasPriorityField)) {
this.log('INFO', 'Detected todo/task list pattern - auto-configuring features');
// Configure for todo/task management
this.listConfig.itemType = 'todo';
this.listConfig.itemTextField = hasTextField ? 'text' : 'task';
// Build itemFields from detected schema
this.listConfig.itemFields = ['text'];
if (hasPriorityField) this.listConfig.itemFields.push('priority');
if (hasCategoryField) this.listConfig.itemFields.push('category');
if (hasDateField) this.listConfig.itemFields.push('dueDate');
// Note: Toggle functionality has been removed
// The completed field is now handled via checkbox display only
// Configure forms if not already configured
if (!this.listConfig.forms.add.fields || this.listConfig.forms.add.fields.length === 0) {
this.listConfig.forms.add = {
title: 'Add New Todo',
fields: this.generateFormFieldsFromSchema(fields)
};
}
if (!this.listConfig.forms.edit.fields || this.listConfig.forms.edit.fields.length === 0) {
this.listConfig.forms.edit = {
title: 'Edit Todo',
fields: this.generateFormFieldsFromSchema(fields)
};
}
// Configure display
this.listConfig.emptyStateMessage = this.listConfig.emptyStateMessage || 'No todos yet! Add your first one to get started.';
this.listConfig.confirmDeletes = true;
} else if (hasTextField) {
// Generic list configuration
this.log('INFO', 'Detected generic list pattern - using basic configuration');
this.listConfig.itemTextField = this.detectPrimaryTextField(fields);
// Generate basic form if none provided
if (!this.listConfig.forms.add.fields || this.listConfig.forms.add.fields.length === 0) {
this.listConfig.forms.add.fields = this.generateFormFieldsFromSchema(fields);
}
}
// Ensure global actions include 'add' if CRUD is enabled
if (this.listConfig.enableCRUD && !this.listConfig.actions.global.includes('add')) {
this.listConfig.actions.global.push('add');
}
}
/**
* Generate form fields from schema fields for add/edit forms
*/
generateFormFieldsFromSchema(schemaFields) {
return schemaFields
.filter(field => {
// Exclude system/read-only fields from forms
return !['id', 'createdAt', 'completed', 'completedAt', 'timeToComplete'].includes(field.key);
})
.map(field => {
const formField = {
name: field.key, // Use 'name' for ModalComponent compatibility
label: field.label,
type: this.getFormFieldTypeFromSchema(field),
required: field.key === 'text' || field.key === 'name' || field.key === 'title',
placeholder: this.getFieldPlaceholder(field)
};
// Add options for select fields
if (formField.type === 'select') {
formField.options = this.getFormFieldOptionsFromSchema(field);
}
return formField;
});
}
/**
* Convert schema field type to form field type
*/
getFormFieldTypeFromSchema(field) {
if (field.key === 'priority') return 'select';
if (field.key === 'text' || field.key === 'task') return 'textarea';
if (field.type === 'date') return 'date';
if (field.type === 'checkbox') return 'checkbox';
return 'text';
}
/**
* Get appropriate placeholder text for form fields
*/
getFieldPlaceholder(field) {
const placeholders = {
'text': 'Enter your todo...',
'task': 'What needs to be done?',
'title': 'Enter title...',
'name': 'Enter name...',
'category': 'Optional category',
'dueDate': '',
'priority': ''
};
return placeholders[field.key] || `Enter ${field.label.toLowerCase()}...`;
}
/**
* Get form field options from schema
*/
getFormFieldOptionsFromSchema(field) {
if (field.key === 'priority') {
return [
{ value: 'low', label: 'Low' },
{ value: 'medium', label: 'Medium' },
{ value: 'high', label: 'High' },
{ value: 'urgent', label: 'Urgent' }
];
}
return field.options || [];
}
/**
* Detect the primary text field from schema
*/
detectPrimaryTextField(fields) {
const candidates = ['text', 'title', 'name', 'task', 'description'];
for (const candidate of candidates) {
if (fields.some(f => f.key === candidate)) {
return candidate;
}
}
return fields.length > 0 ? fields[0].key : 'text';
}
/**
* Validate multi-section configuration
* Ensures that multi-section mode has proper configuration
*/
validateMultiSectionConfig() {
const errors = [];
if (this.listConfig.mode === 'multi') {
// Must have either groupBy or advancedSections
if (!this.listConfig.groupBy && (!this.listConfig.advancedSections || this.listConfig.advancedSections.length === 0)) {
errors.push('Multi mode requires either groupBy or advancedSections');
}
// If using groupBy, must have sections configuration
if (this.listConfig.groupBy && (!this.listConfig.sections || Object.keys(this.listConfig.sections).length === 0)) {
errors.push('groupBy mode requires sections configuration');
}
// If using advancedSections, validate section structure
if (this.listConfig.advancedSections && this.listConfig.advancedSections.length > 0) {
this.listConfig.advancedSections.forEach((section, index) => {
if (!section.id) {
errors.push(`Advanced section ${index} missing required 'id' property`);
}
if (!section.name) {
errors.push(`Advanced section ${index} missing required 'name' property`);
}
if (!section.filter || typeof section.filter !== 'function') {
errors.push(`Advanced section ${index} missing required 'filter' function`);
}
});
}
}
if (errors.length > 0) {
this.log('WARN', `Multi-section configuration errors: ${errors.join(', ')}`);
// Fallback to single mode on configuration errors
this.listConfig.mode = 'single';
this.log('INFO', 'Falling back to single mode due to configuration errors');
}
}
/**
* Main render method
*/
render() {
if (this.isDestroyed) return;
// Update sections data if in multi-section mode
if (this.listConfig.mode === 'multi') {
this.updateSectionsData();
}
const modeClass = this.listConfig.mode === 'multi' ? 'multi-section' : 'single-section';
this.element.innerHTML = this.html`
<div class="component component-list component-${this.listConfig.layout} ${modeClass}">
${this.trustedHtml(this.renderHeader())}
${this.trustedHtml(this.renderToolbar())}
${this.trustedHtml(this.renderContent())}
${this.trustedHtml(this.renderFooter())}
</div>
`;
this.postRenderSetup();
}
/**
* Render component header
*/
renderHeader() {
const stats = this.calculateStats();
return this.html`
<div class="list-header">
<div class="header-title">
<h2>${this.config.title || this.getDefaultTitle()}</h2>
${this.listConfig.showItemCount ? this.trustedHtml(`
<span class="item-count">(${stats.total} ${this.getItemLabel(stats.total)})</span>
`) : ''}
</div>
${this.listConfig.showStats ? this.trustedHtml(this.renderStats(stats)) : ''}
<div class="header-actions">
${this.trustedHtml(this.renderGlobalActions())}
</div>
</div>
`;
}
/**
* Render statistics section
*/
renderStats(stats) {
const statsToShow = [];
// Toggle stats removed - use checkbox-based completion tracking instead
if (this.listState.selectedItems.size > 0) {
statsToShow.push({
label: 'Selected',
value: this.listState.selectedItems.size,
type: 'info'
});
}
if (statsToShow.length === 0) return '';
return this.html`
<div class="list-stats">
${this.trustedHtml(statsToShow.map(stat => `
<div class="stat-item stat-${stat.type}">
<span class="stat-value">${stat.value}</span>
<span class="stat-label">${stat.label}</span>
</div>
`).join(''))}
</div>
`;
}
/**
* Render global actions
*/
renderGlobalActions() {
const actions = this.listConfig.actions.global;
if (!actions || actions.length === 0) return '';
return this.html`
<div class="global-actions">
${this.trustedHtml(actions.map(action => this.renderGlobalAction(action)).join(''))}
</div>
`;
}
/**
* Render a single global action
*/
renderGlobalAction(action) {
const actionConfig = this.getActionConfig(action);
return `
<button
class="btn btn-${actionConfig.type || 'primary'}"
data-action="global-${action}"
title="${actionConfig.title || actionConfig.label}"
>
${actionConfig.icon || ''} ${actionConfig.label}
</button>
`;
}
/**
* Render toolbar with search, filters, etc.
*/
renderToolbar() {
if (!this.hasToolbarFeatures()) return '';
return this.html`
<div class="list-toolbar">
${this.listConfig.enableSearch ? this.trustedHtml(this.renderSearch()) : ''}
${this.listConfig.enableFilters ? this.trustedHtml(this.renderFilters()) : ''}
${this.listConfig.enableSorting && this.listConfig.layout !== 'table' ? this.trustedHtml(this.renderSortControls()) : ''}
</div>
`;
}
/**
* Render search input
*/
renderSearch() {
return this.html`
<div class="search-container">
<input
type="text"
class="search-input"
placeholder="${this.listConfig.search.placeholder}"
value="${this.listState.filterQuery}"
data-action="search"
>
${this.listState.filterQuery ? this.html`
<button class="btn-clear-search" data-action="clear-search" title="Clear search">
×
</button>
` : ''}
</div>
`;
}
/**
* Render filter controls
*/
renderFilters() {
if (!this.listConfig.filters || !this.listConfig.filters.length) {
return '';
}
return this.html`
<div class="filters-container">
${this.trustedHtml(this.listConfig.filters.map(filter => `
<div class="filter-group">
<label class="filter-label">${filter.label}:</label>
<select class="filter-select" data-action="filter" data-filter="${filter.key}">
<option value="">All ${filter.label}</option>
${filter.options.map(option => `
<option value="${option.value}" ${this.listState.filters[filter.key] === option.value ? 'selected' : ''}>
${option.label}
</option>
`).join('')}
</select>
</div>
`).join(''))}
</div>
`;
}
/**
* Render sort controls
*/
renderSortControls() {
const sortableColumns = this.getTableColumns().filter(col => col.sortable);
if (!sortableColumns.length) {
return '';
}
return this.html`
<div class="sort-controls">
<label class="sort-label">Sort by:</label>
<select class="sort-select" data-action="sort-select">
<option value="">Default</option>
${this.trustedHtml(sortableColumns.map(col => `
<option value="${col.key}" ${this.listState.sortColumn === col.key ? 'selected' : ''}>
${col.label}
</option>
`).join(''))}
</select>
${this.listState.sortColumn ? this.trustedHtml(`
<button class="sort-direction-btn" data-action="toggle-sort-direction" title="Toggle sort direction">
${this.listState.sortDirection === 'asc' ? '↑' : '↓'}
</button>
`) : ''}
</div>
`;
}
/**
* Render content based on layout and mode
*/
renderContent() {
// Handle multi-section mode
if (this.listConfig.mode === 'multi') {
return this.renderMultiSections();
}
// Handle single-section mode (existing behavior)
const processedItems = this.getProcessedItems();
if (processedItems.length === 0) {
return this.renderEmptyState();
}
return this.html`
<div class="list-content">
${this.trustedHtml(this.renderItems(processedItems))}
</div>
`;
}
/**
* Update sections data by organizing items into sections
*/
updateSectionsData() {
this.listState.sectionsData.clear();
if (this.listConfig.groupBy) {
// Simple groupBy mode
const sections = Object.keys(this.listConfig.sections);
sections.forEach(sectionKey => {
const sectionItems = this.data.filter(item => {
const itemValue = item[this.listConfig.groupBy];
// Handle boolean values specially
if (typeof itemValue === 'boolean') {
return String(itemValue) === sectionKey;
}
return itemValue === sectionKey;
});
this.listState.sectionsData.set(sectionKey, sectionItems);
});
} else if (this.listConfig.advancedSections.length > 0) {
// Advanced sections mode
this.listConfig.advancedSections.forEach(section => {
const sectionItems = this.data.filter(section.filter);
this.listState.sectionsData.set(section.id, sectionItems);
});
}
}
/**
* Render multi-section layout
*/
renderMultiSections() {
const sections = this.getSectionConfigs();
if (sections.length === 0) {
return this.renderEmptyState();
}
return this.html`
<div class="list-content multi-sections">
${this.trustedHtml(sections.map(section => this.renderSection(section)).join(''))}
</div>
`;
}
/**
* Get section configurations in display order
*/
getSectionConfigs() {
if (this.listConfig.groupBy) {
// Simple groupBy mode
return Object.entries(this.listConfig.sections)
.map(([key, config]) => ({
id: key,
...config
}))
.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0));
} else if (this.listConfig.advancedSections.length > 0) {
// Advanced sections mode
return [...this.listConfig.advancedSections]
.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0));
}
return [];
}
/**
* Render a single section
*/
renderSection(section) {
const sectionItems = this.listState.sectionsData.get(section.id) || [];
const isCollapsed = this.listState.sectionStates.get(section.id)?.collapsed || false;
return this.html`
<div class="list-section" data-section="${section.id}">
${this.trustedHtml(this.renderSectionHeader(section, sectionItems))}
${!isCollapsed ? this.trustedHtml(this.renderSectionContent(section, sectionItems)) : ''}
</div>
`;
}
/**
* Render section header
*/
renderSectionHeader(section, items) {
const itemCount = items.length;
const isCollapsed = this.listState.sectionStates.get(section.id)?.collapsed || false;
const hasSectionActions = this.listConfig.sectionActions[section.id]?.global?.length > 0;
return this.html`
<div class="section-header" data-section="${section.id}">
<div class="section-title">
${section.icon ? this.trustedHtml(`<span class="section-icon">${section.icon}</span>`) : ''}
<h3 class="section-name">${section.name}</h3>
<span class="section-count">(${itemCount})</span>
</div>
<div class="section-header-controls">
${hasSectionActions ? this.trustedHtml(this.renderSectionActions(section.id)) : ''}
${section.collapsible ? this.trustedHtml(`
<button class="section-toggle"
data-action="toggle-section"
data-section="${section.id}"
aria-expanded="${!isCollapsed}"
title="${isCollapsed ? 'Expand section' : 'Collapse section'}">
${isCollapsed ? '▶' : '▼'}
</button>
`) : ''}
</div>
</div>
`;
}
/**
* Render section content
*/
renderSectionContent(section, items) {
if (items.length === 0) {
return this.html`
<div class="section-content empty">
<div class="section-empty-state">
<p>No items in this section</p>
</div>
</div>
`;
}
return this.html`
<div class="section-content">
${this.trustedHtml(this.renderItems(items))}
</div>
`;
}
/**
* Render items based on layout
*/
renderItems(items) {
switch (this.listConfig.layout) {
case 'grid':
return this.renderGrid(items);
case 'table':
return this.renderTable(items);
default:
return this.renderList(items);
}
}
/**
* Render list layout
*/
renderList(items) {
return this.html`
<div class="items-list">
${this.listConfig.enableBulkActions ? this.trustedHtml(this.renderBulkSelector()) : ''}
${this.trustedHtml(items.map(item => this.renderListItem(item)).join(''))}
</div>
`;
}
/**
* Render grid layout
*/
renderGrid(items) {
const gridStyle = `grid-template-columns: repeat(auto-fit, minmax(${this.listConfig.gridMinWidth}, 1fr))`;
return this.html`
<div class="items-grid" style="${gridStyle}">
${this.trustedHtml(items.map(item => this.renderGridItem(item)).join(''))}
</div>
`;
}
/**
* Render table layout
*/
renderTable(items) {
const columns = this.getTableColumns();
return this.html`
<div class="table-container">
<table class="items-table">
${this.trustedHtml(this.renderTableHeader(columns))}
${this.trustedHtml(this.renderTableBody(items, columns))}
</table>
</div>
`;
}
/**
* Render table header
*/
renderTableHeader(columns) {
return this.html`
<thead>
<tr>
${this.listConfig.enableBulkActions ? this.html`
<th class="select-column">
<input
type="checkbox"
data-action="select-all"
${this.isAllSelected() ? 'checked' : ''}
>
</th>
` : ''}
${this.trustedHtml(columns.map(column => `
<th class="column-header ${column.sortable && this.listConfig.enableSorting ? 'sortable' : ''}"
${column.sortable && this.listConfig.enableSorting ? `data-action="sort" data-column="${column.key}"` : ''}>
${column.label}
${column.sortable && this.listConfig.enableSorting && this.listState.sortColumn === column.key ? `
<span class="sort-indicator ${this.listState.sortDirection}">
${this.listState.sortDirection === 'asc' ? '↑' : '↓'}
</span>
` : ''}
</th>
`).join(''))}
${this.hasItemActions() ? this.html`<th class="actions-column">Actions</th>` : ''}
</tr>
</thead>
`;
}
/**
* Render table body
*/
renderTableBody(items, columns) {
return this.html`
<tbody>
${this.trustedHtml(items.map(item => this.renderTableRow(item, columns)).join(''))}
</tbody>
`;
}
/**
* Render a single list item
*/
renderListItem(item) {
const itemId = this.getItemId(item);
const isSelected = this.listState.selectedItems.has(itemId);
const isCompleted = this.hasCompletedField(item) && item.completed;
const hasCompleted = this.hasCompletedField(item);
return `
<div class="list-item ${isSelected ? 'selected' : ''} ${isCompleted ? 'completed' : ''}"
data-id="${itemId}">
<div class="item-main">
${this.listConfig.enableBulkActions ? `
<div class="item-selector">
<input
type="checkbox"
data-action="select-item"
data-id="${itemId}"
${isSelected ? 'checked' : ''}
>
</div>
` : ''}
${hasCompleted ? `
<div class="item-toggle">
<input
type="checkbox"
class="item-checkbox"
data-action="toggle-item"
data-id="${itemId}"
${item.completed ? 'checked' : ''}
title="${item.completed ? 'Mark as incomplete' : 'Mark as complete'}"
>
</div>
` : ''}
<div class="item-content">
${this.renderItemContent(item)}
</div>
</div>
${this.hasItemActions() ? `
<div class="item-actions">
${this.renderItemActions(item)}
</div>
` : ''}
</div>
`;
}
/**
* Render item content based on configured fields
*/
renderItemContent(item) {
// Use schema fields if available, fallback to legacy itemFields
const schemaFields = this.listConfig.fields || [];
const hasSchemaFields = schemaFields.length > 0;
if (hasSchemaFields) {
// Use schema-based field rendering
const visibleFields = schemaFields.filter(field => field.key !== 'id' && field.key !== 'completed');
return `
<div class="item-content-schema">
${visibleFields.map(field => {
const value = item[field.key];
if (value == null || value === '') return '';
return `
<div class="item-field item-field-${field.key}">
<span class="field-label">${field.label}:</span>
<span class="field-value field-${field.type || 'text'}">
${this.formatSchemaFieldValue(value, field)}
</span>
</div>
`;
}).join('')}
</div>
`;
} else {
// Legacy field rendering
const primaryField = this.listConfig.itemTextField;
const primaryValue = item[primaryField] || '';
const secondaryFields = this.listConfig.itemFields.filter(field => field !== primaryField);
return `
<div class="item-primary">
<span class="item-text">${primaryValue}</span>
</div>
${secondaryFields.length > 0 ? `
<div class="item-secondary">
${secondaryFields.map(field => `
<span class="item-field item-${field}">
${this.formatFieldValue(item[field], field)}
</span>
`).join('')}
</div>
` : ''}
`;
}
}
/**
* Check if item has a completed field that can be toggled
*/
hasCompletedField(item) {
return item.hasOwnProperty('completed') && typeof item.completed === 'boolean';
}
/**
* Format field value based on schema field configuration
*/
formatSchemaFieldValue(value, field) {
if (value == null) return '';
// Apply custom format function if provided
if (field.format && typeof field.format === 'function') {
try {
value = field.format(value);
} catch (error) {
console.warn('Field format function error:', error);
}
}
// Special handling for createdAt field - always format as human-readable date
if (field.key === 'createdAt' && value) {
const formattedDate = this.formatCreatedDate(value);
return `<span class="date-value created-date">${formattedDate}</span>`;
}
// Apply type-specific formatting
switch (field.type) {
case 'badge':
const badgeClass = `badge badge-${field.key} badge-${String(value).toLowerCase()}`;
return `<span class="${badgeClass}">${value}</span>`;
case 'date':
// Value might already be formatted by format function
return `<span class="date-value">${value}</span>`;
case 'checkbox':
return value ? '✓' : '✗';
case 'text':
default:
return `<span class="text-value">${value}</span>`;
}
}
/**
* Render item actions
*/
renderItemActions(item) {
const actions = this.listConfig.actions.item;
const itemId = this.getItemId(item);
return actions.map(action => {
const actionConfig = this.getActionConfig(action);
return `
<button
class="btn-action btn-${actionConfig.type || 'default'}"
data-action="item-${action}"
data-id="${itemId}"
title="${actionConfig.title || actionConfig.label}"
>
${actionConfig.icon || actionConfig.label}
</button>
`;
}).join('');
}
/**
* Render section actions
*/
renderSectionActions(sectionId) {
const sectionActions = this.listConfig.sectionActions[sectionId] || {};
const globalActions = sectionActions.global || [];
if (globalActions.length === 0) return '';
return this.html`
<div class="section-actions">
${this.trustedHtml(globalActions.map(action => this.renderSectionAction(action, sectionId)).join(''))}
</div>
`;
}
/**
* Render a single section action
*/
renderSectionAction(action, sectionId) {
const actionConfig = this.getSectionActionConfig(action, sectionId);
return `
<button
class="btn btn-${actionConfig.type || 'secondary'} btn-sm"
data-action="section-${action}"
data-section="${sectionId}"
title="${actionConfig.title || actionConfig.label}"
>
${actionConfig.icon || ''} ${actionConfig.label}
</button>
`;
}
/**
* Render empty state
*/
renderEmptyState() {
return '';
}
/**
* Render footer with pagination, bulk actions, etc.
*/
renderFooter() {
if (!this.hasFooterFeatures()) return '';
return this.html`
<div class="list-footer">
${this.listState.selectedItems.size > 0 ? this.trustedHtml(this.renderBulkActions()) : ''}
${this.listConfig.enablePagination ? this.trustedHtml(this.renderPagination()) : ''}
</div>
`;
}
/**
* Render bulk actions
*/
renderBulkActions() {
const actions = this.listConfig.actions.bulk;
const selectedCount = this.listState.selectedItems.size;
return this.html`
<div class="bulk-actions">
<span class="bulk-info">
${selectedCount} ${this.getItemLabel(selectedCount)} selected
</span>
<div class="bulk-controls">
<button class="btn btn-secondary" data-action="deselect-all">
Deselect All
</button>
${this.trustedHtml(actions.map(action => {
const actionConfig = this.getActionConfig(action);
return `
<button
class="btn btn-${actionConfig.type || 'default'}"
data-action="bulk-${action}"
>
${actionConfig.icon || ''} ${actionConfig.label}
</button>
`;
}).join(''))}
</div>
</div>
`;
}
/**
* Bind all event listeners
*/
bindEvents() {
// Global actions
this.on('click', '[data-action^="global-"]', (e) => {
const action = e.target.dataset.action.replace('global-', '');
this.handleGlobalAction(action);
});
// Item actions
this.on('click', '[data-action^="item-"]', (e) => {
const action = e.target.dataset.action.replace('item-', '');
const id = e.target.dataset.id;
this.handleItemAction(action, id);
});
// Bulk actions
this.on('click', '[data-action^="bulk-"]', (e) => {
const action = e.target.dataset.action.replace('bulk-', '');
this.handleBulkAction(action);
});
// Section actions
this.on('click', '[data-action^="section-"]', (e) => {
const action = e.target.dataset.action.replace('section-', '');
const sectionId = e.target.dataset.section;
this.handleSectionAction(action, sectionId);
});
// Selection
if (this.listConfig.enableBulkActions) {
this.on('change', '[data-action="select-all"]', (e) => {
this.handleSelectAll(e.target.checked);
});
this.on('change', '[data-action="select-item"]', (e) => {
const id = e.target.dataset.id;
this.handleSelectItem(id, e.target.checked);
});
this.on('click', '[data-action="deselect-all"]', () => {
this.handleDeselectAll();
});
}
// Toggle completion checkbox
this.on('change', '[data-action="toggle-item"]', (e) => {
const id = e.target.dataset.id;
const completed = e.target.checked;
this.handleToggleItem(id, completed);
});
// Search
if (this.listConfig.enableSearch) {
this.on('input', '[data-action="search"]', (e) => {
this.handleSearch(e.target.value);
});
this.on('click', '[data-action="clear-search"]', () => {
this.handleClearSearch();
});
}
// Sorting
if (this.listConfig.enableSorting) {
this.on('click', '[data-action="sort"]', (e) => {
const column = e.target.dataset.column;
this.handleSort(column);
});
this.on('change', '[data-action="sort-select"]', (e) => {
const column = e.target.value;
this.handleSort(column);
});
this.on('click', '[data-action="toggle-sort-direction"]', () => {
this.handleToggleSortDirection();
});
}
// Filtering
if (this.listConfig.enableFilters) {
this.on('change', '[data-action="filter"]', (e) => {
const filterKey = e.target.dataset.filter;
const filterValue = e.target.value;
this.handleFilter(filterKey, filterValue);
});
}
// Section toggles (for multi-section mode)
if (this.listConfig.mode === 'multi') {
this.on('click', '[data-action="toggle-section"]', (e) => {
const sectionId = e.target.dataset.section;
this.handleToggleSection(sectionId);
});
}
}
// Action Handlers
/**
* Handle global actions
*/
async handleGlobalAction(action) {
switch (action) {
case 'add':
try {
// Show form modal directly - no need to check with server first
await this.showFormModal('add');
} catch (error) {
this.log('ERROR', `Add action failed: ${error.message}`);
this.handleError(error);
}
break;
default:
this.log('WARN', `Unknown global action: ${action}`);
}
}
/**
* Handle item actions
*/
async handleItemAction(action, id) {
const item = this.findItemById(id);
if (!item) return;
switch (action) {
case 'edit':
await this.showFormModal('edit', item);
break;
case 'delete':
await this.handleDeleteItem(id);
break;
default:
this.log('WARN', `Unknown item action: ${action}`);
}
}
/**
* Handle bulk actions
*/
async handleBulkAction(action) {
const selectedIds = Array.from(this.listState.selectedItems);
switch (action) {
case 'delete':
await this.handleBulkDelete(selectedIds);
break;
default:
this.log('WARN', `Unknown bulk action: ${action}`);
}
}
/**
* Handle section actions
*/
async handleSectionAction(action, sectionId) {
try {
this.log('INFO', `Handling section action: ${action} for section: ${sectionId}`);
// For most actions, delegate to the main action handler
// This allows section-specific actions to be handled by the server/component
const result = await this.handleAction(action, { sectionId });
if (result.success) {
this.log('INFO', `Section action completed: ${action}`);
}
} catch (error) {
this.log('ERROR', `Section action failed: ${action} for section ${sectionId}: ${error.message}`);
this.handleError(error);
}
}
/**
* Handle toggle item completion
*/
async handleToggleItem(id, completed) {
const item = this.findItemById(id);
if (!item) return;
try {
// Update the item locally first for responsive UI
item.completed = completed;
if (completed) {
item.completedAt = new Date().toISOString();
} else {
delete item.completedAt;
}
// Update the server - use 'toggle-item' action to match backend handler
await this.handleAction('toggle-item', {
id,
completed,
completedAt: completed ? item.completedAt : null
});
this.log('INFO', `Item ${completed ? 'completed' : 'uncompleted'}: ${id}`);
// Re-render to apply sorting and move completed items to bottom
this.render();
} catch (error) {
// Revert local change if server update failed
item.completed = !completed;
if (!completed) {
item.completedAt = new Date().toISOString();
} else {
delete item.completedAt;
}
// Re-render to show the reverted state
this.render();
this.handleError(error);
}
}
/**
* Handle delete item with confirmation modal
*/
async handleDeleteItem(id) {
const item = this.findItemById(id);
if (!item) return;
if (this.listConfig.confirmDeletes) {
if (!window.MCPModal) {
// Fallback to native confirm if modal not available
const confirmMessage = this.getDeleteConfirmMessage(item);
if (!confirm(confirmMessage)) return;
} else {
const confirmed = await window.MCPModal.confirm({
title: 'Confirm Delete',
message: this.getDeleteConfirmMessage(item),
confirmText: 'Delete',
cancelText: 'Cancel'
// Removed invalid type: 'danger' - let MCPModal.confirm() set type: 'confirm'
});
if (!confirmed || confirmed.action !== 'confirm') return;
}
}
try {
await this.handleAction('delete', { id });
this.log('INFO', `Item deleted: ${id}`);
} catch (error) {
this.handleError(error);
}
}
/**
* Get delete confirmation message, using schema action config if available
*/
getDeleteConfirmMessage(item) {
// Check if we have schema actions with a confirm message
const schemaActions = this.config?.actions;
if (schemaActions) {
const deleteAction = schemaActions.find(action => action.id === 'delete' || action.handler === 'delete');
if (deleteAction && deleteAction.confirm) {
return deleteAction.confirm;
}
}
// Fallback to generic message
return `Delete "${item[this.listConfig.itemTextField]}"?`;
}
/**
* Handle search
*/
handleSearch(query) {
this.listState.filterQuery = query.trim();
this.listState.currentPage = 1; // Reset to first page
this.render();
}
/**
* Show form modal using server-provided form schema
*/
async showServerFormModal(type, formSchema) {
if (!window.MCPModal) {
this.log('ERROR', 'ModalComponent not available');
return;
}
try {
const result = await window.MCPModal.form({
title: formSchema.title || (type === 'add' ? 'Add Item' : 'Edit Item'),
fields: formSchema.fields || [],
initialData: {},
onSubmit: async (formData) => {
console.log('DEBUG: Add form submitted with data:'