feat(admin): separate platform CRUD from CMS, add entity selector macro
Some checks failed
Some checks failed
- Move platforms menu from CMS to Platform Admin section with create/edit - Add platform create page, API endpoint, and service method - Remove CMS-specific content from platform list and detail pages - Create shared entity_selector + entity_selected_badge Jinja macros - Create entity-selector.js generalizing store-selector.js for any entity - Add Tom Select merchant filter to stores page with localStorage persistence - Migrate store-products page to use shared macros (remove 53 lines of duped CSS) - Fix broken icons: puzzle→puzzle-piece, building-storefront→store, language→translate, server→cube Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
262
app/modules/core/static/shared/js/entity-selector.js
Normal file
262
app/modules/core/static/shared/js/entity-selector.js
Normal file
@@ -0,0 +1,262 @@
|
||||
// static/shared/js/entity-selector.js
|
||||
/**
|
||||
* Shared Entity Selector Module
|
||||
* ==============================
|
||||
* A configurable Tom Select-based autocomplete for any entity type.
|
||||
* Generalizes store-selector.js to work with stores, merchants, platforms, etc.
|
||||
*
|
||||
* Usage:
|
||||
* const selector = initEntitySelector(this.$refs.merchantSelect, {
|
||||
* apiEndpoint: '/admin/merchants',
|
||||
* responseKey: 'merchants',
|
||||
* searchFields: ['name'],
|
||||
* valueField: 'id',
|
||||
* labelField: 'name',
|
||||
* codeField: null,
|
||||
* onSelect: (entity) => this.onMerchantSelected(entity),
|
||||
* onClear: () => this.onMerchantCleared()
|
||||
* });
|
||||
*
|
||||
* selector.setValue(merchantId, merchantData);
|
||||
* selector.clear();
|
||||
*/
|
||||
|
||||
const entitySelectorLog = window.LogConfig?.loggers?.entitySelector ||
|
||||
window.LogConfig?.createLogger?.('entitySelector', false) ||
|
||||
{ info: console.log, warn: console.warn, error: console.error }; // noqa: js-001 - fallback if logger not ready
|
||||
|
||||
/**
|
||||
* Wait for Tom Select to be available
|
||||
*/
|
||||
function waitForTomSelectReady(callback, maxRetries = 20, retryDelay = 100) {
|
||||
let retries = 0;
|
||||
|
||||
function check() {
|
||||
if (typeof TomSelect !== 'undefined') {
|
||||
callback();
|
||||
} else if (retries < maxRetries) {
|
||||
retries++;
|
||||
setTimeout(check, retryDelay);
|
||||
} else {
|
||||
entitySelectorLog.error('TomSelect not available after maximum retries');
|
||||
}
|
||||
}
|
||||
|
||||
check();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize an entity selector on the given element
|
||||
* @param {HTMLElement} selectElement - The select element to enhance
|
||||
* @param {Object} options - Configuration options
|
||||
* @param {string} options.apiEndpoint - API endpoint for search (e.g. '/admin/merchants')
|
||||
* @param {string} options.responseKey - Key in API response containing the array (e.g. 'merchants')
|
||||
* @param {string} options.valueField - Field used as option value (default: 'id')
|
||||
* @param {string} options.labelField - Field used as display label (default: 'name')
|
||||
* @param {string[]} options.searchFields - Fields to search on (default: ['name'])
|
||||
* @param {string|null} options.codeField - Optional secondary code field for display (e.g. 'store_code')
|
||||
* @param {Function} options.onSelect - Callback when entity is selected (receives entity object)
|
||||
* @param {Function} options.onClear - Callback when selection is cleared
|
||||
* @param {number} options.minChars - Minimum characters before search (default: 2)
|
||||
* @param {number} options.maxOptions - Maximum options to show (default: 50)
|
||||
* @param {string} options.placeholder - Placeholder text
|
||||
* @param {string} options.noResultsText - Text when no results (default: 'No results found')
|
||||
* @returns {Object} Controller object with setValue(), clear(), getInstance(), destroy()
|
||||
*/
|
||||
function initEntitySelector(selectElement, options = {}) {
|
||||
if (!selectElement) {
|
||||
entitySelectorLog.error('Entity selector element not provided');
|
||||
return null;
|
||||
}
|
||||
|
||||
const config = {
|
||||
apiEndpoint: options.apiEndpoint || '/admin/stores',
|
||||
responseKey: options.responseKey || 'stores',
|
||||
valueField: options.valueField || 'id',
|
||||
labelField: options.labelField || 'name',
|
||||
searchFields: options.searchFields || ['name'],
|
||||
codeField: options.codeField || null,
|
||||
minChars: options.minChars || 2,
|
||||
maxOptions: options.maxOptions || 50,
|
||||
placeholder: options.placeholder || selectElement.getAttribute('placeholder') || 'Search...',
|
||||
noResultsText: options.noResultsText || 'No results found',
|
||||
onSelect: options.onSelect || (() => {}),
|
||||
onClear: options.onClear || (() => {})
|
||||
};
|
||||
|
||||
let tomSelectInstance = null;
|
||||
|
||||
const controller = {
|
||||
/**
|
||||
* Set the selected entity by ID
|
||||
* @param {*} entityId - Entity ID to select
|
||||
* @param {Object} entityData - Optional entity data to avoid API call
|
||||
*/
|
||||
setValue: async function(entityId, entityData = null) {
|
||||
if (!tomSelectInstance) return;
|
||||
|
||||
if (entityData) {
|
||||
const optionData = {};
|
||||
optionData[config.valueField] = entityData[config.valueField] || entityId;
|
||||
optionData[config.labelField] = entityData[config.labelField];
|
||||
if (config.codeField) {
|
||||
optionData[config.codeField] = entityData[config.codeField];
|
||||
}
|
||||
tomSelectInstance.addOption(optionData);
|
||||
tomSelectInstance.setValue(optionData[config.valueField], true);
|
||||
} else if (entityId) {
|
||||
try {
|
||||
const response = await apiClient.get(`${config.apiEndpoint}/${entityId}`);
|
||||
const optionData = {};
|
||||
optionData[config.valueField] = response[config.valueField] || entityId;
|
||||
optionData[config.labelField] = response[config.labelField];
|
||||
if (config.codeField) {
|
||||
optionData[config.codeField] = response[config.codeField];
|
||||
}
|
||||
tomSelectInstance.addOption(optionData);
|
||||
tomSelectInstance.setValue(optionData[config.valueField], true);
|
||||
} catch (error) {
|
||||
entitySelectorLog.error('Failed to load entity:', error);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
clear: function() {
|
||||
if (tomSelectInstance) {
|
||||
tomSelectInstance.clear();
|
||||
}
|
||||
},
|
||||
|
||||
getInstance: function() {
|
||||
return tomSelectInstance;
|
||||
},
|
||||
|
||||
destroy: function() {
|
||||
if (tomSelectInstance) {
|
||||
tomSelectInstance.destroy();
|
||||
tomSelectInstance = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize Tom Select when available
|
||||
waitForTomSelectReady(() => {
|
||||
entitySelectorLog.info(`Initializing entity selector (${config.responseKey})`);
|
||||
|
||||
// Build render functions
|
||||
const render = {
|
||||
option: function(data, escape) {
|
||||
if (config.codeField && data[config.codeField]) {
|
||||
return `<div class="flex justify-between items-center py-1">
|
||||
<span class="font-medium">${escape(data[config.labelField])}</span>
|
||||
<span class="text-xs text-gray-400 dark:text-gray-500 ml-2 font-mono">${escape(data[config.codeField])}</span>
|
||||
</div>`;
|
||||
}
|
||||
return `<div class="py-1"><span class="font-medium">${escape(data[config.labelField])}</span></div>`;
|
||||
},
|
||||
item: function(data, escape) {
|
||||
if (config.codeField && data[config.codeField]) {
|
||||
return `<div class="flex items-center gap-2">
|
||||
<span>${escape(data[config.labelField])}</span>
|
||||
<span class="text-xs text-gray-400 font-mono">(${escape(data[config.codeField])})</span>
|
||||
</div>`;
|
||||
}
|
||||
return `<div>${escape(data[config.labelField])}</div>`;
|
||||
},
|
||||
no_results: function() {
|
||||
return `<div class="no-results py-2 px-3 text-gray-500 dark:text-gray-400">${config.noResultsText}</div>`;
|
||||
},
|
||||
loading: function() {
|
||||
return '<div class="loading py-2 px-3 text-gray-500 dark:text-gray-400">Searching...</div>';
|
||||
}
|
||||
};
|
||||
|
||||
tomSelectInstance = new TomSelect(selectElement, {
|
||||
valueField: config.valueField,
|
||||
labelField: config.labelField,
|
||||
searchField: config.searchFields,
|
||||
maxOptions: config.maxOptions,
|
||||
placeholder: config.placeholder,
|
||||
|
||||
load: async function(query, callback) {
|
||||
if (query.length < config.minChars) {
|
||||
callback([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const sep = config.apiEndpoint.includes('?') ? '&' : '?';
|
||||
const response = await apiClient.get(
|
||||
`${config.apiEndpoint}${sep}search=${encodeURIComponent(query)}&limit=${config.maxOptions}`
|
||||
);
|
||||
|
||||
const items = (response[config.responseKey] || []).map(v => {
|
||||
const item = {};
|
||||
item[config.valueField] = v[config.valueField];
|
||||
item[config.labelField] = v[config.labelField];
|
||||
if (config.codeField) {
|
||||
item[config.codeField] = v[config.codeField];
|
||||
}
|
||||
return item;
|
||||
});
|
||||
|
||||
entitySelectorLog.info(`Found ${items.length} ${config.responseKey} for "${query}"`);
|
||||
callback(items);
|
||||
} catch (error) {
|
||||
entitySelectorLog.error(`${config.responseKey} search failed:`, error);
|
||||
callback([]);
|
||||
}
|
||||
},
|
||||
|
||||
render: render,
|
||||
|
||||
onChange: function(value) {
|
||||
if (value) {
|
||||
const selectedOption = this.options[value];
|
||||
if (selectedOption) {
|
||||
entitySelectorLog.info(`${config.responseKey} selected:`, selectedOption);
|
||||
const entity = {};
|
||||
entity[config.valueField] = config.valueField === 'id' ? parseInt(value) : value;
|
||||
entity[config.labelField] = selectedOption[config.labelField];
|
||||
if (config.codeField) {
|
||||
entity[config.codeField] = selectedOption[config.codeField];
|
||||
}
|
||||
config.onSelect(entity);
|
||||
}
|
||||
} else {
|
||||
entitySelectorLog.info(`${config.responseKey} selection cleared`);
|
||||
config.onClear();
|
||||
}
|
||||
},
|
||||
|
||||
loadThrottle: 150,
|
||||
closeAfterSelect: true,
|
||||
hideSelected: false,
|
||||
persist: true,
|
||||
createOnBlur: false,
|
||||
create: false
|
||||
});
|
||||
|
||||
entitySelectorLog.info(`Entity selector (${config.responseKey}) initialized`);
|
||||
});
|
||||
|
||||
return controller;
|
||||
}
|
||||
|
||||
// Convenience wrapper for store selector (backward compatible)
|
||||
function initStoreSelectorFromEntity(selectElement, options = {}) {
|
||||
return initEntitySelector(selectElement, {
|
||||
apiEndpoint: options.apiEndpoint || '/admin/stores',
|
||||
responseKey: 'stores',
|
||||
valueField: 'id',
|
||||
labelField: 'name',
|
||||
searchFields: ['name', 'store_code'],
|
||||
codeField: 'store_code',
|
||||
placeholder: options.placeholder || 'Search store by name or code...',
|
||||
noResultsText: 'No stores found',
|
||||
...options
|
||||
});
|
||||
}
|
||||
|
||||
// Export to window
|
||||
window.initEntitySelector = initEntitySelector;
|
||||
Reference in New Issue
Block a user