A Pen by Pelle Wessman on CodePen.
Originally created by Gemini.
<h1>Loan Applications Management</h1> | |
<h2>Applications List</h2> | |
<admin-list resource="loanApplications"></admin-list> | |
<hr> | |
<h2>Create New Loan Application</h2> | |
<admin-form resource="loanApplications"></admin-form> | |
<hr> | |
<h2>Edit Loan Application (ID: 2)</h2> | |
<admin-form resource="loanApplications" resource-id="2"></admin-form> |
// --- MOCK PROVIDERS --- | |
// In a real application, these would be imported or injected. | |
// They implement the StandardDataProvider and StandardSchemaProvider interfaces. | |
const mockLoanAppSchema = { | |
resource: 'loanApplications', | |
displayField: 'applicantName', | |
fields: { | |
id: { name: 'id', label: 'ID', type: 'number', readOnly: true }, | |
applicantName: { name: 'applicantName', label: 'Applicant Name', type: 'string', required: true, filterable: true, sortable: true, constraints: { maxLength: 100 } }, | |
amount: { name: 'amount', label: 'Amount', type: 'number', required: true, constraints: { min: 100 }, sortable: true }, | |
status: { name: 'status', label: 'Status', type: 'enum', required: true, filterable: true, options: [ { label: 'Pending', value: 'pending'}, { label: 'Approved', value: 'approved'}, { label: 'Rejected', value: 'rejected'} ], defaultValue: 'pending' }, | |
notes: { name: 'notes', label: 'Internal Notes', type: 'text', uiHint: 'textarea'}, | |
createdAt: { name: 'createdAt', label: 'Created At', type: 'datetime', readOnly: true, sortable: true }, | |
} | |
}; | |
// Use a simple in-memory store for the mock data | |
let mockLoanAppsStore = [ | |
{ id: 1, applicantName: 'Alice Smith', amount: 5000, status: 'pending', notes: 'Initial application.', createdAt: new Date(Date.now() - 86400000).toISOString() }, // 1 day ago | |
{ id: 2, applicantName: 'Bob Johnson', amount: 15000, status: 'approved', notes: '', createdAt: new Date(Date.now() - 172800000).toISOString() }, // 2 days ago | |
{ id: 3, applicantName: 'Charlie Brown', amount: 2000, status: 'pending', notes: 'Needs follow up.', createdAt: new Date().toISOString() }, | |
]; | |
let nextId = 4; | |
const schemaProvider = { | |
getSchema: async ({ resource }) => { | |
console.log(`SchemaProvider: getSchema requested for ${resource}`); | |
if (resource === 'loanApplications') { | |
await new Promise(res => setTimeout(res, 50)); // Simulate network delay | |
// Return a deep copy to prevent accidental modification | |
return { schema: JSON.parse(JSON.stringify(mockLoanAppSchema)) }; | |
} | |
// Simulate not found error | |
const error = new Error(`Schema for resource ${resource} not found.`); | |
error.name = 'NotFoundError'; | |
error.status = 404; | |
throw error; | |
} | |
}; | |
const dataProvider = { | |
getList: async ({ resource, pagination, sort, filter }) => { | |
console.log(`DataProvider: getList requested for ${resource}`, { pagination, sort, filter }); | |
if (resource === 'loanApplications') { | |
await new Promise(res => setTimeout(res, 150)); // Simulate network delay | |
let dataView = [...mockLoanAppsStore]; // Work with a copy | |
// Simulate filtering (case-insensitive contains for name, exact for status) | |
if (filter) { | |
Object.entries(filter).forEach(([key, value]) => { | |
if (value === null || value === '') return; | |
dataView = dataView.filter(item => { | |
if (item[key] === undefined || item[key] === null) return false; | |
if (key === 'applicantName') { | |
return item[key].toLowerCase().includes(String(value).toLowerCase()); | |
} | |
if (key === 'status') { | |
return item[key] === value; | |
} | |
// Add more filter logic as needed | |
return String(item[key]).toLowerCase().includes(String(value).toLowerCase()); | |
}); | |
}); | |
} | |
// Simulate sorting | |
if (sort && sort.field) { | |
dataView.sort((a, b) => { | |
const valA = a[sort.field]; | |
const valB = b[sort.field]; | |
if (valA < valB) return sort.order === 'ASC' ? -1 : 1; | |
if (valA > valB) return sort.order === 'ASC' ? 1 : -1; | |
return 0; | |
}); | |
} | |
// Simulate pagination | |
const page = pagination?.page || 1; | |
const perPage = pagination?.perPage || 10; | |
const total = dataView.length; | |
const offset = (page - 1) * perPage; | |
const paginatedData = dataView.slice(offset, offset + perPage); | |
// Return deep copy of results | |
return { data: JSON.parse(JSON.stringify(paginatedData)), total }; | |
} | |
const error = new Error(`Resource ${resource} not found.`); | |
error.name = 'NotFoundError'; | |
throw error; | |
}, | |
getOne: async ({ resource, id }) => { | |
console.log(`DataProvider: getOne requested for ${resource} id ${id}`); | |
if (resource === 'loanApplications') { | |
await new Promise(res => setTimeout(res, 100)); // Simulate network delay | |
const record = mockLoanAppsStore.find(item => item.id == id); // Loose comparison for id type | |
if (record) { | |
return { data: JSON.parse(JSON.stringify(record)) }; // Return deep copy | |
} | |
const error = new Error(`Record ${id} not found in ${resource}.`); | |
error.name = 'NotFoundError'; | |
error.status = 404; | |
throw error; | |
} | |
const error = new Error(`Resource ${resource} not found.`); | |
error.name = 'NotFoundError'; | |
throw error; | |
}, | |
create: async ({ resource, variables }) => { | |
console.log(`DataProvider: create requested for ${resource}`, variables); | |
if (resource === 'loanApplications') { | |
await new Promise(res => setTimeout(res, 200)); // Simulate network delay | |
// Basic validation simulation | |
const requiredFields = Object.entries(mockLoanAppSchema.fields) | |
.filter(([_, field]) => field.required && !field.readOnly) | |
.map(([key, _]) => key); | |
const missingFields = requiredFields.filter(f => variables[f] === undefined || variables[f] === null || variables[f] === ''); | |
if (missingFields.length > 0) { | |
const error = new Error('Validation Failed'); | |
error.name = 'ValidationError'; | |
error.status = 422; | |
error.details = missingFields.map(f => ({ field: f, message: `${mockLoanAppSchema.fields[f].label} is required.`})); | |
throw error; | |
} | |
// Add more specific validation based on schema constraints if needed | |
const newRecord = { | |
...variables, | |
id: nextId++, | |
createdAt: new Date().toISOString() | |
}; | |
mockLoanAppsStore.push(newRecord); | |
console.log("Store after add:", mockLoanAppsStore); | |
return { data: JSON.parse(JSON.stringify(newRecord)) }; | |
} | |
const error = new Error(`Resource ${resource} not found.`); | |
error.name = 'NotFoundError'; | |
throw error; | |
}, | |
update: async ({ resource, id, variables }) => { | |
console.log(`DataProvider: update requested for ${resource} id ${id}`, variables); | |
if (resource === 'loanApplications') { | |
await new Promise(res => setTimeout(res, 200)); // Simulate network delay | |
const index = mockLoanAppsStore.findIndex(item => item.id == id); | |
if (index === -1) { | |
const error = new Error(`Record ${id} not found in ${resource}.`); | |
error.name = 'NotFoundError'; | |
error.status = 404; | |
throw error; | |
} | |
// Basic validation simulation (e.g., amount cannot be negative) | |
if (variables.amount !== undefined && variables.amount < 0) { | |
const error = new Error('Validation Failed'); | |
error.name = 'ValidationError'; | |
error.status = 422; | |
error.details = [{ field: 'amount', message: 'Amount cannot be negative.' }]; | |
throw error; | |
} | |
// Add more specific validation | |
mockLoanAppsStore[index] = { ...mockLoanAppsStore[index], ...variables }; | |
console.log("Store after update:", mockLoanAppsStore); | |
return { data: JSON.parse(JSON.stringify(mockLoanAppsStore[index])) }; | |
} | |
const error = new Error(`Resource ${resource} not found.`); | |
error.name = 'NotFoundError'; | |
throw error; | |
}, | |
delete: async ({ resource, id }) => { | |
console.log(`DataProvider: delete requested for ${resource} id ${id}`); | |
if (resource === 'loanApplications') { | |
await new Promise(res => setTimeout(res, 180)); // Simulate network delay | |
const index = mockLoanAppsStore.findIndex(item => item.id == id); | |
if (index === -1) { | |
const error = new Error(`Record ${id} not found in ${resource}.`); | |
error.name = 'NotFoundError'; | |
error.status = 404; | |
throw error; | |
} | |
const deletedRecord = mockLoanAppsStore.splice(index, 1)[0]; | |
console.log("Store after delete:", mockLoanAppsStore); | |
return { data: JSON.parse(JSON.stringify(deletedRecord)) }; | |
} | |
const error = new Error(`Resource ${resource} not found.`); | |
error.name = 'NotFoundError'; | |
throw error; | |
} | |
// getMany, updateMany, deleteMany not implemented in mock | |
}; | |
// --- END MOCK PROVIDERS --- | |
// --- <admin-list> WEB COMPONENT --- | |
const adminListTemplate = document.createElement('template'); | |
adminListTemplate.innerHTML = ` | |
<style> | |
:host { display: block; margin-bottom: 1em; } | |
table { border-collapse: collapse; width: 100%; margin-bottom: 1em; font-size: 0.9em; } | |
th, td { border: 1px solid #ccc; padding: 0.5em; text-align: left; vertical-align: top; } | |
th { background-color: #f2f2f2; } | |
th.sortable { cursor: pointer; user-select: none; } | |
th.sortable:hover { background-color: #e8e8e8; } | |
th.sort-asc::after { content: ' ▲'; font-size: 0.8em; } | |
th.sort-desc::after { content: ' ▼'; font-size: 0.8em; } | |
.status-pending { font-weight: bold; color: orange; } | |
.status-approved { color: green; } | |
.status-rejected { color: red; text-decoration: line-through; } | |
.loading, .error { padding: 1em; text-align: center; } | |
.error { color: #a00; background-color: #fdd; border: 1px solid #fbb; } | |
.pagination { margin-top: 1em; text-align: center; } | |
.pagination button { margin: 0 0.3em; padding: 0.3em 0.6em; cursor: pointer; } | |
.pagination button:disabled { cursor: not-allowed; opacity: 0.6; } | |
.filters { margin-bottom: 1em; padding: 0.5em; background-color: #f9f9f9; border: 1px solid #eee; display: flex; gap: 1em; align-items: center; flex-wrap: wrap; } | |
.filters label { font-weight: bold; margin-right: 0.3em;} | |
.filters input, .filters select { padding: 0.3em; } | |
</style> | |
<div class="filters" part="filters"></div> | |
<div class="loading" part="loading">Loading...</div> | |
<div class="error" part="error" hidden></div> | |
<div class="table-container"> | |
<table> | |
<thead part="header"></thead> | |
<tbody part="body"></tbody> | |
</table> | |
</div> | |
<div class="pagination" part="pagination"></div> | |
`; | |
class AdminList extends HTMLElement { | |
static get observedAttributes() { | |
return ['resource', 'per-page']; | |
} | |
constructor() { | |
super(); | |
this.attachShadow({ mode: 'open' }); | |
this.shadowRoot.appendChild(adminListTemplate.content.cloneNode(true)); | |
// State - underscore prefix indicates internal state | |
this._resource = this.getAttribute('resource'); | |
this._schema = null; | |
this._data = []; | |
this._total = 0; | |
this._sort = { field: 'id', order: 'ASC' }; // Default sort | |
this._pagination = { page: 1, perPage: parseInt(this.getAttribute('per-page') || '10', 10) }; | |
this._filter = {}; | |
this._loading = true; | |
this._error = null; | |
// Element refs - cache frequently accessed elements | |
this._thead = this.shadowRoot.querySelector('thead'); | |
this._tbody = this.shadowRoot.querySelector('tbody'); | |
this._loadingEl = this.shadowRoot.querySelector('.loading'); | |
this._errorEl = this.shadowRoot.querySelector('.error'); | |
this._paginationEl = this.shadowRoot.querySelector('.pagination'); | |
this._filtersEl = this.shadowRoot.querySelector('.filters'); | |
this._tableContainer = this.shadowRoot.querySelector('.table-container'); | |
} | |
// Lifecycle callback when element is added to the DOM | |
connectedCallback() { | |
if (!this._resource) { | |
this._setError('Error: resource attribute is required.'); | |
return; | |
} | |
// Initial load | |
this._loadSchemaAndData(); | |
} | |
// Lifecycle callback when observed attributes change | |
attributeChangedCallback(name, oldValue, newValue) { | |
if (oldValue === newValue) return; // No change | |
let needsReload = false; | |
if (name === 'resource') { | |
this._resource = newValue; | |
// Reset state completely on resource change | |
this._schema = null; | |
this._data = []; | |
this._total = 0; | |
this._sort = { field: 'id', order: 'ASC' }; | |
this._pagination = { page: 1, perPage: this._pagination.perPage }; // Keep perPage | |
this._filter = {}; | |
needsReload = true; | |
} else if (name === 'per-page') { | |
const newPerPage = parseInt(newValue || '10', 10); | |
if (!isNaN(newPerPage) && newPerPage > 0) { | |
this._pagination.perPage = newPerPage; | |
this._pagination.page = 1; // Reset to first page | |
needsReload = true; | |
} | |
} | |
if (needsReload && this.isConnected) { // Only fetch if connected | |
this._loadSchemaAndData(); | |
} | |
} | |
// Fetches schema (if needed) then data | |
async _loadSchemaAndData() { | |
this._setLoading(true); | |
this._setError(null); | |
try { | |
// Fetch schema only if we don't have it | |
if (!this._schema) { | |
const { schema } = await schemaProvider.getSchema({ resource: this._resource }); | |
this._schema = schema; | |
// Render static parts that depend on schema | |
this._renderHeader(); | |
this._renderFilters(); | |
} | |
// Fetch data (this might be called separately for pagination/sort/filter) | |
await this._loadData(); | |
} catch (err) { | |
console.error(`[${this._resource}] Error loading schema/data:`, err); | |
this._setError(err.message || 'Failed to load schema or data.'); | |
// Clear dynamic content on error | |
this._thead.innerHTML = ''; | |
this._tbody.innerHTML = ''; | |
this._paginationEl.innerHTML = ''; | |
this._filtersEl.innerHTML = ''; | |
} finally { | |
this._setLoading(false); | |
} | |
} | |
// Fetches data based on current state (pagination, sort, filter) | |
async _loadData() { | |
if (!this._schema) { // Should not happen if _loadSchemaAndData called first | |
console.warn(`[${this._resource}] Attempted to load data before schema.`); | |
return; | |
} | |
this._setLoading(true); // Ensure loading is true specifically for data fetch | |
this._setError(null); | |
try { | |
const { data, total } = await dataProvider.getList({ | |
resource: this._resource, | |
pagination: this._pagination, | |
sort: this._sort, | |
filter: this._filter | |
}); | |
this._data = data; | |
this._total = total; | |
// Re-render dynamic parts | |
this._renderBody(); | |
this._renderPagination(); | |
} catch (err) { | |
console.error(`[${this._resource}] Error loading list data:`, err); | |
this._setError(err.message || 'Failed to load list data.'); | |
// Clear dynamic content on error | |
this._data = []; | |
this._total = 0; | |
this._renderBody(); | |
this._renderPagination(); | |
} finally { | |
this._setLoading(false); | |
} | |
} | |
// Renders the table header based on the schema | |
_renderHeader() { | |
if (!this._schema) return; | |
this._thead.innerHTML = ''; // Clear existing | |
const tr = document.createElement('tr'); | |
Object.values(this._schema.fields).forEach(fieldSchema => { | |
// Simple heuristic: don't show complex types in list view | |
if (fieldSchema.type !== 'json' && fieldSchema.type !== 'text' && fieldSchema.uiHint !== 'textarea' && fieldSchema.uiHint !== 'richtext') { | |
const th = document.createElement('th'); | |
th.textContent = fieldSchema.label; | |
th.setAttribute('part', `header-${fieldSchema.name}`); // Expose part for styling | |
if (fieldSchema.sortable) { | |
th.classList.add('sortable'); | |
th.dataset.field = fieldSchema.name; // Store field name for click handler | |
th.onclick = (e) => this._handleSort(e.target.dataset.field); | |
// Add sort indicator if this field is the current sort field | |
if (this._sort.field === fieldSchema.name) { | |
th.classList.add(this._sort.order === 'ASC' ? 'sort-asc' : 'sort-desc'); | |
} | |
} | |
tr.appendChild(th); | |
} | |
}); | |
// Add empty header for potential action buttons | |
const thAction = document.createElement('th'); | |
thAction.textContent = 'Actions'; | |
tr.appendChild(thAction); | |
this._thead.appendChild(tr); | |
} | |
// Renders the table body based on current data | |
_renderBody() { | |
this._tbody.innerHTML = ''; // Clear existing | |
if (!this._schema) return; | |
if (this._data.length === 0) { | |
const tr = document.createElement('tr'); | |
const cellCount = Object.values(this._schema.fields).filter(f => f.type !== 'json' && f.type !== 'text' && f.uiHint !== 'textarea' && f.uiHint !== 'richtext').length + 1; // +1 for actions | |
const td = document.createElement('td'); | |
td.colSpan = cellCount; | |
td.textContent = 'No records found.'; | |
td.style.textAlign = 'center'; | |
tr.appendChild(td); | |
this._tbody.appendChild(tr); | |
return; | |
} | |
this._data.forEach(item => { | |
const tr = document.createElement('tr'); | |
tr.dataset.id = item.id; // Add id for potential row actions | |
Object.values(this._schema.fields).forEach(fieldSchema => { | |
if (fieldSchema.type !== 'json' && fieldSchema.type !== 'text' && fieldSchema.uiHint !== 'textarea' && fieldSchema.uiHint !== 'richtext') { | |
const td = document.createElement('td'); | |
td.setAttribute('part', `cell-${fieldSchema.name}`); // Expose part | |
let value = item[fieldSchema.name]; | |
let displayValue = ''; | |
// Basic formatting based on type | |
if (value !== null && value !== undefined) { | |
if (fieldSchema.type === 'datetime') { | |
try { displayValue = new Date(value).toLocaleString(); } catch(e) { displayValue = value; } | |
} else if (fieldSchema.type === 'date') { | |
try { displayValue = new Date(value).toLocaleDateString(); } catch(e) { displayValue = value; } | |
} else if (fieldSchema.type === 'boolean') { | |
displayValue = value ? '✓' : '✗'; // Or Yes/No | |
td.style.textAlign = 'center'; | |
} else if (fieldSchema.type === 'enum' && fieldSchema.options) { | |
const option = fieldSchema.options.find(o => o.value == value); // Loose comparison | |
displayValue = option ? option.label : value; | |
// Add class for styling enum status | |
if(fieldSchema.name === 'status') { // Example specific to status | |
td.classList.add(`status-${String(value).toLowerCase()}`); | |
} | |
} else if (fieldSchema.type === 'number') { | |
td.style.textAlign = 'right'; | |
// Add formatting? e.g., locale string | |
displayValue = value; | |
} else { | |
displayValue = value; | |
} | |
} | |
td.textContent = displayValue; | |
tr.appendChild(td); | |
} | |
}); | |
// Add action buttons (Edit/Delete) | |
const tdAction = document.createElement('td'); | |
const editButton = document.createElement('button'); | |
editButton.textContent = 'Edit'; | |
editButton.onclick = () => { | |
// Dispatch an event or handle navigation to edit form | |
this.dispatchEvent(new CustomEvent('edit-item', { detail: { id: item.id, resource: this._resource }, bubbles: true, composed: true })); | |
console.log(`Edit item ${item.id}`); | |
}; | |
const deleteButton = document.createElement('button'); | |
deleteButton.textContent = 'Delete'; | |
deleteButton.onclick = () => this._handleDelete(item.id); | |
tdAction.appendChild(editButton); | |
tdAction.appendChild(deleteButton); | |
tr.appendChild(tdAction); | |
this._tbody.appendChild(tr); | |
}); | |
} | |
// Renders pagination controls | |
_renderPagination() { | |
this._paginationEl.innerHTML = ''; // Clear | |
const currentPage = this._pagination.page; | |
const perPage = this._pagination.perPage; | |
const totalPages = Math.ceil(this._total / perPage); | |
if (totalPages <= 1) return; // No pagination needed | |
const createButton = (text, page, disabled = false) => { | |
const button = document.createElement('button'); | |
button.textContent = text; | |
button.disabled = disabled; | |
button.onclick = () => { | |
this._pagination.page = page; | |
this._loadData(); | |
}; | |
return button; | |
}; | |
this._paginationEl.appendChild(createButton('<< First', 1, currentPage <= 1)); | |
this._paginationEl.appendChild(createButton('< Prev', currentPage - 1, currentPage <= 1)); | |
const pageInfo = document.createElement('span'); | |
pageInfo.textContent = ` Page ${currentPage} of ${totalPages} (${this._total} items) `; | |
pageInfo.style.margin = '0 0.5em'; | |
this._paginationEl.appendChild(pageInfo); | |
this._paginationEl.appendChild(createButton('Next >', currentPage + 1, currentPage >= totalPages)); | |
this._paginationEl.appendChild(createButton('Last >>', totalPages, currentPage >= totalPages)); | |
} | |
// Renders filter inputs based on schema | |
_renderFilters() { | |
this._filtersEl.innerHTML = ''; // Clear | |
if (!this._schema) return; | |
let hasFilters = false; | |
Object.values(this._schema.fields).forEach(fieldSchema => { | |
if(fieldSchema.filterable) { | |
hasFilters = true; | |
const fieldContainer = document.createElement('div'); | |
const label = document.createElement('label'); | |
label.textContent = fieldSchema.label; | |
label.htmlFor = `filter-${fieldSchema.name}`; | |
fieldContainer.appendChild(label); | |
let input; | |
if (fieldSchema.type === 'enum' && fieldSchema.options) { | |
input = document.createElement('select'); | |
const defaultOption = document.createElement('option'); | |
defaultOption.value = ''; | |
defaultOption.textContent = 'All'; | |
input.appendChild(defaultOption); | |
fieldSchema.options.forEach(opt => { | |
const option = document.createElement('option'); | |
option.value = opt.value; | |
option.textContent = opt.label; | |
if (this._filter[fieldSchema.name] == opt.value) { // Loose comparison | |
option.selected = true; | |
} | |
input.appendChild(option); | |
}); | |
// Apply filter immediately on select change | |
input.onchange = (e) => this._handleFilterChange(fieldSchema.name, e.target.value); | |
} else { // Default to text input for other filterable types | |
input = document.createElement('input'); | |
input.type = 'text'; | |
input.placeholder = `Filter by ${fieldSchema.label}`; | |
input.value = this._filter[fieldSchema.name] || ''; | |
// Defer applying text filters until button click | |
} | |
input.id = `filter-${fieldSchema.name}`; | |
input.name = fieldSchema.name; // Use name for easy retrieval | |
fieldContainer.appendChild(input); | |
this._filtersEl.appendChild(fieldContainer); | |
} | |
}); | |
if(hasFilters) { | |
// Add buttons to apply/clear filters | |
const applyButton = document.createElement('button'); | |
applyButton.textContent = 'Apply Filters'; | |
applyButton.style.marginLeft = '1em'; | |
applyButton.onclick = () => this._applyFilters(); | |
this._filtersEl.appendChild(applyButton); | |
const clearButton = document.createElement('button'); | |
clearButton.textContent = 'Clear Filters'; | |
clearButton.onclick = () => this._clearFilters(); | |
this._filtersEl.appendChild(clearButton); | |
} | |
} | |
// Handler for clicking sortable headers | |
_handleSort(field) { | |
if (this._sort.field === field) { | |
// Toggle order if clicking the same field | |
this._sort.order = this._sort.order === 'ASC' ? 'DESC' : 'ASC'; | |
} else { | |
// Set new field, default to ASC | |
this._sort.field = field; | |
this._sort.order = 'ASC'; | |
} | |
this._pagination.page = 1; // Reset to first page on sort change | |
this._renderHeader(); // Re-render header to update sort indicators | |
this._loadData(); // Load data with new sort | |
} | |
// Handler for select filter changes (applies immediately) | |
_handleFilterChange(field, value) { | |
if (value === '') { | |
delete this._filter[field]; | |
} else { | |
// Potentially coerce type if needed (e.g., for boolean filter) | |
this._filter[field] = value; | |
} | |
this._pagination.page = 1; // Reset page | |
this._loadData(); | |
} | |
// Handler for Apply Filters button (collects all filter values) | |
_applyFilters() { | |
const newFilter = {}; | |
const inputs = this.shadowRoot.querySelectorAll('.filters input, .filters select'); | |
inputs.forEach(input => { | |
if (input.value !== '') { | |
// Potentially coerce type based on schema if needed | |
newFilter[input.name] = input.value; | |
} | |
}); | |
this._filter = newFilter; | |
this._pagination.page = 1; // Reset page | |
this._loadData(); | |
} | |
// Handler for Clear Filters button | |
_clearFilters() { | |
this._filter = {}; | |
// Clear UI inputs | |
const inputs = this.shadowRoot.querySelectorAll('.filters input, .filters select'); | |
inputs.forEach(input => input.value = ''); | |
this._pagination.page = 1; // Reset page | |
this._loadData(); | |
} | |
// Handler for delete button click | |
async _handleDelete(id) { | |
if (!confirm(`Are you sure you want to delete record ${id}?`)) { | |
return; | |
} | |
this._setLoading(true); // Maybe indicate loading on the row? | |
this._setError(null); | |
try { | |
await dataProvider.delete({ resource: this._resource, id: id }); | |
// Refresh the list after delete | |
// Ideally, just remove the row, but full reload is simpler for demo | |
this._loadData(); | |
// Dispatch success event | |
this.dispatchEvent(new CustomEvent('delete-success', { detail: { id: id, resource: this._resource }, bubbles: true, composed: true })); | |
} catch (err) { | |
console.error(`[${this._resource}] Error deleting item ${id}:`, err); | |
this._setError(err.message || `Failed to delete item ${id}.`); | |
this._setLoading(false); // Ensure loading stops on error | |
} | |
// Loading state is turned off by _loadData on success | |
} | |
// Helper to manage loading state display | |
_setLoading(isLoading) { | |
this._loading = isLoading; | |
if (this._loadingEl) this._loadingEl.hidden = !isLoading; | |
// Dim table while loading data | |
if(this._tableContainer) this._tableContainer.style.opacity = isLoading ? 0.6 : 1; | |
} | |
// Helper to manage error message display | |
_setError(message) { | |
this._error = message; | |
if(this._errorEl) { | |
this._errorEl.textContent = message || ''; | |
this._errorEl.hidden = !message; | |
} | |
} | |
} | |
customElements.define('admin-list', AdminList); | |
// --- <admin-form> WEB COMPONENT --- | |
const adminFormTemplate = document.createElement('template'); | |
adminFormTemplate.innerHTML = ` | |
<style> | |
:host { display: block; } | |
form { border: 1px solid #eee; padding: 1em; } | |
.form-field { margin-bottom: 1em; } | |
label { display: block; margin-bottom: 0.3em; font-weight: bold; } | |
input[type="text"], | |
input[type="number"], | |
input[type="email"], | |
input[type="url"], | |
input[type="password"], | |
input[type="date"], | |
input[type="datetime-local"], | |
input[type="time"], | |
select, | |
textarea { | |
width: 100%; | |
padding: 0.5em; | |
border: 1px solid #ccc; | |
box-sizing: border-box; /* Include padding in width */ | |
} | |
input[type="checkbox"] { width: auto; margin-right: 0.5em;} | |
input[readonly], textarea[readonly] { background-color: #f4f4f4; cursor: not-allowed; } | |
textarea { min-height: 80px; resize: vertical; } | |
button[type="submit"] { | |
padding: 0.7em 1.5em; | |
border: none; | |
background-color: #007bff; | |
color: white; | |
cursor: pointer; | |
font-size: 1em; | |
border-radius: 3px; | |
} | |
button[type="submit"]:disabled { background-color: #aaa; cursor: not-allowed; } | |
button[type="submit"]:hover:not(:disabled) { background-color: #0056b3; } | |
.loading, .error, .success { padding: 1em; text-align: center; margin-top: 1em; border-radius: 3px; } | |
.loading { background-color: #eee; } | |
.error { color: #a00; background-color: #fdd; border: 1px solid #fbb; } | |
.success { color: #080; background-color: #dfd; border: 1px solid #bfb; } | |
.validation-error { color: #a00; font-size: 0.85em; margin-top: 0.2em; display: block; } | |
</style> | |
<form part="form"> | |
<button type="submit" part="submit-button">Save</button> | |
</form> | |
<div class="loading" part="loading" hidden>Saving...</div> | |
<div class="error" part="error" hidden></div> | |
<div class="success" part="success" hidden></div> | |
`; | |
class AdminForm extends HTMLElement { | |
static get observedAttributes() { | |
// Use kebab-case for attributes in HTML, camelCase for JS properties | |
return ['resource', 'resource-id']; | |
} | |
constructor() { | |
super(); | |
this.attachShadow({ mode: 'open' }); | |
this.shadowRoot.appendChild(adminFormTemplate.content.cloneNode(true)); | |
// State | |
this._resource = this.getAttribute('resource'); | |
this._resourceId = this.getAttribute('resource-id') || null; // null if creating | |
this._schema = null; | |
this._formData = {}; // Holds current entity data for editing or defaults for creating | |
this._loading = false; // Loading schema/data or saving | |
this._error = null; // General save error message | |
this._success = null; // Success message | |
this._validationErrors = {}; // Field-specific validation errors { fieldName: message } | |
// Element refs | |
this._form = this.shadowRoot.querySelector('form'); | |
this._loadingEl = this.shadowRoot.querySelector('.loading'); | |
this._errorEl = this.shadowRoot.querySelector('.error'); | |
this._successEl = this.shadowRoot.querySelector('.success'); | |
this._submitButton = this.shadowRoot.querySelector('button[type="submit"]'); | |
} | |
connectedCallback() { | |
if (!this._resource) { | |
this._setError('Error: resource attribute is required.'); | |
return; | |
} | |
this._loadSchemaAndData(); | |
// Add submit listener | |
this._form.addEventListener('submit', this._handleSubmit.bind(this)); | |
} | |
disconnectedCallback() { | |
// Remove listeners if necessary, although form listener is on shadow root | |
// this._form.removeEventListener('submit', this._handleSubmit.bind(this)); // Might cause issues if bound function ref isn't stored | |
} | |
attributeChangedCallback(name, oldValue, newValue) { | |
if (oldValue === newValue) return; | |
let needsReload = false; | |
if (name === 'resource') { | |
this._resource = newValue; | |
this._resourceId = null; // Reset ID if resource changes | |
this.setAttribute('resource-id', ''); // Reflect property change | |
needsReload = true; | |
} | |
if (name === 'resource-id') { | |
this._resourceId = newValue || null; | |
needsReload = true; | |
} | |
if (needsReload && this.isConnected) { | |
this._formData = {}; // Clear old data | |
this._loadSchemaAndData(); // Reload schema and potentially data | |
} | |
} | |
// Fetches schema and initial data (if editing) | |
async _loadSchemaAndData() { | |
this._setLoading(true); | |
this._setError(null); | |
this._setSuccess(null); | |
this._clearValidationErrors(); // Clear previous errors | |
try { | |
// Fetch schema first | |
const { schema } = await schemaProvider.getSchema({ resource: this._resource }); | |
this._schema = schema; | |
// Fetch existing data if resourceId is set (edit mode) | |
if (this._resourceId) { | |
const { data } = await dataProvider.getOne({ resource: this._resource, id: this._resourceId }); | |
this._formData = data; | |
} else { // Create mode: set defaults from schema | |
this._formData = {}; | |
Object.values(this._schema.fields).forEach(f => { | |
if (!f.readOnly && f.defaultValue !== undefined) { | |
this._formData[f.name] = f.defaultValue; | |
} | |
}); | |
} | |
// Render the form fields based on schema and loaded/default data | |
this._renderFormFields(); | |
} catch (err) { | |
console.error(`[${this._resource}] Error loading schema/data for form:`, err); | |
this._setError(err.message || 'Failed to load form schema or data.'); | |
this._renderFormFields(); // Attempt to render empty form structure on error | |
} finally { | |
this._setLoading(false); | |
} | |
} | |
// Generates form input elements based on the schema | |
_renderFormFields() { | |
// Clear previous fields (except submit button) | |
const fieldsContainer = this._form; // Or a dedicated container element | |
while (fieldsContainer.firstChild && fieldsContainer.firstChild !== this._submitButton) { | |
fieldsContainer.removeChild(fieldsContainer.firstChild); | |
} | |
if (!this._schema) { | |
// Optionally display a message if schema failed to load | |
const errorDiv = document.createElement('div'); | |
errorDiv.textContent = 'Cannot render form: Schema not available.'; | |
errorDiv.style.color = 'red'; | |
fieldsContainer.insertBefore(errorDiv, this._submitButton); | |
return; | |
} | |
const fragment = document.createDocumentFragment(); | |
// Iterate through schema fields and create corresponding inputs | |
Object.values(this._schema.fields).forEach(fieldSchema => { | |
// Skip read-only fields in the form, unless it's the ID in edit mode (display only) | |
if (fieldSchema.readOnly && fieldSchema.name !== 'id') return; | |
if (fieldSchema.name === 'id' && !this._resourceId) return; // Don't show ID field on create | |
const isReadOnly = fieldSchema.readOnly || (fieldSchema.name === 'id' && this._resourceId); | |
const div = document.createElement('div'); | |
div.className = 'form-field'; | |
div.setAttribute('part', `field-container-${fieldSchema.name}`); | |
const label = document.createElement('label'); | |
label.textContent = fieldSchema.label; | |
label.htmlFor = `field-${fieldSchema.name}`; | |
div.appendChild(label); | |
let input; | |
const currentValue = this._formData[fieldSchema.name] ?? ''; | |
// Determine input type based on schema hints | |
const hint = fieldSchema.uiHint; | |
const type = fieldSchema.type; | |
if (isReadOnly) { | |
input = document.createElement('input'); | |
input.type = 'text'; | |
input.readOnly = true; | |
input.value = currentValue; | |
input.style.backgroundColor = '#eee'; // Visual cue | |
input.style.cursor = 'not-allowed'; | |
} else if (type === 'enum' && fieldSchema.options) { | |
input = document.createElement('select'); | |
// Add an empty option for non-required enums | |
if (!fieldSchema.required) { | |
const emptyOpt = document.createElement('option'); | |
emptyOpt.value = ''; | |
emptyOpt.textContent = '-- Select --'; | |
input.appendChild(emptyOpt); | |
} | |
fieldSchema.options.forEach(opt => { | |
const option = document.createElement('option'); | |
option.value = opt.value; | |
option.textContent = opt.label; | |
// Use loose comparison for value matching (e.g., number vs string) | |
if (currentValue !== '' && currentValue == opt.value) { | |
option.selected = true; | |
} | |
input.appendChild(option); | |
}); | |
} else if (type === 'boolean') { | |
input = document.createElement('input'); | |
input.type = 'checkbox'; | |
input.checked = !!currentValue; // Coerce to boolean | |
// Checkboxes often need different label handling (e.g., label after input) | |
// For simplicity, keep label before. Style appropriately. | |
label.style.display = 'inline-block'; // Adjust label style for checkbox | |
label.style.marginBottom = '0'; | |
label.style.marginRight = '0.5em'; | |
div.insertBefore(input, label); // Put input before label for standard checkbox layout | |
} else if (hint === 'textarea' || type === 'text') { | |
input = document.createElement('textarea'); | |
input.value = currentValue; | |
input.rows = type === 'text' ? 4 : 8; // Example row counts | |
} else if (type === 'number') { | |
input = document.createElement('input'); | |
input.type = 'number'; | |
input.value = currentValue; | |
if(fieldSchema.constraints?.min !== undefined) input.min = fieldSchema.constraints.min; | |
if(fieldSchema.constraints?.max !== undefined) input.max = fieldSchema.constraints.max; | |
// Suggest step based on data type if needed (e.g., '0.01' for currency) | |
input.step = fieldSchema.constraints?.step || 'any'; | |
} else if (type === 'date') { | |
input = document.createElement('input'); | |
input.type = 'date'; | |
// Input type="date" expects YYYY-MM-DD format | |
try { | |
input.value = currentValue ? new Date(currentValue).toISOString().split('T')[0] : ''; | |
} catch(e) { input.value = '';} // Handle invalid date format | |
} else if (type === 'datetime') { | |
input = document.createElement('input'); | |
input.type = 'datetime-local'; | |
// Input type="datetime-local" expects YYYY-MM-DDTHH:mm | |
try { | |
// Adjust for timezone offset if necessary before formatting | |
const dt = currentValue ? new Date(currentValue) : null; | |
if (dt) { | |
const offset = dt.getTimezoneOffset() * 60000; // Offset in milliseconds | |
const localISOTime = (new Date(dt.getTime() - offset)).toISOString().slice(0, 16); | |
input.value = localISOTime; | |
} else { | |
input.value = ''; | |
} | |
} catch(e){ input.value = ''; } // Handle invalid initial date | |
} else { // Default to text input, respecting type hints | |
input = document.createElement('input'); | |
input.type = type === 'password' ? 'password' : (type === 'email' ? 'email' : (type === 'url' ? 'url' : 'text')); | |
input.value = currentValue; | |
// Apply common constraints | |
if(fieldSchema.constraints?.minLength !== undefined) input.minLength = fieldSchema.constraints.minLength; | |
if(fieldSchema.constraints?.maxLength !== undefined) input.maxLength = fieldSchema.constraints.maxLength; | |
if(fieldSchema.constraints?.pattern !== undefined) input.pattern = fieldSchema.constraints.pattern; | |
} | |
// Common attributes | |
input.id = `field-${fieldSchema.name}`; | |
input.name = fieldSchema.name; | |
if (fieldSchema.required && !isReadOnly) { | |
input.required = true; // Leverage browser validation where possible | |
} | |
// Add input to div (unless it was a checkbox already inserted) | |
if (type !== 'boolean') { | |
div.appendChild(input); | |
} | |
// Placeholder for validation errors specific to this field | |
const errorSpan = document.createElement('span'); | |
errorSpan.className = 'validation-error'; | |
errorSpan.setAttribute('part', `error-${fieldSchema.name}`); | |
errorSpan.dataset.fieldErrorFor = fieldSchema.name; // Link error span to field | |
div.appendChild(errorSpan); | |
fragment.appendChild(div); | |
}); | |
// Insert all generated fields before the submit button | |
fieldsContainer.insertBefore(fragment, this._submitButton); | |
} | |
// Handles form submission | |
async _handleSubmit(event) { | |
event.preventDefault(); // Prevent default browser form submission | |
this._setLoading(true); | |
this._setError(null); | |
this._setSuccess(null); | |
this._clearValidationErrors(); | |
const formData = new FormData(this._form); | |
const variables = {}; | |
let formIsValid = true; | |
if (!this._schema) { | |
this._setError("Cannot submit: schema not loaded."); | |
this._setLoading(false); | |
return; | |
} | |
// Process form data based on schema | |
for (const fieldName in this._schema.fields) { | |
const fieldSchema = this._schema.fields[fieldName]; | |
// Skip read-only fields unless it's the ID (which isn't usually submitted anyway) | |
if (fieldSchema.readOnly) continue; | |
let value; | |
// Handle different input types correctly | |
if (fieldSchema.type === 'boolean') { | |
value = formData.has(fieldName); // Checkbox value | |
} else if (fieldSchema.type === 'number') { | |
const rawValue = formData.get(fieldName); | |
if (rawValue === '' || rawValue === null) { | |
value = null; // Or undefined, depending on API expectation | |
} else { | |
value = Number(rawValue); | |
if (isNaN(value)) { | |
this._setValidationError(fieldName, `${fieldSchema.label} must be a valid number.`); | |
formIsValid = false; | |
value = null; // Prevent sending NaN | |
} | |
} | |
} else { | |
value = formData.get(fieldName); | |
} | |
// Store value if it's not null/undefined OR if it's a boolean false | |
// Handle empty strings - send them or convert to null? Depends on API. Let's send empty strings. | |
if (value !== null && value !== undefined) { | |
variables[fieldName] = value; | |
} else if (fieldSchema.required) { | |
// If required and value is null/undefined/empty string (after potential coercion) | |
if (value === null || value === undefined || value === '') { | |
this._setValidationError(fieldName, `${fieldSchema.label} is required.`); | |
formIsValid = false; | |
} | |
} | |
// Add more client-side validation based on fieldSchema.constraints if desired | |
} | |
if (!formIsValid) { | |
this._setError("Please fix the validation errors."); | |
this._setLoading(false); | |
return; | |
} | |
// Call the appropriate dataProvider method | |
try { | |
let result; | |
const meta = {}; // Add auth tokens etc. here if needed | |
if (this._resourceId) { | |
// Update existing record | |
result = await dataProvider.update({ | |
resource: this._resource, | |
id: this._resourceId, | |
variables: variables, | |
previousData: this._formData, // Pass previous data | |
meta: meta | |
}); | |
this._formData = result.data; // Update internal state with response | |
this._setSuccess(`Successfully updated record ${this._resourceId}.`); | |
} else { | |
// Create new record | |
result = await dataProvider.create({ | |
resource: this._resource, | |
variables: variables, | |
meta: meta | |
}); | |
this._setSuccess(`Successfully created record with ID ${result.data.id}.`); | |
// Optionally reset form after successful creation | |
this._form.reset(); | |
this._formData = {}; // Clear internal data | |
// Set defaults again? | |
Object.values(this._schema.fields).forEach(f => { | |
if (!f.readOnly && f.defaultValue !== undefined) { | |
this._formData[f.name] = f.defaultValue; | |
const input = this.shadowRoot.querySelector(`[name="${f.name}"]`); | |
if(input) { | |
if(f.type === 'boolean') input.checked = f.defaultValue; | |
else input.value = f.defaultValue; | |
} | |
} | |
}); | |
} | |
// Dispatch a success event that parent components can listen to | |
this.dispatchEvent(new CustomEvent('save-success', { | |
bubbles: true, // Allow event to bubble up | |
composed: true, // Allow event to cross shadow DOM boundary | |
detail: { data: result.data, isCreate: !this._resourceId } | |
})); | |
} catch (err) { | |
console.error(`[${this._resource}] Save error:`, err); | |
this._setError(err.message || 'Failed to save data.'); | |
// Handle validation errors from the backend | |
if (err.name === 'ValidationError' && Array.isArray(err.details)) { | |
err.details.forEach(detail => { | |
if (detail.field) { | |
this._setValidationError(detail.field, detail.message); | |
} | |
}); | |
} | |
} finally { | |
this._setLoading(false); | |
} | |
} | |
// Helper to manage loading state display | |
_setLoading(isLoading) { | |
this._loading = isLoading; | |
if (this._loadingEl) this._loadingEl.hidden = !isLoading; | |
if (this._submitButton) this._submitButton.disabled = isLoading; | |
} | |
// Helper to manage general error message display | |
_setError(message) { | |
this._error = message; | |
if(this._errorEl) { | |
this._errorEl.textContent = message || ''; | |
this._errorEl.hidden = !message; | |
} | |
if (message) this._setSuccess(null); // Clear success if error occurs | |
} | |
// Helper to manage success message display | |
_setSuccess(message) { | |
this._success = message; | |
if(this._successEl) { | |
this._successEl.textContent = message || ''; | |
this._successEl.hidden = !message; | |
} | |
if (message) this._setError(null); // Clear error if success occurs | |
} | |
// Clears all field-specific validation errors | |
_clearValidationErrors() { | |
this._validationErrors = {}; | |
this.shadowRoot.querySelectorAll('.validation-error').forEach(el => el.textContent = ''); | |
} | |
// Sets and displays a field-specific validation error | |
_setValidationError(field, message) { | |
this._validationErrors[field] = message; | |
const el = this.shadowRoot.querySelector(`.validation-error[data-field-error-for="${field}"]`); | |
if (el) { | |
el.textContent = message; | |
} else { | |
// Fallback: Add to general error if specific field error span not found | |
console.warn(`Validation error span not found for field: ${field}`); | |
this._setError((this._error ? this._error + '; ' : '') + `${field}: ${message}`); | |
} | |
} | |
} | |
customElements.define('admin-form', AdminForm); |
/* Basic page styling */ | |
body { | |
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; | |
line-height: 1.5; | |
padding: 1rem; | |
max-width: 900px; | |
margin: 0 auto; | |
} | |
h1, h2 { | |
border-bottom: 1px solid #eee; | |
padding-bottom: 0.3em; | |
margin-top: 1.5em; | |
} | |
hr { | |
border: none; | |
border-top: 1px solid #eee; | |
margin: 2em 0; | |
} | |
/* Basic styles moved inside component templates where possible */ |
A Pen by Pelle Wessman on CodePen.
Originally created by Gemini.