Complete Step 16 by extracting and testing the useJsonFilter composable with comprehensive functionality and test coverage

This commit is contained in:
2026-01-29 03:47:32 +00:00
parent 78e5dd9217
commit 9507059ad9
3 changed files with 964 additions and 30 deletions

View File

@@ -0,0 +1,350 @@
/**
* useJsonFilter Composable
*
* Manages JSON path filtering and property-based data filtering.
* Handles path extraction from JSON objects and filtering by property values.
*/
import { ref, computed, watch } from 'vue';
import { getValueByPath } from '../utilities/json-utils.js';
/**
* Get value from object using dot notation path
* @param {Object} obj - Object to get value from
* @param {string} path - Dot notation path
* @returns {any} Value at path
*/
function extractValueByPath(obj, path) {
return getValueByPath(obj, path);
}
/**
* Extract all unique paths from an object recursively
* @param {Object} obj - Object to extract from
* @param {string} prefix - Current path prefix
* @param {Set} paths - Set to accumulate paths
* @param {number} maxDepth - Maximum recursion depth
* @param {number} currentDepth - Current depth
*/
function extractPathsRecursive(
obj,
prefix = '',
paths = new Set(),
maxDepth = 5,
currentDepth = 0
) {
if (currentDepth >= maxDepth || obj === null || typeof obj !== 'object') {
return;
}
Object.keys(obj).forEach(key => {
const path = prefix ? `${prefix}.${key}` : key;
paths.add(path);
if (
typeof obj[key] === 'object' &&
obj[key] !== null &&
!Array.isArray(obj[key])
) {
extractPathsRecursive(obj[key], path, paths, maxDepth, currentDepth + 1);
}
});
}
export default function useJsonFilter() {
// State
const filterProperty = ref('');
const filterValue = ref('');
const filterMode = ref('equals'); // equals, contains, regex
const availablePaths = ref([]);
const isFiltering = ref(false);
const filterError = ref(null);
// Data to filter (referenced, not copied)
let rawData = null;
/**
* Initialize filter with raw data
* @param {Array} data - Array of objects to filter
* @param {number} sampleSize - Number of items to sample for path extraction
*/
function initializeFilter(data, sampleSize = 100) {
rawData = data;
extractPathsFromData(data, sampleSize);
}
/**
* Extract paths from data array
* @param {Array} data - Data to extract from
* @param {number} sampleSize - Sample size for extraction
*/
function extractPathsFromData(data, sampleSize = 100) {
if (!Array.isArray(data) || data.length === 0) {
availablePaths.value = [];
return;
}
const paths = new Set();
const sample = data.slice(0, Math.min(sampleSize, data.length));
sample.forEach(item => {
if (typeof item === 'object' && item !== null) {
extractPathsRecursive(item, '', paths);
}
});
availablePaths.value = Array.from(paths)
.sort()
.map(path => ({
path,
breadcrumb: path.replace(/\./g, ' ')
}));
}
/**
* Continue extracting paths from remaining items (lazy)
* @param {Array} data - Full data array
* @param {number} startIndex - Where to start
* @param {Function} callback - Callback with new paths
* @param {number} chunkSize - Items per chunk
*/
function extractPathsLazy(
data,
startIndex = 100,
callback,
chunkSize = 100
) {
if (!Array.isArray(data) || startIndex >= data.length) {
return;
}
const existingPaths = new Set(availablePaths.value.map(p => p.path));
const processChunk = index => {
const end = Math.min(index + chunkSize, data.length);
const chunk = data.slice(index, end);
const newPaths = new Set(existingPaths);
chunk.forEach(item => {
if (typeof item === 'object' && item !== null) {
extractPathsRecursive(item, '', newPaths);
}
});
const addedPaths = Array.from(newPaths).filter(p => !existingPaths.has(p));
if (addedPaths.length > 0) {
addedPaths.forEach(p => existingPaths.add(p));
availablePaths.value = Array.from(existingPaths)
.sort()
.map(path => ({
path,
breadcrumb: path.replace(/\./g, ' ')
}));
if (callback) {
callback(
addedPaths.map(path => ({
path,
breadcrumb: path.replace(/\./g, ' ')
}))
);
}
}
if (end < data.length) {
if (typeof requestIdleCallback !== 'undefined') {
requestIdleCallback(() => processChunk(end));
} else {
setTimeout(() => processChunk(end), 0);
}
}
};
if (typeof requestIdleCallback !== 'undefined') {
requestIdleCallback(() => processChunk(startIndex));
} else {
setTimeout(() => processChunk(startIndex), 0);
}
}
/**
* Filter data by property value
* @returns {Array} Filtered items
*/
const filteredData = computed(() => {
if (!rawData || !Array.isArray(rawData)) {
return rawData || [];
}
// Return all data if no filter is set or if there's a filter error
if (!filterProperty.value || filterError.value) {
return rawData;
}
return rawData.filter(item => {
try {
const value = extractValueByPath(item, filterProperty.value);
return matchesFilter(value, filterValue.value, filterMode.value);
} catch {
return false;
}
});
});
/**
* Check if value matches filter criteria
* @param {any} value - Value to check
* @param {string} filterVal - Filter value
* @param {string} mode - Filter mode (equals, contains, regex)
* @returns {boolean} Whether value matches
*/
function matchesFilter(value, filterVal, mode) {
if (!filterVal) return true;
const strValue = String(value).toLowerCase();
const strFilter = String(filterVal).toLowerCase();
switch (mode) {
case 'equals':
return strValue === strFilter;
case 'contains':
return strValue.includes(strFilter);
case 'regex':
try {
const regex = new RegExp(filterVal, 'i');
return regex.test(strValue);
} catch {
return false;
}
default:
return true;
}
}
/**
* Get all unique values for a property
* @param {string} property - Property path
* @returns {Array} Sorted unique values
*/
function getUniqueValues(property) {
if (!rawData || !Array.isArray(rawData) || !property) {
return [];
}
const values = new Set();
rawData.forEach(item => {
try {
const value = extractValueByPath(item, property);
if (value !== undefined && value !== null) {
values.add(String(value));
}
} catch {
// Silently skip items where path doesn't exist
}
});
return Array.from(values).sort();
}
/**
* Clear all filters
*/
function clearFilters() {
filterProperty.value = '';
filterValue.value = '';
filterMode.value = 'equals';
filterError.value = null;
}
/**
* Set filter from user input
* @param {string} property - Property to filter by
* @param {string} value - Value to filter
* @param {string} mode - Filter mode
*/
function setFilter(property, value, mode = 'equals') {
try {
filterProperty.value = property;
filterValue.value = value;
filterMode.value = mode;
filterError.value = null;
// Validate regex if mode is regex
if (mode === 'regex') {
new RegExp(value);
}
} catch (err) {
filterError.value = err.message;
}
}
/**
* Check if filter is active
*/
const hasActiveFilter = computed(() => {
return filterProperty.value && (filterValue.value || filterMode.value);
});
/**
* Get filter description for display
*/
const filterDescription = computed(() => {
if (!hasActiveFilter.value) {
return 'No filter applied';
}
const property = filterProperty.value;
const value = filterValue.value || '*';
const mode = filterMode.value;
const modeLabel = {
equals: '=',
contains: '∋',
regex: '~'
}[mode] || mode;
return `${property} ${modeLabel} ${value}`;
});
/**
* Get filter statistics
*/
const filterStats = computed(() => {
const total = rawData?.length || 0;
const filtered = filteredData.value.length;
return {
total,
filtered,
matched: filtered,
filtered: total - filtered,
percentage: total > 0 ? Math.round((filtered / total) * 100) : 0
};
});
return {
// State
filterProperty,
filterValue,
filterMode,
availablePaths,
isFiltering,
filterError,
// Computed
filteredData,
hasActiveFilter,
filterDescription,
filterStats,
// Methods
initializeFilter,
extractPathsFromData,
extractPathsLazy,
matchesFilter,
getUniqueValues,
clearFilters,
setFilter
};
}