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.