picocrank 1.24.0 → 1.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +2 -0
- package/docs/filter-query-v1.md +180 -0
- package/logo.svg +7 -7
- package/package.json +7 -5
- package/public/themes/README.md +19 -2
- package/public/themes/index.json +1 -5
- package/styles/dark-theme.css +16 -1
- package/supplemental-themes/README.md +9 -0
- package/supplemental-themes/catppuccin-latte-frappe/theme.css +276 -0
- package/supplemental-themes/dracula-alucard/theme.css +258 -0
- package/supplemental-themes/gruvbox-dark-light/theme.css +264 -0
- package/supplemental-themes/index.json +17 -0
- package/{public/themes → supplemental-themes}/waffles/theme.css +99 -0
- package/vite.config.js +91 -0
- package/vue/components/Header.vue +3 -3
- package/vue/components/Login.vue +1 -3
- package/vue/components/Section.vue +45 -14
- package/vue/components/Sidebar.vue +2 -1
- package/vue/components/SidebarNavLink.vue +6 -0
- package/vue/components/Table.vue +1113 -73
- package/vue/components/TableColumnFilterPopover.vue +263 -44
- package/vue/components/TableColumnOptionsPopover.vue +1116 -0
- package/vue/components/ThemeSwitcher.vue +119 -0
- package/vue/components/TopBar.vue +2 -2
- package/vue/composables/tableColumnPresets.js +296 -0
- package/vue/composables/tableFilterQuery.js +202 -0
- package/vue/composables/tableFilters.js +167 -3
- package/vue/composables/useCustomTheme.js +167 -24
- package/vue/data/examplePeople.js +2 -6
- package/vue/examples/App.vue +1 -1
- package/vue/examples/LoginExample.vue +1 -3
- package/vue/examples/TableExample.vue +329 -11
- package/vue/examples/TableRemoteExample.vue +112 -4
- package/vue/examples/ThemePreview.vue +17 -31
- package/public/themes/catppuccin/theme.css +0 -195
- package/public/themes/dracula/theme.css +0 -181
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<label class="theme-switcher">
|
|
3
|
+
<span v-if="label" class="theme-switcher-label">{{ label }}</span>
|
|
4
|
+
<select :value="themePreference" @change="onThemeChange">
|
|
5
|
+
<option value="">{{ defaultOptionLabel }}</option>
|
|
6
|
+
<option
|
|
7
|
+
v-for="name in availableThemes"
|
|
8
|
+
:key="name"
|
|
9
|
+
:value="name"
|
|
10
|
+
>
|
|
11
|
+
{{ themeLabels[name] || name }}
|
|
12
|
+
</option>
|
|
13
|
+
</select>
|
|
14
|
+
</label>
|
|
15
|
+
<p v-if="showEmptyHint && availableThemes.length === 0" class="subtle theme-switcher-hint">
|
|
16
|
+
<slot name="empty">
|
|
17
|
+
No drop-in themes found. Add <code>public/themes/<name>/theme.css</code>
|
|
18
|
+
or set <code>include-supplemental-themes</code> to list bundled supplemental themes.
|
|
19
|
+
</slot>
|
|
20
|
+
</p>
|
|
21
|
+
</template>
|
|
22
|
+
|
|
23
|
+
<script setup>
|
|
24
|
+
import { onMounted, watch } from 'vue'
|
|
25
|
+
import { useCustomTheme } from '../composables/useCustomTheme.js'
|
|
26
|
+
|
|
27
|
+
const props = defineProps({
|
|
28
|
+
label: {
|
|
29
|
+
type: String,
|
|
30
|
+
default: 'Active theme',
|
|
31
|
+
},
|
|
32
|
+
defaultOptionLabel: {
|
|
33
|
+
type: String,
|
|
34
|
+
default: 'Default (Femtocrank)',
|
|
35
|
+
},
|
|
36
|
+
includeSupplementalThemes: {
|
|
37
|
+
type: Boolean,
|
|
38
|
+
default: false,
|
|
39
|
+
},
|
|
40
|
+
themesBasePath: {
|
|
41
|
+
type: String,
|
|
42
|
+
default: undefined,
|
|
43
|
+
},
|
|
44
|
+
supplementalThemesBasePath: {
|
|
45
|
+
type: String,
|
|
46
|
+
default: undefined,
|
|
47
|
+
},
|
|
48
|
+
storageKey: {
|
|
49
|
+
type: String,
|
|
50
|
+
default: undefined,
|
|
51
|
+
},
|
|
52
|
+
showEmptyHint: {
|
|
53
|
+
type: Boolean,
|
|
54
|
+
default: false,
|
|
55
|
+
},
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
const emit = defineEmits(['change'])
|
|
59
|
+
|
|
60
|
+
const {
|
|
61
|
+
availableThemes,
|
|
62
|
+
themeLabels,
|
|
63
|
+
themePreference,
|
|
64
|
+
setTheme,
|
|
65
|
+
discoverThemes,
|
|
66
|
+
configure,
|
|
67
|
+
} = useCustomTheme()
|
|
68
|
+
|
|
69
|
+
function syncOptions() {
|
|
70
|
+
configure({
|
|
71
|
+
includeSupplementalThemes: props.includeSupplementalThemes,
|
|
72
|
+
themesBasePath: props.themesBasePath,
|
|
73
|
+
supplementalThemesBasePath: props.supplementalThemesBasePath,
|
|
74
|
+
storageKey: props.storageKey,
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function onThemeChange(event) {
|
|
79
|
+
const value = event.target.value
|
|
80
|
+
setTheme(value)
|
|
81
|
+
emit('change', value)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
watch(
|
|
85
|
+
() => [
|
|
86
|
+
props.includeSupplementalThemes,
|
|
87
|
+
props.themesBasePath,
|
|
88
|
+
props.supplementalThemesBasePath,
|
|
89
|
+
props.storageKey,
|
|
90
|
+
],
|
|
91
|
+
() => {
|
|
92
|
+
syncOptions()
|
|
93
|
+
void discoverThemes()
|
|
94
|
+
},
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
onMounted(() => {
|
|
98
|
+
syncOptions()
|
|
99
|
+
void discoverThemes()
|
|
100
|
+
})
|
|
101
|
+
</script>
|
|
102
|
+
|
|
103
|
+
<style scoped>
|
|
104
|
+
.theme-switcher {
|
|
105
|
+
display: flex;
|
|
106
|
+
flex-wrap: wrap;
|
|
107
|
+
align-items: center;
|
|
108
|
+
gap: 0.75rem;
|
|
109
|
+
margin: 0;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.theme-switcher select {
|
|
113
|
+
min-width: 14rem;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
.theme-switcher-hint {
|
|
117
|
+
margin: 0.75rem 0 0;
|
|
118
|
+
}
|
|
119
|
+
</style>
|
|
@@ -97,7 +97,7 @@ function handleLinkClick(callback = null) {
|
|
|
97
97
|
padding: 0.5em 0.75em;
|
|
98
98
|
border-radius: 0.25em;
|
|
99
99
|
text-decoration: none;
|
|
100
|
-
color: inherit;
|
|
100
|
+
color: var(--header-text-color, inherit);
|
|
101
101
|
transition: background-color 0.15s ease;
|
|
102
102
|
white-space: nowrap;
|
|
103
103
|
border: 0;
|
|
@@ -124,7 +124,7 @@ function handleLinkClick(callback = null) {
|
|
|
124
124
|
|
|
125
125
|
html[data-theme="dark"] {
|
|
126
126
|
.topbar-link {
|
|
127
|
-
color: #f8f9fa;
|
|
127
|
+
color: var(--header-text-color, #f8f9fa);
|
|
128
128
|
}
|
|
129
129
|
|
|
130
130
|
.separator {
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import {
|
|
2
|
+
normalizeColumnFilterEntries,
|
|
3
|
+
normalizeStoredFilterEntry,
|
|
4
|
+
} from './tableFilters.js'
|
|
5
|
+
|
|
6
|
+
export const PRESET_STORE_VERSION = 1
|
|
7
|
+
export const MAX_PRESETS_PER_TABLE = 20
|
|
8
|
+
export const ALLOWED_PAGE_SIZES = [10, 25, 50, 100]
|
|
9
|
+
|
|
10
|
+
let presetIdCounter = 0
|
|
11
|
+
|
|
12
|
+
export function createPresetId() {
|
|
13
|
+
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
14
|
+
return `preset-${crypto.randomUUID()}`
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
presetIdCounter += 1
|
|
18
|
+
return `preset-${presetIdCounter}-${Date.now()}`
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function getPresetStorageKey(tableId) {
|
|
22
|
+
const origin = typeof window !== 'undefined' ? window.location.origin : 'ssr'
|
|
23
|
+
return `picocrank-table-layouts:v${PRESET_STORE_VERSION}:${origin}:${tableId}`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function createEmptyStore() {
|
|
27
|
+
return {
|
|
28
|
+
version: PRESET_STORE_VERSION,
|
|
29
|
+
defaultPresetId: null,
|
|
30
|
+
presets: [],
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function normalizeStore(raw) {
|
|
35
|
+
if (!raw || typeof raw !== 'object') {
|
|
36
|
+
return createEmptyStore()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const presets = Array.isArray(raw.presets)
|
|
40
|
+
? raw.presets.filter((preset) => preset?.id && preset?.name && preset?.state)
|
|
41
|
+
: []
|
|
42
|
+
|
|
43
|
+
const defaultPresetId = typeof raw.defaultPresetId === 'string'
|
|
44
|
+
&& presets.some((preset) => preset.id === raw.defaultPresetId)
|
|
45
|
+
? raw.defaultPresetId
|
|
46
|
+
: null
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
version: PRESET_STORE_VERSION,
|
|
50
|
+
defaultPresetId,
|
|
51
|
+
presets,
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function readPresetStore(tableId) {
|
|
56
|
+
if (!tableId || typeof localStorage === 'undefined') {
|
|
57
|
+
return createEmptyStore()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const raw = localStorage.getItem(getPresetStorageKey(tableId))
|
|
62
|
+
if (!raw) {
|
|
63
|
+
return createEmptyStore()
|
|
64
|
+
}
|
|
65
|
+
return normalizeStore(JSON.parse(raw))
|
|
66
|
+
} catch {
|
|
67
|
+
return createEmptyStore()
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function writePresetStore(tableId, store) {
|
|
72
|
+
if (!tableId || typeof localStorage === 'undefined') {
|
|
73
|
+
return false
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
localStorage.setItem(getPresetStorageKey(tableId), JSON.stringify(normalizeStore(store)))
|
|
77
|
+
return true
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function listPresets(tableId) {
|
|
81
|
+
const store = readPresetStore(tableId)
|
|
82
|
+
return {
|
|
83
|
+
presets: store.presets.map((preset) => ({ ...preset })),
|
|
84
|
+
defaultPresetId: store.defaultPresetId,
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function getDefaultPreset(tableId) {
|
|
89
|
+
const store = readPresetStore(tableId)
|
|
90
|
+
if (!store.defaultPresetId) {
|
|
91
|
+
return null
|
|
92
|
+
}
|
|
93
|
+
return store.presets.find((preset) => preset.id === store.defaultPresetId) ?? null
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function isValidColPriority(priority) {
|
|
97
|
+
return Number.isInteger(priority) && priority >= 1 && priority <= 5
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function headerMaps(headers = []) {
|
|
101
|
+
const headerByKey = new Map()
|
|
102
|
+
const manageableKeys = []
|
|
103
|
+
const configurableKeys = []
|
|
104
|
+
|
|
105
|
+
for (const header of headers) {
|
|
106
|
+
if (!header?.key || header.hidden) {
|
|
107
|
+
continue
|
|
108
|
+
}
|
|
109
|
+
manageableKeys.push(header.key)
|
|
110
|
+
headerByKey.set(header.key, header)
|
|
111
|
+
if (header.hideable !== false) {
|
|
112
|
+
configurableKeys.push(header.key)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return { headerByKey, manageableKeys, configurableKeys }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function sanitizePresetState(state, headers = [], rows = []) {
|
|
120
|
+
if (!state || typeof state !== 'object') {
|
|
121
|
+
return null
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const { headerByKey, manageableKeys, configurableKeys } = headerMaps(headers)
|
|
125
|
+
const sanitized = {}
|
|
126
|
+
|
|
127
|
+
if (Array.isArray(state.columnOrder)) {
|
|
128
|
+
const fromOrder = state.columnOrder.filter((key) => manageableKeys.includes(key))
|
|
129
|
+
const missing = manageableKeys.filter((key) => !fromOrder.includes(key))
|
|
130
|
+
sanitized.columnOrder = [...fromOrder, ...missing]
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (state.columnVisibility && typeof state.columnVisibility === 'object') {
|
|
134
|
+
sanitized.columnVisibility = Object.fromEntries(
|
|
135
|
+
configurableKeys.map((key) => [
|
|
136
|
+
key,
|
|
137
|
+
state.columnVisibility[key] !== false,
|
|
138
|
+
]),
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (state.columnPriorities && typeof state.columnPriorities === 'object') {
|
|
143
|
+
const priorities = {}
|
|
144
|
+
for (const [key, value] of Object.entries(state.columnPriorities)) {
|
|
145
|
+
if (!manageableKeys.includes(key)) {
|
|
146
|
+
continue
|
|
147
|
+
}
|
|
148
|
+
if (value === null || value === '') {
|
|
149
|
+
priorities[key] = null
|
|
150
|
+
continue
|
|
151
|
+
}
|
|
152
|
+
if (isValidColPriority(value)) {
|
|
153
|
+
priorities[key] = value
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
sanitized.columnPriorities = priorities
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (state.filters && typeof state.filters === 'object') {
|
|
160
|
+
const filters = {}
|
|
161
|
+
for (const [key, columnFilters] of Object.entries(state.filters)) {
|
|
162
|
+
const header = headerByKey.get(key)
|
|
163
|
+
if (!header || header.filterable === false) {
|
|
164
|
+
continue
|
|
165
|
+
}
|
|
166
|
+
const entries = normalizeColumnFilterEntries(columnFilters, header, rows)
|
|
167
|
+
.map((entry) => normalizeStoredFilterEntry(header, entry, rows))
|
|
168
|
+
.filter(Boolean)
|
|
169
|
+
if (entries.length > 0) {
|
|
170
|
+
filters[key] = entries
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
sanitized.filters = filters
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (state.sortBy) {
|
|
177
|
+
const header = headerByKey.get(state.sortBy)
|
|
178
|
+
if (header?.sortable) {
|
|
179
|
+
sanitized.sortBy = state.sortBy
|
|
180
|
+
sanitized.sortDir = state.sortDir === 'desc' ? 'desc' : 'asc'
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const rawPageSize = Number(state.pageSize)
|
|
185
|
+
if (Number.isFinite(rawPageSize)) {
|
|
186
|
+
const normalizedPageSize = Math.floor(rawPageSize)
|
|
187
|
+
if (ALLOWED_PAGE_SIZES.includes(normalizedPageSize)) {
|
|
188
|
+
sanitized.pageSize = normalizedPageSize
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return sanitized
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function savePreset(tableId, { name, state, setAsDefault = false }) {
|
|
196
|
+
const trimmedName = String(name ?? '').trim()
|
|
197
|
+
if (!tableId) {
|
|
198
|
+
return { ok: false, error: 'Table identity is required.' }
|
|
199
|
+
}
|
|
200
|
+
if (!trimmedName) {
|
|
201
|
+
return { ok: false, error: 'Preset name is required.' }
|
|
202
|
+
}
|
|
203
|
+
if (!state || typeof state !== 'object') {
|
|
204
|
+
return { ok: false, error: 'Nothing to save.' }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const store = readPresetStore(tableId)
|
|
208
|
+
if (store.presets.length >= MAX_PRESETS_PER_TABLE) {
|
|
209
|
+
return { ok: false, error: `Maximum of ${MAX_PRESETS_PER_TABLE} saved layouts reached.` }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const preset = {
|
|
213
|
+
id: createPresetId(),
|
|
214
|
+
name: trimmedName,
|
|
215
|
+
savedAt: new Date().toISOString(),
|
|
216
|
+
state: JSON.parse(JSON.stringify(state)),
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
store.presets.push(preset)
|
|
220
|
+
if (setAsDefault) {
|
|
221
|
+
store.defaultPresetId = preset.id
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
writePresetStore(tableId, store)
|
|
225
|
+
return { ok: true, preset, defaultPresetId: store.defaultPresetId }
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function deletePreset(tableId, presetId) {
|
|
229
|
+
if (!tableId || !presetId) {
|
|
230
|
+
return { ok: false, error: 'Preset not found.' }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const store = readPresetStore(tableId)
|
|
234
|
+
const nextPresets = store.presets.filter((preset) => preset.id !== presetId)
|
|
235
|
+
if (nextPresets.length === store.presets.length) {
|
|
236
|
+
return { ok: false, error: 'Preset not found.' }
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
store.presets = nextPresets
|
|
240
|
+
if (store.defaultPresetId === presetId) {
|
|
241
|
+
store.defaultPresetId = null
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
writePresetStore(tableId, store)
|
|
245
|
+
return { ok: true, defaultPresetId: store.defaultPresetId }
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function setDefaultPreset(tableId, presetId) {
|
|
249
|
+
if (!tableId) {
|
|
250
|
+
return { ok: false, error: 'Table identity is required.' }
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const store = readPresetStore(tableId)
|
|
254
|
+
if (presetId === null) {
|
|
255
|
+
store.defaultPresetId = null
|
|
256
|
+
writePresetStore(tableId, store)
|
|
257
|
+
return { ok: true, defaultPresetId: null }
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const preset = store.presets.find((item) => item.id === presetId)
|
|
261
|
+
if (!preset) {
|
|
262
|
+
return { ok: false, error: 'Preset not found.' }
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
store.defaultPresetId = presetId
|
|
266
|
+
writePresetStore(tableId, store)
|
|
267
|
+
return { ok: true, defaultPresetId: presetId }
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function getPresetById(tableId, presetId) {
|
|
271
|
+
const store = readPresetStore(tableId)
|
|
272
|
+
return store.presets.find((preset) => preset.id === presetId) ?? null
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function cloneLayoutState(state) {
|
|
276
|
+
return JSON.parse(JSON.stringify(state ?? {}))
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function normalizeLayoutStateForCompare(state, headers = [], rows = []) {
|
|
280
|
+
const sanitized = sanitizePresetState(state, headers, rows) ?? {}
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
columnOrder: sanitized.columnOrder ?? [],
|
|
284
|
+
columnVisibility: sanitized.columnVisibility ?? {},
|
|
285
|
+
columnPriorities: sanitized.columnPriorities ?? {},
|
|
286
|
+
filters: sanitized.filters ?? {},
|
|
287
|
+
sortBy: sanitized.sortBy ?? null,
|
|
288
|
+
sortDir: sanitized.sortDir === 'desc' ? 'desc' : 'asc',
|
|
289
|
+
pageSize: sanitized.pageSize ?? ALLOWED_PAGE_SIZES[0],
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export function layoutStatesEqual(left, right, headers = [], rows = []) {
|
|
294
|
+
return JSON.stringify(normalizeLayoutStateForCompare(left, headers, rows))
|
|
295
|
+
=== JSON.stringify(normalizeLayoutStateForCompare(right, headers, rows))
|
|
296
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import {
|
|
2
|
+
applyColumnFilter,
|
|
3
|
+
filterEntrySpec,
|
|
4
|
+
isFilterActive,
|
|
5
|
+
isFilterEntryActive,
|
|
6
|
+
normalizeColumnFilterEntries,
|
|
7
|
+
normalizeFilter,
|
|
8
|
+
} from './tableFilters.js'
|
|
9
|
+
|
|
10
|
+
export const FILTER_QUERY_VERSION = 1
|
|
11
|
+
|
|
12
|
+
/** @see docs/filter-query-v1.md */
|
|
13
|
+
|
|
14
|
+
export function createEmptyFilterQuery() {
|
|
15
|
+
return {
|
|
16
|
+
version: FILTER_QUERY_VERSION,
|
|
17
|
+
match: 'all',
|
|
18
|
+
columns: [],
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function normalizeFilterSpec(entry, header, rows) {
|
|
23
|
+
if (!entry || typeof entry !== 'object') {
|
|
24
|
+
return null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const { id, name, enabled, ...partial } = entry
|
|
28
|
+
const filter = normalizeFilter(header, partial, rows)
|
|
29
|
+
if (!filter) {
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
id: typeof id === 'string' ? id : undefined,
|
|
35
|
+
name: typeof name === 'string' ? name : '',
|
|
36
|
+
enabled: enabled !== false,
|
|
37
|
+
...filter,
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function normalizeFilterQuery(filterQuery, headers = [], rows = []) {
|
|
42
|
+
if (!filterQuery || typeof filterQuery !== 'object') {
|
|
43
|
+
return createEmptyFilterQuery()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const headerByKey = new Map(
|
|
47
|
+
headers
|
|
48
|
+
.filter((header) => header?.key)
|
|
49
|
+
.map((header) => [header.key, header]),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
const columns = Array.isArray(filterQuery.columns)
|
|
53
|
+
? filterQuery.columns
|
|
54
|
+
.map((column) => {
|
|
55
|
+
if (!column?.key || !headerByKey.has(column.key)) {
|
|
56
|
+
return null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const header = headerByKey.get(column.key)
|
|
60
|
+
if (header.filterable === false) {
|
|
61
|
+
return null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const filters = Array.isArray(column.filters)
|
|
65
|
+
? column.filters
|
|
66
|
+
.map((entry) => normalizeFilterSpec(entry, header, rows))
|
|
67
|
+
.filter(Boolean)
|
|
68
|
+
: []
|
|
69
|
+
|
|
70
|
+
if (filters.length === 0) {
|
|
71
|
+
return null
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
key: column.key,
|
|
76
|
+
match: column.match === 'any' ? 'any' : 'all',
|
|
77
|
+
filters,
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
.filter(Boolean)
|
|
81
|
+
: []
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
version: FILTER_QUERY_VERSION,
|
|
85
|
+
match: filterQuery.match === 'any' ? 'any' : 'all',
|
|
86
|
+
columns,
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function buildFilterQuery(filters = {}, headers = [], rows = [], { includeInactive = true } = {}) {
|
|
91
|
+
const headerByKey = new Map(
|
|
92
|
+
headers
|
|
93
|
+
.filter((header) => header?.key)
|
|
94
|
+
.map((header) => [header.key, header]),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
const columns = []
|
|
98
|
+
|
|
99
|
+
for (const [key, columnFilters] of Object.entries(filters ?? {})) {
|
|
100
|
+
const header = headerByKey.get(key)
|
|
101
|
+
if (!header || header.filterable === false) {
|
|
102
|
+
continue
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const entries = normalizeColumnFilterEntries(columnFilters, header, rows)
|
|
106
|
+
.map((entry) => normalizeFilterSpec(entry, header, rows))
|
|
107
|
+
.filter(Boolean)
|
|
108
|
+
.filter((entry) => includeInactive || isFilterEntryActive(entry))
|
|
109
|
+
|
|
110
|
+
if (entries.length === 0) {
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
columns.push({
|
|
115
|
+
key,
|
|
116
|
+
match: 'all',
|
|
117
|
+
filters: entries,
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
version: FILTER_QUERY_VERSION,
|
|
123
|
+
match: 'all',
|
|
124
|
+
columns,
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function filterQueryToFilters(filterQuery, headers = [], rows = []) {
|
|
129
|
+
const normalized = normalizeFilterQuery(filterQuery, headers, rows)
|
|
130
|
+
const filters = {}
|
|
131
|
+
|
|
132
|
+
for (const column of normalized.columns) {
|
|
133
|
+
filters[column.key] = column.filters.map((entry) => ({ ...entry }))
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return filters
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function applyColumnFilterQuery(row, header, columnQuery) {
|
|
140
|
+
const activeFilters = (columnQuery.filters ?? [])
|
|
141
|
+
.filter((entry) => entry.enabled !== false)
|
|
142
|
+
.map((entry) => filterEntrySpec(entry))
|
|
143
|
+
.filter((filter) => isFilterActive(filter))
|
|
144
|
+
|
|
145
|
+
if (activeFilters.length === 0) {
|
|
146
|
+
return true
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (columnQuery.match === 'any') {
|
|
150
|
+
return activeFilters.some((filter) => applyColumnFilter(row, header, filter))
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return activeFilters.every((filter) => applyColumnFilter(row, header, filter))
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function applyFilterQuery(rows, headers = [], filterQuery) {
|
|
157
|
+
const normalized = normalizeFilterQuery(filterQuery, headers, rows)
|
|
158
|
+
if (normalized.columns.length === 0) {
|
|
159
|
+
return rows
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const headerByKey = new Map(
|
|
163
|
+
headers
|
|
164
|
+
.filter((header) => header?.key)
|
|
165
|
+
.map((header) => [header.key, header]),
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
return rows.filter((row) => {
|
|
169
|
+
const columnResults = normalized.columns.map((column) => {
|
|
170
|
+
const header = headerByKey.get(column.key)
|
|
171
|
+
if (!header) {
|
|
172
|
+
return true
|
|
173
|
+
}
|
|
174
|
+
return applyColumnFilterQuery(row, header, column)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
if (normalized.match === 'any') {
|
|
178
|
+
return columnResults.some(Boolean)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return columnResults.every(Boolean)
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function countActiveFilterQuery(filterQuery, headers = [], rows = []) {
|
|
186
|
+
const normalized = normalizeFilterQuery(filterQuery, headers, rows)
|
|
187
|
+
let total = 0
|
|
188
|
+
|
|
189
|
+
for (const column of normalized.columns) {
|
|
190
|
+
total += column.filters.filter((entry) => isFilterEntryActive(entry)).length
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return total
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function isFilterQueryActive(filterQuery, headers = [], rows = []) {
|
|
197
|
+
return countActiveFilterQuery(filterQuery, headers, rows) > 0
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function cloneFilterQuery(filterQuery) {
|
|
201
|
+
return JSON.parse(JSON.stringify(filterQuery ?? createEmptyFilterQuery()))
|
|
202
|
+
}
|