feat: add custom filter chips and filter section components
- Implemented CustomFilterChips component for displaying and managing custom filters with context menu options for pinning, editing, and deleting filters. - Created FilterSection component to integrate CustomFilterChips and provide UI for creating advanced filters and displaying active filters. - Introduced useCustomFilters hook to manage custom filter logic, including loading, saving, updating, and deleting filters. - Added AdvancedFilterBuilder modal for creating and editing advanced filters with dynamic condition handling. - Implemented SaveFilterModal for saving filters with preview functionality and validation for filter names. - Enhanced useChoreFilters and useProjectFilter hooks for improved filter management and state handling.
This commit is contained in:
281
src/utils/CustomFilterStorage.js
Normal file
281
src/utils/CustomFilterStorage.js
Normal file
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* Custom Filter Storage - Manages saving/loading custom filters
|
||||
*
|
||||
* This module handles CRUD operations for custom filters.
|
||||
* Currently uses localStorage, but designed to easily migrate to backend API.
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = 'customFilters'
|
||||
const MAX_FILTERS = 20 // Limit to prevent localStorage overflow
|
||||
|
||||
/**
|
||||
* Generate a unique filter ID
|
||||
*/
|
||||
const generateFilterId = () => {
|
||||
return `filter_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all saved filters
|
||||
* @returns {Array} - Array of filter objects
|
||||
*/
|
||||
export const getSavedFilters = () => {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (!stored) return []
|
||||
|
||||
const filters = JSON.parse(stored)
|
||||
|
||||
// Ensure filters have required fields
|
||||
return filters.filter(f => f.id && f.name && f.conditions)
|
||||
} catch (error) {
|
||||
console.error('Error loading saved filters:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a new filter
|
||||
* @param {Object} filter - The filter to save
|
||||
* @returns {Object} - The saved filter with generated ID and metadata
|
||||
*/
|
||||
export const saveFilter = (filter) => {
|
||||
try {
|
||||
const filters = getSavedFilters()
|
||||
|
||||
// Check limit
|
||||
if (filters.length >= MAX_FILTERS) {
|
||||
throw new Error(`Maximum of ${MAX_FILTERS} filters allowed. Please delete some filters first.`)
|
||||
}
|
||||
|
||||
// Create new filter with metadata
|
||||
const newFilter = {
|
||||
id: generateFilterId(),
|
||||
name: filter.name,
|
||||
icon: filter.icon || null,
|
||||
conditions: filter.conditions,
|
||||
operator: filter.operator || 'AND',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
isPinned: filter.isPinned || false,
|
||||
usageCount: 0,
|
||||
lastUsedAt: null
|
||||
}
|
||||
|
||||
// Add to filters array
|
||||
const updatedFilters = [...filters, newFilter]
|
||||
|
||||
// Save to localStorage
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(updatedFilters))
|
||||
|
||||
return newFilter
|
||||
} catch (error) {
|
||||
console.error('Error saving filter:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing filter
|
||||
* @param {string} filterId - The ID of the filter to update
|
||||
* @param {Object} updates - The fields to update
|
||||
* @returns {Object} - The updated filter
|
||||
*/
|
||||
export const updateFilter = (filterId, updates) => {
|
||||
try {
|
||||
const filters = getSavedFilters()
|
||||
const filterIndex = filters.findIndex(f => f.id === filterId)
|
||||
|
||||
if (filterIndex === -1) {
|
||||
throw new Error('Filter not found')
|
||||
}
|
||||
|
||||
// Update filter
|
||||
const updatedFilter = {
|
||||
...filters[filterIndex],
|
||||
...updates,
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
|
||||
filters[filterIndex] = updatedFilter
|
||||
|
||||
// Save to localStorage
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(filters))
|
||||
|
||||
return updatedFilter
|
||||
} catch (error) {
|
||||
console.error('Error updating filter:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a filter
|
||||
* @param {string} filterId - The ID of the filter to delete
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
export const deleteFilter = (filterId) => {
|
||||
try {
|
||||
const filters = getSavedFilters()
|
||||
const updatedFilters = filters.filter(f => f.id !== filterId)
|
||||
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(updatedFilters))
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Error deleting filter:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single filter by ID
|
||||
* @param {string} filterId - The ID of the filter
|
||||
* @returns {Object|null} - The filter object or null if not found
|
||||
*/
|
||||
export const getFilterById = (filterId) => {
|
||||
const filters = getSavedFilters()
|
||||
return filters.find(f => f.id === filterId) || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment usage count for a filter
|
||||
* @param {string} filterId - The ID of the filter
|
||||
*/
|
||||
export const trackFilterUsage = (filterId) => {
|
||||
try {
|
||||
const filters = getSavedFilters()
|
||||
const filterIndex = filters.findIndex(f => f.id === filterId)
|
||||
|
||||
if (filterIndex !== -1) {
|
||||
filters[filterIndex].usageCount = (filters[filterIndex].usageCount || 0) + 1
|
||||
filters[filterIndex].lastUsedAt = new Date().toISOString()
|
||||
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(filters))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error tracking filter usage:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle pin status of a filter
|
||||
* @param {string} filterId - The ID of the filter
|
||||
* @returns {boolean} - New pin status
|
||||
*/
|
||||
export const toggleFilterPin = (filterId) => {
|
||||
try {
|
||||
const filters = getSavedFilters()
|
||||
const filterIndex = filters.findIndex(f => f.id === filterId)
|
||||
|
||||
if (filterIndex === -1) {
|
||||
throw new Error('Filter not found')
|
||||
}
|
||||
|
||||
filters[filterIndex].isPinned = !filters[filterIndex].isPinned
|
||||
filters[filterIndex].updatedAt = new Date().toISOString()
|
||||
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(filters))
|
||||
|
||||
return filters[filterIndex].isPinned
|
||||
} catch (error) {
|
||||
console.error('Error toggling filter pin:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get filters sorted by usage (most used first)
|
||||
* @returns {Array} - Sorted filters
|
||||
*/
|
||||
export const getFiltersByUsage = () => {
|
||||
const filters = getSavedFilters()
|
||||
return filters.sort((a, b) => (b.usageCount || 0) - (a.usageCount || 0))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pinned filters
|
||||
* @returns {Array} - Pinned filters
|
||||
*/
|
||||
export const getPinnedFilters = () => {
|
||||
const filters = getSavedFilters()
|
||||
return filters.filter(f => f.isPinned)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if filter name already exists
|
||||
* @param {string} name - The filter name to check
|
||||
* @param {string} excludeId - Optional ID to exclude from check (for updates)
|
||||
* @returns {boolean} - Whether the name exists
|
||||
*/
|
||||
export const filterNameExists = (name, excludeId = null) => {
|
||||
const filters = getSavedFilters()
|
||||
return filters.some(f =>
|
||||
f.name.toLowerCase() === name.toLowerCase() &&
|
||||
f.id !== excludeId
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Export filters as JSON (for backup/sharing)
|
||||
* @returns {string} - JSON string of all filters
|
||||
*/
|
||||
export const exportFilters = () => {
|
||||
const filters = getSavedFilters()
|
||||
return JSON.stringify(filters, null, 2)
|
||||
}
|
||||
|
||||
/**
|
||||
* Import filters from JSON
|
||||
* @param {string} jsonString - JSON string of filters
|
||||
* @returns {number} - Number of filters imported
|
||||
*/
|
||||
export const importFilters = (jsonString) => {
|
||||
try {
|
||||
const importedFilters = JSON.parse(jsonString)
|
||||
|
||||
if (!Array.isArray(importedFilters)) {
|
||||
throw new Error('Invalid filter format')
|
||||
}
|
||||
|
||||
const existingFilters = getSavedFilters()
|
||||
|
||||
// Generate new IDs for imported filters to avoid conflicts
|
||||
const newFilters = importedFilters.map(filter => ({
|
||||
...filter,
|
||||
id: generateFilterId(),
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
usageCount: 0,
|
||||
lastUsedAt: null
|
||||
}))
|
||||
|
||||
const allFilters = [...existingFilters, ...newFilters]
|
||||
|
||||
// Check limit
|
||||
if (allFilters.length > MAX_FILTERS) {
|
||||
throw new Error(`Import would exceed maximum of ${MAX_FILTERS} filters`)
|
||||
}
|
||||
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(allFilters))
|
||||
|
||||
return newFilters.length
|
||||
} catch (error) {
|
||||
console.error('Error importing filters:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all filters (use with caution!)
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
export const clearAllFilters = () => {
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Error clearing filters:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
369
src/utils/FilterEngine.js
Normal file
369
src/utils/FilterEngine.js
Normal file
@@ -0,0 +1,369 @@
|
||||
/**
|
||||
* Filter Engine - Evaluates filter conditions against chores
|
||||
*
|
||||
* This engine takes saved filter conditions and applies them to chores.
|
||||
* It supports various operators and combines conditions with AND/OR logic.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Evaluate a single condition against a chore
|
||||
* @param {Object} chore - The chore to evaluate
|
||||
* @param {Object} condition - The condition to check
|
||||
* @param {Object} context - Additional context (userId, members, etc.)
|
||||
* @returns {boolean} - Whether the chore matches the condition
|
||||
*/
|
||||
export const evaluateCondition = (chore, condition, context = {}) => {
|
||||
const { type, operator, value } = condition
|
||||
|
||||
switch (type) {
|
||||
case 'assignee':
|
||||
return evaluateAssignee(chore, operator, value, context)
|
||||
|
||||
case 'createdBy':
|
||||
return evaluateCreatedBy(chore, operator, value, context)
|
||||
|
||||
case 'priority':
|
||||
return evaluatePriority(chore, operator, value)
|
||||
|
||||
case 'status':
|
||||
return evaluateStatus(chore, operator, value)
|
||||
|
||||
case 'dueDate':
|
||||
return evaluateDueDate(chore, operator, value)
|
||||
|
||||
case 'label':
|
||||
return evaluateLabel(chore, operator, value)
|
||||
|
||||
case 'project':
|
||||
return evaluateProject(chore, operator, value)
|
||||
|
||||
default:
|
||||
console.warn(`Unknown condition type: ${type}`)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate assignee condition
|
||||
*/
|
||||
const evaluateAssignee = (chore, operator, value, context) => {
|
||||
const { userId } = context
|
||||
|
||||
// Handle special values
|
||||
if (value === 'me' && userId) {
|
||||
const isAssignedToMe =
|
||||
String(chore.assignedTo) === String(userId) ||
|
||||
chore.assignees?.some(a => String(a.userId) === String(userId))
|
||||
return operator === 'is' ? isAssignedToMe : !isAssignedToMe
|
||||
}
|
||||
|
||||
if (value === 'others' && userId) {
|
||||
const isAssignedToOthers =
|
||||
String(chore.assignedTo) !== String(userId) &&
|
||||
!chore.assignees?.some(a => String(a.userId) === String(userId))
|
||||
return operator === 'is' ? isAssignedToOthers : !isAssignedToOthers
|
||||
}
|
||||
|
||||
if (value === 'anyone') {
|
||||
return true
|
||||
}
|
||||
|
||||
// Handle specific user IDs (can be array for multi-select)
|
||||
const userIds = Array.isArray(value) ? value : [value]
|
||||
const isAssigned = userIds.some(
|
||||
id =>
|
||||
String(chore.assignedTo) === String(id) ||
|
||||
chore.assignees?.some(a => String(a.userId) === String(id)),
|
||||
)
|
||||
|
||||
return operator === 'is' ? isAssigned : !isAssigned
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate created by condition
|
||||
*/
|
||||
const evaluateCreatedBy = (chore, operator, value, context) => {
|
||||
const { userId } = context
|
||||
|
||||
if (value === 'me' && userId) {
|
||||
return operator === 'is'
|
||||
? String(chore.createdBy) === String(userId)
|
||||
: String(chore.createdBy) !== String(userId)
|
||||
}
|
||||
|
||||
const creatorIds = Array.isArray(value) ? value : [value]
|
||||
const isCreatedBy = creatorIds.some(
|
||||
id => String(id) === String(chore.createdBy),
|
||||
)
|
||||
|
||||
return operator === 'is' ? isCreatedBy : !isCreatedBy
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate priority condition
|
||||
*/
|
||||
const evaluatePriority = (chore, operator, value) => {
|
||||
const priorities = Array.isArray(value) ? value : [value]
|
||||
const chorePriority = chore.priority || 0
|
||||
|
||||
switch (operator) {
|
||||
case 'is':
|
||||
return priorities.some(p => Number(p) === Number(chorePriority))
|
||||
case 'isNot':
|
||||
return !priorities.some(p => Number(p) === Number(chorePriority))
|
||||
case 'greaterThan':
|
||||
return Number(chorePriority) > Number(value)
|
||||
case 'lessThan':
|
||||
return Number(chorePriority) < Number(value)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate status condition
|
||||
*/
|
||||
const evaluateStatus = (chore, operator, value) => {
|
||||
const statuses = Array.isArray(value) ? value : [value]
|
||||
const choreStatus = chore.status || 0
|
||||
|
||||
switch (operator) {
|
||||
case 'is':
|
||||
return statuses.some(s => Number(s) === Number(choreStatus))
|
||||
case 'isNot':
|
||||
return !statuses.some(s => Number(s) === Number(choreStatus))
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate due date condition
|
||||
*/
|
||||
const evaluateDueDate = (chore, operator, value) => {
|
||||
const { nextDueDate } = chore
|
||||
|
||||
// Handle "no due date" case
|
||||
if (operator === 'hasNoDueDate') {
|
||||
return nextDueDate === null || nextDueDate === undefined
|
||||
}
|
||||
|
||||
if (operator === 'hasDueDate') {
|
||||
return nextDueDate !== null && nextDueDate !== undefined
|
||||
}
|
||||
|
||||
if (!nextDueDate) return false
|
||||
|
||||
const dueDate = new Date(nextDueDate)
|
||||
const now = new Date()
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
const tomorrow = new Date(today)
|
||||
tomorrow.setDate(tomorrow.getDate() + 1)
|
||||
|
||||
switch (operator) {
|
||||
case 'isOverdue':
|
||||
return dueDate < now
|
||||
|
||||
case 'isDueToday':
|
||||
return dueDate.toDateString() === today.toDateString()
|
||||
|
||||
case 'isDueTomorrow':
|
||||
return dueDate.toDateString() === tomorrow.toDateString()
|
||||
|
||||
case 'isDueThisWeek': {
|
||||
const nextWeek = new Date(today)
|
||||
nextWeek.setDate(nextWeek.getDate() + 7)
|
||||
return dueDate >= today && dueDate < nextWeek
|
||||
}
|
||||
|
||||
case 'isDueThisMonth': {
|
||||
return (
|
||||
dueDate.getMonth() === today.getMonth() &&
|
||||
dueDate.getFullYear() === today.getFullYear()
|
||||
)
|
||||
}
|
||||
|
||||
case 'before': {
|
||||
const targetDate = value === 'today' ? today : new Date(value)
|
||||
return dueDate < targetDate
|
||||
}
|
||||
|
||||
case 'after': {
|
||||
const targetDate = value === 'today' ? today : new Date(value)
|
||||
return dueDate > targetDate
|
||||
}
|
||||
|
||||
case 'between': {
|
||||
const [start, end] = value
|
||||
return dueDate >= new Date(start) && dueDate <= new Date(end)
|
||||
}
|
||||
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate label condition
|
||||
*/
|
||||
const evaluateLabel = (chore, operator, value) => {
|
||||
const labelIds = Array.isArray(value) ? value : [value]
|
||||
const choreLabels = chore.labelsV2 || []
|
||||
|
||||
const hasLabel = labelIds.some(labelId =>
|
||||
choreLabels.some(l => String(l.id) === String(labelId)),
|
||||
)
|
||||
|
||||
switch (operator) {
|
||||
case 'has':
|
||||
case 'is':
|
||||
return hasLabel
|
||||
case 'doesNotHave':
|
||||
case 'isNot':
|
||||
return !hasLabel
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate project condition
|
||||
*/
|
||||
const evaluateProject = (chore, operator, value) => {
|
||||
const { projectId } = chore
|
||||
|
||||
// Convert to array for consistent handling
|
||||
const projectIds = Array.isArray(value) ? value : [value]
|
||||
|
||||
// Check if 'default' is in the selection
|
||||
const includesDefault = projectIds.includes('default')
|
||||
|
||||
// Check if chore is in default project (no projectId or projectId is 'default')
|
||||
const choreIsDefault =
|
||||
!projectId || projectId === null || projectId === 'default'
|
||||
|
||||
// Check if chore matches any of the non-default project IDs
|
||||
const otherProjectIds = projectIds.filter(id => id !== 'default')
|
||||
const matchesOtherProject = otherProjectIds.some(
|
||||
id => String(projectId) === String(id),
|
||||
)
|
||||
|
||||
// Chore matches if it's default and default is selected, OR if it matches any other selected project
|
||||
const isInProject = (includesDefault && choreIsDefault) || matchesOtherProject
|
||||
|
||||
return operator === 'is' ? isInProject : !isInProject
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a complete filter (with multiple conditions) to chores
|
||||
* @param {Array} chores - The chores to filter
|
||||
* @param {Object} filter - The filter with conditions
|
||||
* @param {Object} context - Additional context (userId, members, etc.)
|
||||
* @returns {Array} - Filtered chores
|
||||
*/
|
||||
export const applyFilter = (chores, filter, context = {}) => {
|
||||
if (!filter || !filter.conditions || filter.conditions.length === 0) {
|
||||
return chores
|
||||
}
|
||||
|
||||
const { conditions, operator = 'AND' } = filter
|
||||
|
||||
return chores.filter(chore => {
|
||||
if (operator === 'OR') {
|
||||
// At least one condition must match
|
||||
return conditions.some(condition =>
|
||||
evaluateCondition(chore, condition, context),
|
||||
)
|
||||
} else {
|
||||
// All conditions must match (AND)
|
||||
return conditions.every(condition =>
|
||||
evaluateCondition(chore, condition, context),
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of chores matching a filter
|
||||
* @param {Array} chores - The chores to count
|
||||
* @param {Object} filter - The filter to apply
|
||||
* @param {Object} context - Additional context
|
||||
* @returns {number} - Count of matching chores
|
||||
*/
|
||||
export const getFilterCount = (chores, filter, context = {}) => {
|
||||
return applyFilter(chores, filter, context).length
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of overdue chores matching a filter
|
||||
* @param {Array} chores - The chores to count
|
||||
* @param {Object} filter - The filter to apply
|
||||
* @param {Object} context - Additional context
|
||||
* @returns {number} - Count of overdue chores
|
||||
*/
|
||||
export const getFilterOverdueCount = (chores, filter, context = {}) => {
|
||||
const filtered = applyFilter(chores, filter, context)
|
||||
return filtered.filter(chore => {
|
||||
if (!chore.nextDueDate) return false
|
||||
return new Date(chore.nextDueDate) < new Date()
|
||||
}).length
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if a filter is still valid
|
||||
* (e.g., checks if referenced users/labels/projects still exist)
|
||||
* @param {Object} filter - The filter to validate
|
||||
* @param {Object} context - Context with available users, labels, projects
|
||||
* @returns {Object} - { isValid: boolean, issues: Array }
|
||||
*/
|
||||
export const validateFilter = (filter, context = {}) => {
|
||||
const { members = [], labels = [], projects = [] } = context
|
||||
const issues = []
|
||||
|
||||
if (!filter.conditions || filter.conditions.length === 0) {
|
||||
issues.push('Filter has no conditions')
|
||||
return { isValid: false, issues }
|
||||
}
|
||||
|
||||
filter.conditions.forEach((condition, index) => {
|
||||
const { type, value } = condition
|
||||
|
||||
// Check assignee references
|
||||
if (type === 'assignee' && !['me', 'others', 'anyone'].includes(value)) {
|
||||
const userIds = Array.isArray(value) ? value : [value]
|
||||
const invalidUsers = userIds.filter(
|
||||
id => !members.some(m => m.id === id || m.userId === id),
|
||||
)
|
||||
if (invalidUsers.length > 0) {
|
||||
issues.push(`Condition ${index + 1}: User(s) no longer exist`)
|
||||
}
|
||||
}
|
||||
|
||||
// Check label references
|
||||
if (type === 'label') {
|
||||
const labelIds = Array.isArray(value) ? value : [value]
|
||||
const invalidLabels = labelIds.filter(
|
||||
id => !labels.some(l => l.id === id),
|
||||
)
|
||||
if (invalidLabels.length > 0) {
|
||||
issues.push(`Condition ${index + 1}: Label(s) no longer exist`)
|
||||
}
|
||||
}
|
||||
|
||||
// Check project references
|
||||
if (type === 'project' && value !== 'default') {
|
||||
const projectIds = Array.isArray(value) ? value : [value]
|
||||
const invalidProjects = projectIds.filter(
|
||||
id => !projects.some(p => p.id === id || p.id === Number(id)),
|
||||
)
|
||||
if (invalidProjects.length > 0) {
|
||||
issues.push(`Condition ${index + 1}: Project(s) no longer exist`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
isValid: issues.length === 0,
|
||||
issues,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user