Refine journal search filtering
This commit is contained in:
@@ -306,6 +306,11 @@ These are real incidents — know them before you start.
|
|||||||
redundant on the V3 path. Both paths now pretty-print with 2-space indent.
|
redundant on the V3 path. Both paths now pretty-print with 2-space indent.
|
||||||
See `GUIDE__AE_API_V3_for_Frontend.md` → section 3C for the full explanation.
|
See `GUIDE__AE_API_V3_for_Frontend.md` → section 3C for the full explanation.
|
||||||
|
|
||||||
|
12. **Broad Dexie result windows get silently clipped** — if a broad "All" view shows fewer
|
||||||
|
rows than a narrower filter, check for a page-level limit or an API revalidation step
|
||||||
|
replacing the local IDB result set. For empty text searches, the full local result set
|
||||||
|
should drive the display; server refreshes should update cache, not shrink visibility.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. Source Layout (Quick Reference)
|
## 8. Source Layout (Quick Reference)
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ $effect(() => {
|
|||||||
|
|
||||||
## Practical Patterns from Aether (Journals & Events)
|
## Practical Patterns from Aether (Journals & Events)
|
||||||
- Journals: The journaling pages use SWR-style background refreshes but reliably render because either (a) the page `+page.ts` blocks to populate DB for critical views, or (b) components accept `data.initial_*` fallback values until `liveQuery` emits. This hybrid approach avoids the "refresh twice" problem while keeping navigation snappy.
|
- Journals: The journaling pages use SWR-style background refreshes but reliably render because either (a) the page `+page.ts` blocks to populate DB for critical views, or (b) components accept `data.initial_*` fallback values until `liveQuery` emits. This hybrid approach avoids the "refresh twice" problem while keeping navigation snappy.
|
||||||
|
- Journals broad views: if text search is empty, let the local IDB result set drive the visible list. The API can revalidate the cache in the background, but it should not replace a broad "All" view with a limited slice that hides valid rows.
|
||||||
|
|
||||||
- Sessions / Presentations: The session page demonstrates several best practices:
|
- Sessions / Presentations: The session page demonstrates several best practices:
|
||||||
- Use `url_*` constants (derived from `data.params`) so the `liveQuery` closure captures a stable value instead of the reactive store directly.
|
- Use `url_*` constants (derived from `data.params`) so the `liveQuery` closure captures a stable value instead of the reactive store directly.
|
||||||
@@ -113,6 +114,7 @@ let lq__event_presentation_obj_li = $derived(
|
|||||||
- Add a small `console.log` inside each `liveQuery` closure to confirm when it runs and what `id` it sees.
|
- Add a small `console.log` inside each `liveQuery` closure to confirm when it runs and what `id` it sees.
|
||||||
- Verify that `+page.ts` either `await`s critical loads or returns `initial_*` payloads for first-render hydration.
|
- Verify that `+page.ts` either `await`s critical loads or returns `initial_*` payloads for first-render hydration.
|
||||||
- Confirm that dependent store values (selected IDs) are assigned before components subscribe — use `untrack` to prevent extra reactive cycles.
|
- Confirm that dependent store values (selected IDs) are assigned before components subscribe — use `untrack` to prevent extra reactive cycles.
|
||||||
|
- If a broad Dexie-backed list shows fewer rows than a narrower filter, look for a limit or revalidation step overwriting the local IDB result set. Broad views should stay unbounded unless the user is actually narrowing by text.
|
||||||
- Ensure your `liveQuery` closures return quickly and do not throw; any exception inside the query can stop updates.
|
- Ensure your `liveQuery` closures return quickly and do not throw; any exception inside the query can stop updates.
|
||||||
- If a dependent query appears stale, temporarily add `await 0` in the upstream query or an explicit `Promise.resolve()` after the IDB write to force the microtask queue to flush during debugging.
|
- If a dependent query appears stale, temporarily add `await 0` in the upstream query or an explicit `Promise.resolve()` after the IDB write to force the microtask queue to flush during debugging.
|
||||||
|
|
||||||
|
|||||||
105
src/lib/ae_journals/ae_journals_search_helpers.ts
Normal file
105
src/lib/ae_journals/ae_journals_search_helpers.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
export interface JournalEntrySearchParams {
|
||||||
|
str?: string;
|
||||||
|
cat?: string | null;
|
||||||
|
enabled?: 'enabled' | 'all' | 'not_enabled';
|
||||||
|
hidden?: 'hidden' | 'all' | 'not_hidden';
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalize_search_value(value: unknown): string {
|
||||||
|
if (value === null || value === undefined) return '';
|
||||||
|
|
||||||
|
if (typeof value === 'string') return value.toLowerCase();
|
||||||
|
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||||
|
return String(value).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value).toLowerCase();
|
||||||
|
} catch {
|
||||||
|
return String(value).toLowerCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function build_journal_entry_search_blob(entry: any): string {
|
||||||
|
return [
|
||||||
|
entry?.code,
|
||||||
|
entry?.name,
|
||||||
|
entry?.short_name,
|
||||||
|
entry?.summary,
|
||||||
|
entry?.outline,
|
||||||
|
entry?.content,
|
||||||
|
entry?.history,
|
||||||
|
entry?.notes,
|
||||||
|
entry?.tags,
|
||||||
|
entry?.activity_code,
|
||||||
|
entry?.category_code,
|
||||||
|
entry?.type_code,
|
||||||
|
entry?.topic_code,
|
||||||
|
entry?.group,
|
||||||
|
entry?.journal_code,
|
||||||
|
entry?.journal_name,
|
||||||
|
entry?.alert_msg,
|
||||||
|
entry?.default_qry_str
|
||||||
|
]
|
||||||
|
.map(normalize_search_value)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function journal_entry_matches_search(
|
||||||
|
entry: any,
|
||||||
|
params: JournalEntrySearchParams
|
||||||
|
): boolean {
|
||||||
|
if (!entry) return false;
|
||||||
|
|
||||||
|
const qry_str = (params.str ?? '').toLowerCase().trim();
|
||||||
|
const category_code = params.cat ?? '';
|
||||||
|
const enabled_mode = params.enabled ?? 'all';
|
||||||
|
const hidden_mode = params.hidden ?? 'all';
|
||||||
|
|
||||||
|
const is_hidden = entry.hide === true || entry.hide === 1;
|
||||||
|
const is_disabled = entry.enable === false || entry.enable === 0;
|
||||||
|
|
||||||
|
if (category_code && entry.category_code !== category_code) return false;
|
||||||
|
|
||||||
|
if (enabled_mode === 'enabled' && is_disabled) return false;
|
||||||
|
if (enabled_mode === 'not_enabled' && !is_disabled) return false;
|
||||||
|
|
||||||
|
if (hidden_mode === 'hidden' && !is_hidden) return false;
|
||||||
|
if (hidden_mode === 'not_hidden' && is_hidden) return false;
|
||||||
|
|
||||||
|
if (!qry_str) return true;
|
||||||
|
|
||||||
|
return build_journal_entry_search_blob(entry).includes(qry_str);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function journal_entry_compare_for_list(a: any, b: any): number {
|
||||||
|
return (
|
||||||
|
(b?.tmp_sort_1 ?? '').localeCompare(a?.tmp_sort_1 ?? '') ||
|
||||||
|
(b?.updated_on ?? '').localeCompare(a?.updated_on ?? '') ||
|
||||||
|
(b?.journal_entry_id ?? '').localeCompare(a?.journal_entry_id ?? '')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function journal_entry_filter_list(
|
||||||
|
list: any[] | null | undefined,
|
||||||
|
params: JournalEntrySearchParams
|
||||||
|
): any[] | null {
|
||||||
|
if (list === undefined || list === null) return null;
|
||||||
|
if (!Array.isArray(list)) return [];
|
||||||
|
|
||||||
|
const has_text_search = Boolean((params.str ?? '').trim());
|
||||||
|
|
||||||
|
const filtered = list
|
||||||
|
.filter((entry) => journal_entry_matches_search(entry, params))
|
||||||
|
.sort(journal_entry_compare_for_list);
|
||||||
|
|
||||||
|
// Broad views should show the full local result set; only text searches
|
||||||
|
// should be sliced to a page-sized window.
|
||||||
|
if (has_text_search && params.limit && params.limit > 0) {
|
||||||
|
return filtered.slice(0, params.limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
@@ -235,7 +235,7 @@ Middle-click to open in new tab`}>
|
|||||||
</a>
|
</a>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Edit Journal button. Creates a modal to edit the journal. -->
|
<!-- Edit Journal button. Creates a modal to edit the journal. -->
|
||||||
<Journal_entry_obj_qry {log_lvl} {lq__journal_obj} />
|
<Journal_entry_obj_qry {log_lvl} lq__journal_obj={$lq__journal_obj} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Add default journal entry -->
|
<!-- Add default journal entry -->
|
||||||
|
|||||||
@@ -3,7 +3,10 @@
|
|||||||
let log_lvl: number = $state(0);
|
let log_lvl: number = $state(0);
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data: any;
|
data: {
|
||||||
|
account_id: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let { data }: Props = $props();
|
let { data }: Props = $props();
|
||||||
@@ -16,23 +19,13 @@ import { untrack } from 'svelte';
|
|||||||
import { liveQuery } from 'dexie';
|
import { liveQuery } from 'dexie';
|
||||||
|
|
||||||
// *** Import Aether specific variables and functions
|
// *** Import Aether specific variables and functions
|
||||||
import { ae_util } from '$lib/ae_utils/ae_utils';
|
import { ae_loc, ae_api } from '$lib/stores/ae_stores';
|
||||||
import {
|
|
||||||
ae_snip,
|
|
||||||
ae_loc,
|
|
||||||
ae_sess,
|
|
||||||
ae_api,
|
|
||||||
ae_trig,
|
|
||||||
slct,
|
|
||||||
slct_trigger
|
|
||||||
} from '$lib/stores/ae_stores';
|
|
||||||
import { db_journals } from '$lib/ae_journals/db_journals';
|
import { db_journals } from '$lib/ae_journals/db_journals';
|
||||||
|
import { journal_entry_filter_list } from '$lib/ae_journals/ae_journals_search_helpers';
|
||||||
import {
|
import {
|
||||||
journals_loc,
|
journals_loc,
|
||||||
journals_sess,
|
journals_sess,
|
||||||
journals_slct,
|
journals_slct
|
||||||
journals_prom,
|
|
||||||
journals_trig
|
|
||||||
} from '$lib/ae_journals/ae_journals_stores';
|
} from '$lib/ae_journals/ae_journals_stores';
|
||||||
import { journals_func } from '$lib/ae_journals/ae_journals_functions';
|
import { journals_func } from '$lib/ae_journals/ae_journals_functions';
|
||||||
|
|
||||||
@@ -42,12 +35,34 @@ import AeCompModalJournalExport from '../ae_comp__modal_journal_export.svelte';
|
|||||||
import AeCompModalJournalImport from '../ae_comp__modal_journal_import.svelte';
|
import AeCompModalJournalImport from '../ae_comp__modal_journal_import.svelte';
|
||||||
|
|
||||||
// Variables
|
// Variables
|
||||||
let ae_acct = $derived(data[data.account_id]);
|
interface JournalPageAccount {
|
||||||
|
api?: unknown;
|
||||||
|
loc?: {
|
||||||
|
person_id?: string | null;
|
||||||
|
};
|
||||||
|
slct: {
|
||||||
|
journal_id: string | null;
|
||||||
|
journal_entry_id?: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let ae_acct = $derived(data[data.account_id] as JournalPageAccount);
|
||||||
let show_export_modal = $state(false);
|
let show_export_modal = $state(false);
|
||||||
let show_import_modal = $state(false);
|
let show_import_modal = $state(false);
|
||||||
|
|
||||||
|
interface JournalSearchParams {
|
||||||
|
v: number;
|
||||||
|
str: string;
|
||||||
|
cat: string;
|
||||||
|
limit: number;
|
||||||
|
enabled: 'enabled' | 'all' | 'not_enabled';
|
||||||
|
hidden: 'hidden' | 'all' | 'not_hidden';
|
||||||
|
journal_id: string | null;
|
||||||
|
remote_first: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
let search_id_li: Array<string> = $state([]);
|
let search_id_li: Array<string> = $state([]);
|
||||||
let search_debounce_timer: any = null;
|
let search_debounce_timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let last_search_id = 0;
|
let last_search_id = 0;
|
||||||
let last_executed_key = ''; // Search Guard Key
|
let last_executed_key = ''; // Search Guard Key
|
||||||
|
|
||||||
@@ -72,45 +87,6 @@ let lq__journal_obj = $derived(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Stable LiveQuery Pattern (Aether UI V3)
|
|
||||||
// Re-wrapped in $derived to ensure the observable instance remains stable
|
|
||||||
// unless the underlying dependencies (ids, search context) change.
|
|
||||||
// Important: keep the `liveQuery` closure free of transient reactive
|
|
||||||
// references — capture stable values (ids, search keys) so the observable
|
|
||||||
// isn't recreated unnecessarily on every render. Use `search_id_li` or
|
|
||||||
// other plain arrays/values as explicit dependencies.
|
|
||||||
let lq__journal_entry_obj_li = $derived(
|
|
||||||
liveQuery(async () => {
|
|
||||||
const ids = search_id_li;
|
|
||||||
const journal_id = $lq__journal_obj?.journal_id;
|
|
||||||
const search_text = $journals_loc.entry.qry__search_text;
|
|
||||||
const cat_code = $journals_loc.entry.qry__category_code;
|
|
||||||
|
|
||||||
// SCENARIO 1: Specific IDs provided (Search Results)
|
|
||||||
if (Array.isArray(ids) && ids.length > 0) {
|
|
||||||
if (log_lvl)
|
|
||||||
console.log(`Journal Page LQ: bulkGet ${ids.length} IDs`);
|
|
||||||
const results = await db_journals.journal_entry.bulkGet(ids);
|
|
||||||
return results.filter((item) => item !== undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
// SCENARIO 2: Fallback to broad search (Default view)
|
|
||||||
if (journal_id && !search_text && !cat_code) {
|
|
||||||
if (log_lvl)
|
|
||||||
console.log(
|
|
||||||
`Journal Page LQ: Fallback search for journal: ${journal_id}`
|
|
||||||
);
|
|
||||||
return await db_journals.journal_entry
|
|
||||||
.where('journal_id')
|
|
||||||
.equals(journal_id)
|
|
||||||
.reverse()
|
|
||||||
.sortBy('tmp_sort_1');
|
|
||||||
}
|
|
||||||
|
|
||||||
return [];
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// Standardized Reactive Search Pattern (Aether UI V3)
|
// Standardized Reactive Search Pattern (Aether UI V3)
|
||||||
// 1. Isolate dependencies into a stable derived object
|
// 1. Isolate dependencies into a stable derived object
|
||||||
let search_params = $derived({
|
let search_params = $derived({
|
||||||
@@ -121,10 +97,53 @@ let search_params = $derived({
|
|||||||
enabled: $journals_loc.entry.qry__enabled,
|
enabled: $journals_loc.entry.qry__enabled,
|
||||||
hidden: $journals_loc.entry.qry__hidden,
|
hidden: $journals_loc.entry.qry__hidden,
|
||||||
journal_id: $journals_slct.journal_id,
|
journal_id: $journals_slct.journal_id,
|
||||||
person_id: $ae_loc.person_id,
|
|
||||||
remote_first: $journals_loc.entry.qry__remote_first
|
remote_first: $journals_loc.entry.qry__remote_first
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Stable LiveQuery Pattern (Aether UI V3)
|
||||||
|
// Re-wrapped in $derived to ensure the observable instance remains stable
|
||||||
|
// unless the underlying dependencies (ids, search context) change.
|
||||||
|
// Important: keep the `liveQuery` closure free of transient reactive
|
||||||
|
// references — capture stable values (ids, search keys) so the observable
|
||||||
|
// isn't recreated unnecessarily on every render. Use `search_id_li` or
|
||||||
|
// other plain arrays/values as explicit dependencies.
|
||||||
|
let lq__journal_entry_obj_li = $derived(
|
||||||
|
(() => {
|
||||||
|
const ids = search_id_li;
|
||||||
|
const params = search_params;
|
||||||
|
const journal_id = $lq__journal_obj?.journal_id;
|
||||||
|
|
||||||
|
return liveQuery(async () => {
|
||||||
|
if (params.remote_first && (!Array.isArray(ids) || ids.length === 0)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SCENARIO 1: Specific IDs provided (Search Results)
|
||||||
|
if (Array.isArray(ids) && ids.length > 0) {
|
||||||
|
if (log_lvl)
|
||||||
|
console.log(`Journal Page LQ: bulkGet ${ids.length} IDs`);
|
||||||
|
const results = await db_journals.journal_entry.bulkGet(ids);
|
||||||
|
return results.filter((item) => item !== undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!journal_id) return null;
|
||||||
|
|
||||||
|
// SCENARIO 2: Fallback to broad journal search (Default view)
|
||||||
|
if (log_lvl)
|
||||||
|
console.log(
|
||||||
|
`Journal Page LQ: Broad search for journal: ${journal_id}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const results = await db_journals.journal_entry
|
||||||
|
.where('journal_id')
|
||||||
|
.equals(journal_id)
|
||||||
|
.toArray();
|
||||||
|
|
||||||
|
return journal_entry_filter_list(results, params) ?? [];
|
||||||
|
});
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
|
||||||
// 2. Controlled effect for triggering searches
|
// 2. Controlled effect for triggering searches
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
// Establishes reactive dependency on search_params
|
// Establishes reactive dependency on search_params
|
||||||
@@ -143,7 +162,7 @@ $effect(() => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
async function handle_search_refresh(params: any) {
|
async function handle_search_refresh(params: JournalSearchParams) {
|
||||||
// 1. Guard: Check if criteria actually changed
|
// 1. Guard: Check if criteria actually changed
|
||||||
const qry_key = JSON.stringify(params);
|
const qry_key = JSON.stringify(params);
|
||||||
if (qry_key === last_executed_key) return;
|
if (qry_key === last_executed_key) return;
|
||||||
@@ -163,46 +182,40 @@ async function handle_search_refresh(params: any) {
|
|||||||
$journals_sess.entry.qry__status = 'loading';
|
$journals_sess.entry.qry__status = 'loading';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (remote_first) {
|
||||||
|
untrack(() => {
|
||||||
|
search_id_li = [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const qry_str = params.str;
|
const qry_str = params.str;
|
||||||
const cat_code = params.cat;
|
const cat_code = params.cat;
|
||||||
|
const order_by_li = {
|
||||||
|
group: 'DESC',
|
||||||
|
priority: 'DESC',
|
||||||
|
sort: 'DESC',
|
||||||
|
updated_on: 'DESC',
|
||||||
|
created_on: 'DESC'
|
||||||
|
};
|
||||||
|
|
||||||
let local_ids: string[] = [];
|
let local_ids: string[] = [];
|
||||||
|
|
||||||
// 3. FAST PATH: Local IDB Search (SWR)
|
// 3. FAST PATH: Local IDB Search (SWR)
|
||||||
// We skip this ONLY if remote_first is checked AND we have search text
|
// Broad views still need the local IDB set so "All" remains complete.
|
||||||
if (!remote_first) {
|
// Remote-first is only used to skip the local fast path for text searches.
|
||||||
|
if (!remote_first || !qry_str) {
|
||||||
try {
|
try {
|
||||||
if (journal_id) {
|
if (journal_id) {
|
||||||
let local_results = await db_journals.journal_entry
|
const local_results = await db_journals.journal_entry
|
||||||
.where('journal_id')
|
.where('journal_id')
|
||||||
.equals(journal_id)
|
.equals(journal_id)
|
||||||
.filter((entry) => {
|
|
||||||
if (cat_code && entry.category_code !== cat_code)
|
|
||||||
return false;
|
|
||||||
if (qry_str) {
|
|
||||||
const name = (entry.name ?? '').toLowerCase();
|
|
||||||
const content = (entry.content ?? '').toLowerCase();
|
|
||||||
return (
|
|
||||||
name.includes(qry_str) ||
|
|
||||||
content.includes(qry_str)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
})
|
|
||||||
.toArray();
|
.toArray();
|
||||||
|
|
||||||
local_results.sort((a, b) => {
|
const filtered_results =
|
||||||
const dateA = a.updated_on
|
journal_entry_filter_list(local_results, params) ?? [];
|
||||||
? new Date(a.updated_on).getTime()
|
|
||||||
: 0;
|
|
||||||
const dateB = b.updated_on
|
|
||||||
? new Date(b.updated_on).getTime()
|
|
||||||
: 0;
|
|
||||||
return dateB - dateA;
|
|
||||||
});
|
|
||||||
|
|
||||||
local_ids = local_results
|
local_ids = filtered_results
|
||||||
.map((e) => e.id || e.journal_entry_id)
|
.map((entry) => entry.id || entry.journal_entry_id)
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
if (current_search_id === last_search_id) {
|
if (current_search_id === last_search_id) {
|
||||||
@@ -233,21 +246,25 @@ async function handle_search_refresh(params: any) {
|
|||||||
enabled: params.enabled,
|
enabled: params.enabled,
|
||||||
hidden: params.hidden,
|
hidden: params.hidden,
|
||||||
limit: params.limit,
|
limit: params.limit,
|
||||||
|
order_by_li,
|
||||||
log_lvl: 0
|
log_lvl: 0
|
||||||
});
|
});
|
||||||
|
|
||||||
if (current_search_id === last_search_id) {
|
if (current_search_id === last_search_id) {
|
||||||
const api_results = results || [];
|
const api_results = (results || []) as Array<{
|
||||||
|
id?: string;
|
||||||
|
journal_entry_id?: string | null;
|
||||||
|
}>;
|
||||||
const api_ids = api_results
|
const api_ids = api_results
|
||||||
.map((e: any) => e.id || e.journal_entry_id)
|
.map((entry) => entry.id || entry.journal_entry_id)
|
||||||
.filter(Boolean);
|
.filter((entry): entry is string => Boolean(entry));
|
||||||
|
const display_ids = !qry_str && local_ids.length > 0 ? local_ids : api_ids;
|
||||||
|
|
||||||
// Protect UI cache if API returns empty during revalidation
|
// Protect UI cache if API returns empty during revalidation
|
||||||
if (
|
if (
|
||||||
api_ids.length === 0 &&
|
!qry_str &&
|
||||||
local_ids.length > 0 &&
|
local_ids.length > 0 &&
|
||||||
!remote_first &&
|
api_ids.length === 0
|
||||||
!qry_str
|
|
||||||
) {
|
) {
|
||||||
untrack(() => {
|
untrack(() => {
|
||||||
$journals_sess.entry.qry__status = 'done';
|
$journals_sess.entry.qry__status = 'done';
|
||||||
@@ -257,7 +274,7 @@ async function handle_search_refresh(params: any) {
|
|||||||
|
|
||||||
untrack(() => {
|
untrack(() => {
|
||||||
$journals_sess.entry_li = api_results;
|
$journals_sess.entry_li = api_results;
|
||||||
search_id_li = api_ids;
|
search_id_li = display_ids;
|
||||||
$journals_sess.entry.qry__status = 'done';
|
$journals_sess.entry.qry__status = 'done';
|
||||||
});
|
});
|
||||||
if (log_lvl)
|
if (log_lvl)
|
||||||
|
|||||||
@@ -84,26 +84,7 @@ let visible_journal_entry_obj_li = $derived(
|
|||||||
if (list === undefined || list === null) return null;
|
if (list === undefined || list === null) return null;
|
||||||
if (!Array.isArray(list)) return [];
|
if (!Array.isArray(list)) return [];
|
||||||
|
|
||||||
const filtered = list.filter((item: any) => {
|
const filtered = list.filter((item: any) => !!item);
|
||||||
if (!item) return false;
|
|
||||||
|
|
||||||
const is_hidden = item.hide === true || item.hide === 1;
|
|
||||||
const is_disabled = item.enable === false || item.enable === 0;
|
|
||||||
|
|
||||||
// Standard Visibility: Filter out hidden/disabled if not in Edit Mode
|
|
||||||
if (!$ae_loc.edit_mode) {
|
|
||||||
return !is_hidden && !is_disabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Edit Mode Gating:
|
|
||||||
// - To see Hidden: Must have Trusted Access or higher
|
|
||||||
if (is_hidden && !$ae_loc.trusted_access) return false;
|
|
||||||
|
|
||||||
// - To see Disabled: Must have Administrator Access or higher
|
|
||||||
if (is_disabled && !$ae_loc.administrator_access) return false;
|
|
||||||
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (log_lvl)
|
if (log_lvl)
|
||||||
console.log(
|
console.log(
|
||||||
@@ -125,7 +106,9 @@ let visible_journal_entry_obj_li = $derived(
|
|||||||
</div>
|
</div>
|
||||||
<section
|
<section
|
||||||
class="journal_list relative flex w-full flex-col items-center justify-center gap-1 md:gap-2">
|
class="journal_list relative flex w-full flex-col items-center justify-center gap-1 md:gap-2">
|
||||||
{#if visible_journal_entry_obj_li === null}
|
{#if visible_journal_entry_obj_li === null ||
|
||||||
|
($journals_sess.entry.qry__status === 'loading' &&
|
||||||
|
visible_journal_entry_obj_li.length === 0)}
|
||||||
<!-- Loading state -->
|
<!-- Loading state -->
|
||||||
<div
|
<div
|
||||||
class="flex flex-col items-center justify-center p-10 opacity-50">
|
class="flex flex-col items-center justify-center p-10 opacity-50">
|
||||||
|
|||||||
@@ -1,72 +1,33 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
interface JournalObjLike {
|
||||||
|
name?: string;
|
||||||
|
cfg_json?: {
|
||||||
|
category_li?: Array<{
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
log_lvl?: number;
|
log_lvl?: number;
|
||||||
lq__journal_obj: any;
|
lq__journal_obj: JournalObjLike | null | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { log_lvl = $bindable(0), lq__journal_obj }: Props = $props();
|
let { log_lvl = $bindable(0), lq__journal_obj }: Props = $props();
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ArrowDown01,
|
|
||||||
ArrowDown10,
|
|
||||||
ArrowDownUp,
|
|
||||||
BetweenVerticalEnd,
|
|
||||||
BetweenVerticalStart,
|
|
||||||
BookHeart,
|
|
||||||
BookImage,
|
|
||||||
Bookmark,
|
|
||||||
BookOpenText,
|
|
||||||
BriefcaseBusiness,
|
|
||||||
Check,
|
|
||||||
Copy,
|
|
||||||
Expand,
|
|
||||||
Eye,
|
|
||||||
EyeOff,
|
|
||||||
Flag,
|
|
||||||
FlagOff,
|
|
||||||
FilePlus,
|
|
||||||
Fingerprint,
|
|
||||||
Globe,
|
|
||||||
Library,
|
Library,
|
||||||
MessageSquareWarning,
|
RemoveFormatting
|
||||||
Minus,
|
|
||||||
Notebook,
|
|
||||||
Pencil,
|
|
||||||
Plus,
|
|
||||||
RemoveFormatting,
|
|
||||||
SquareLibrary,
|
|
||||||
Shapes,
|
|
||||||
Share2,
|
|
||||||
ShieldCheck,
|
|
||||||
ShieldMinus,
|
|
||||||
Siren,
|
|
||||||
Skull,
|
|
||||||
Tags,
|
|
||||||
Target,
|
|
||||||
ToggleLeft,
|
|
||||||
ToggleRight,
|
|
||||||
Trash2,
|
|
||||||
TypeOutline,
|
|
||||||
X
|
|
||||||
} from '@lucide/svelte';
|
} from '@lucide/svelte';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ae_snip,
|
|
||||||
ae_loc,
|
ae_loc,
|
||||||
ae_sess,
|
|
||||||
ae_api,
|
|
||||||
ae_trig,
|
|
||||||
slct,
|
|
||||||
slct_trigger
|
|
||||||
} from '$lib/stores/ae_stores';
|
} from '$lib/stores/ae_stores';
|
||||||
import {
|
import {
|
||||||
journals_loc,
|
journals_loc,
|
||||||
journals_sess,
|
journals_sess
|
||||||
journals_slct,
|
|
||||||
journals_prom,
|
|
||||||
journals_trig
|
|
||||||
} from '$lib/ae_journals/ae_journals_stores';
|
} from '$lib/ae_journals/ae_journals_stores';
|
||||||
import { journals_func } from '$lib/ae_journals/ae_journals_functions';
|
|
||||||
|
|
||||||
// *** Functions and Logic
|
// *** Functions and Logic
|
||||||
function handle_search_trigger() {
|
function handle_search_trigger() {
|
||||||
@@ -102,10 +63,10 @@ function prevent_default<T extends Event>(fn: (event: T) => void) {
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="Search Journal Entries"
|
placeholder="Search Journal Entries"
|
||||||
bind:value={$journals_loc.entry.qry__search_text}
|
bind:value={$journals_loc.entry.qry__search_text}
|
||||||
onkeyup={(event) => {
|
onkeyup={() => {
|
||||||
// Reactive effect in parent handles this debounced
|
// Reactive effect in parent handles this debounced
|
||||||
}}
|
}}
|
||||||
title={`Search for Entries in "${$lq__journal_obj?.name}. Press Enter to search.`}
|
title={`Search for Entries in "${lq__journal_obj?.name}. Press Enter to search.`}
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
class="
|
class="
|
||||||
input input-sm
|
input input-sm
|
||||||
@@ -153,17 +114,47 @@ function prevent_default<T extends Event>(fn: (event: T) => void) {
|
|||||||
<select
|
<select
|
||||||
class="select select-sm"
|
class="select select-sm"
|
||||||
bind:value={$journals_loc.entry.qry__category_code}
|
bind:value={$journals_loc.entry.qry__category_code}
|
||||||
onchange={(event) => {
|
onchange={() => {
|
||||||
handle_search_trigger();
|
handle_search_trigger();
|
||||||
}}
|
}}
|
||||||
title="Filter by category">
|
title="Filter by category">
|
||||||
<option value="">All Categories</option>
|
<option value="">All Categories</option>
|
||||||
{#each $lq__journal_obj?.cfg_json?.category_li as category (category.code)}
|
{#each lq__journal_obj?.cfg_json?.category_li as category (category.code)}
|
||||||
<option value={category.code}>{category.name}</option>
|
<option value={category.code}>{category.name}</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
<span class="flex flex-row flex-wrap items-center gap-2">
|
||||||
|
<span class="hidden text-sm text-gray-500 lg:inline"> Filters: </span>
|
||||||
|
|
||||||
|
<label class="flex flex-row items-center gap-1 text-xs font-semibold text-gray-500">
|
||||||
|
<span>Enabled</span>
|
||||||
|
<select
|
||||||
|
class="select select-sm"
|
||||||
|
bind:value={$journals_loc.entry.qry__enabled}
|
||||||
|
onchange={handle_search_trigger}
|
||||||
|
title="Filter by enabled status">
|
||||||
|
<option value="enabled">Enabled Only</option>
|
||||||
|
<option value="not_enabled">Disabled Only</option>
|
||||||
|
<option value="all">All</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex flex-row items-center gap-1 text-xs font-semibold text-gray-500">
|
||||||
|
<span>Hidden</span>
|
||||||
|
<select
|
||||||
|
class="select select-sm"
|
||||||
|
bind:value={$journals_loc.entry.qry__hidden}
|
||||||
|
onchange={handle_search_trigger}
|
||||||
|
title="Filter by hidden status">
|
||||||
|
<option value="not_hidden">Visible Only</option>
|
||||||
|
<option value="hidden">Hidden Only</option>
|
||||||
|
<option value="all">All</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</span>
|
||||||
|
|
||||||
<!-- Search Control Toggles -->
|
<!-- Search Control Toggles -->
|
||||||
<span
|
<span
|
||||||
class="border-surface-300-700 flex flex-row flex-wrap items-center gap-2 border-l pl-2">
|
class="border-surface-300-700 flex flex-row flex-wrap items-center gap-2 border-l pl-2">
|
||||||
|
|||||||
@@ -1,80 +1,110 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
// Simulating the filter logic from ae_comp__journal_entry_obj_li.svelte
|
import {
|
||||||
function filterEntries(list: any[], ae_loc: any) {
|
journal_entry_filter_list,
|
||||||
if (!list) return null;
|
journal_entry_matches_search
|
||||||
return list.filter((item: any) => {
|
} from '$lib/ae_journals/ae_journals_search_helpers';
|
||||||
if (!item) return false;
|
|
||||||
|
|
||||||
const is_hidden = item.hide === true || item.hide === 1;
|
describe('Journal Entry Search Filtering', () => {
|
||||||
const is_disabled = item.enable === false || item.enable === 0;
|
|
||||||
|
|
||||||
// Standard Visibility: Filter out hidden/disabled if not in Edit Mode
|
|
||||||
if (!ae_loc.edit_mode) {
|
|
||||||
return !is_hidden && !is_disabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Edit Mode Gating:
|
|
||||||
// - To see Hidden: Must have Trusted Access or higher
|
|
||||||
if (is_hidden && !ae_loc.trusted_access) return false;
|
|
||||||
|
|
||||||
// - To see Disabled: Must have Administrator Access or higher
|
|
||||||
if (is_disabled && !ae_loc.administrator_access) return false;
|
|
||||||
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('Journal Entry Visibility Filtering', () => {
|
|
||||||
const mockEntries = [
|
const mockEntries = [
|
||||||
{ id: '1', name: 'Normal Entry', hide: false, enable: true },
|
{
|
||||||
{ id: '2', name: 'Hidden Entry', hide: true, enable: true },
|
id: '4',
|
||||||
{ id: '3', name: 'Disabled Entry', hide: false, enable: false },
|
journal_entry_id: '4',
|
||||||
{ id: '4', name: 'Hidden & Disabled', hide: true, enable: false }
|
name: 'Hidden & Disabled',
|
||||||
|
hide: true,
|
||||||
|
enable: false,
|
||||||
|
tmp_sort_1: 'd'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
journal_entry_id: '3',
|
||||||
|
name: 'Disabled Entry',
|
||||||
|
hide: false,
|
||||||
|
enable: false,
|
||||||
|
tmp_sort_1: 'c'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
journal_entry_id: '2',
|
||||||
|
name: 'Hidden Entry',
|
||||||
|
hide: true,
|
||||||
|
enable: true,
|
||||||
|
tmp_sort_1: 'b'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
journal_entry_id: '1',
|
||||||
|
name: 'Normal Entry',
|
||||||
|
summary: 'Alpha notes',
|
||||||
|
content: 'Beta details',
|
||||||
|
category_code: 'general',
|
||||||
|
hide: false,
|
||||||
|
enable: true,
|
||||||
|
tmp_sort_1: 'a'
|
||||||
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
it('should show only normal entries when Edit Mode is OFF (Manager)', () => {
|
it('should return all entries when filters are broad', () => {
|
||||||
const ae_loc = {
|
const result = journal_entry_filter_list(mockEntries, {
|
||||||
edit_mode: false,
|
str: '',
|
||||||
trusted_access: true,
|
cat: '',
|
||||||
administrator_access: true
|
enabled: 'all',
|
||||||
};
|
hidden: 'all',
|
||||||
const result = filterEntries(mockEntries, ae_loc);
|
limit: 2
|
||||||
expect(result?.length).toBe(1);
|
});
|
||||||
expect(result?.[0].id).toBe('1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should show hidden entries to Trusted users when Edit Mode is ON', () => {
|
|
||||||
const ae_loc = {
|
|
||||||
edit_mode: true,
|
|
||||||
trusted_access: true,
|
|
||||||
administrator_access: false
|
|
||||||
};
|
|
||||||
const result = filterEntries(mockEntries, ae_loc);
|
|
||||||
// Should see Normal (1) and Hidden (2). Should NOT see Disabled (3, 4)
|
|
||||||
expect(result?.length).toBe(2);
|
|
||||||
expect(result?.map((r) => r.id)).toContain('1');
|
|
||||||
expect(result?.map((r) => r.id)).toContain('2');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should show everything to Administrators when Edit Mode is ON', () => {
|
|
||||||
const ae_loc = {
|
|
||||||
edit_mode: true,
|
|
||||||
trusted_access: true,
|
|
||||||
administrator_access: true
|
|
||||||
};
|
|
||||||
const result = filterEntries(mockEntries, ae_loc);
|
|
||||||
expect(result?.length).toBe(4);
|
expect(result?.length).toBe(4);
|
||||||
|
expect(result?.map((entry) => entry.id)).toEqual([
|
||||||
|
'4',
|
||||||
|
'3',
|
||||||
|
'2',
|
||||||
|
'1'
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should hide everything sensitive to Public users even if Edit Mode is ON (Safety Check)', () => {
|
it('should filter by enabled and hidden status', () => {
|
||||||
const ae_loc = {
|
const result = journal_entry_filter_list(mockEntries, {
|
||||||
edit_mode: true,
|
str: '',
|
||||||
trusted_access: false,
|
cat: '',
|
||||||
administrator_access: false
|
enabled: 'enabled',
|
||||||
};
|
hidden: 'not_hidden',
|
||||||
const result = filterEntries(mockEntries, ae_loc);
|
limit: 50
|
||||||
|
});
|
||||||
|
|
||||||
expect(result?.length).toBe(1);
|
expect(result?.length).toBe(1);
|
||||||
expect(result?.[0].id).toBe('1');
|
expect(result?.[0].id).toBe('1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should match text across summary and content', () => {
|
||||||
|
expect(
|
||||||
|
journal_entry_matches_search(mockEntries[3], {
|
||||||
|
str: 'alpha',
|
||||||
|
cat: '',
|
||||||
|
enabled: 'all',
|
||||||
|
hidden: 'all'
|
||||||
|
})
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
journal_entry_matches_search(mockEntries[3], {
|
||||||
|
str: 'beta',
|
||||||
|
cat: '',
|
||||||
|
enabled: 'all',
|
||||||
|
hidden: 'all'
|
||||||
|
})
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should limit text-filtered results after sorting', () => {
|
||||||
|
const result = journal_entry_filter_list(mockEntries, {
|
||||||
|
str: 'entry',
|
||||||
|
cat: '',
|
||||||
|
enabled: 'all',
|
||||||
|
hidden: 'all',
|
||||||
|
limit: 2
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result?.length).toBe(2);
|
||||||
|
expect(result?.map((entry) => entry.id)).toEqual(['3', '2']);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { defineConfig } from 'vitest/config';
|
import { defineConfig, mergeConfig } from 'vitest/config';
|
||||||
|
import viteConfig from './vite.config';
|
||||||
|
|
||||||
export default defineConfig({
|
export default mergeConfig(
|
||||||
test: {
|
viteConfig,
|
||||||
include: ['src/**/*.{test,spec}.ts'],
|
defineConfig({
|
||||||
exclude: ['node_modules', 'tests']
|
test: {
|
||||||
}
|
include: ['src/**/*.{test,spec}.ts'],
|
||||||
});
|
exclude: ['node_modules', 'tests']
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user