Skip to content

Instantly share code, notes, and snippets.

@voxpelli
Created April 9, 2025 07:51
Show Gist options
  • Save voxpelli/4b037074cf4d6dceb5bc24197a4dbe15 to your computer and use it in GitHub Desktop.
Save voxpelli/4b037074cf4d6dceb5bc24197a4dbe15 to your computer and use it in GitHub Desktop.
Vanilla JS Web Component Admin Reference Implementation
<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 */
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment