diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1d938fd..e8d4026 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -142,6 +142,14 @@ jobs: LAMBDA_ERP_TEST_DB: postgresql://postgres:postgres@localhost:5432/lambda_test run: python -m tests.test_adjacent + - name: List search — SQLite (temp file) + run: python -m tests.test_search + + - name: List search — PostgreSQL + env: + LAMBDA_ERP_TEST_DB: postgresql://postgres:postgres@localhost:5432/lambda_test + run: python -m tests.test_search + - name: Transaction release (no idle-in-transaction) — PostgreSQL env: LAMBDA_ERP_TEST_DB: postgresql://postgres:postgres@localhost:5432/lambda_test diff --git a/CHANGELOG.md b/CHANGELOG.md index 298b23a..8d86821 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,22 @@ semver-governed public surface — a breaking change to a seam is a major bump. ## [Unreleased] +## [0.6.8] - 2026-07-29 + +### Added +- **Free-text search on the document list.** A doctype config may declare + `searchFields: string[]` — columns matched case-insensitively (substring) + by a **debounced** search box above the list (commits to the URL `q` after a + pause, so it doesn't refetch on every keystroke). The list/count/adjacent + endpoints take `search` + `search_fields` (a CSV validated to real columns); + search is ANDed with the other filters, and prev/next (`DocPager`) steps + through the searched result set too. Omit `searchFields` for no search box. +- **`register_search_expansion(doctype_slug, fn)` seam.** Lets a plugin match a + doctype by a **related table the core doesn't know** — `fn(query, db)` returns + the PK names to also include, OR-ed into the search (`name IN (...)`). E.g. the + internal CRM finds a Lead by one of its Contacts. `tests/test_search.py` + covers same-table search, the expansion seam, count parity, and adjacent. + ## [0.6.7] - 2026-07-29 ### Fixed diff --git a/api/routers/documents.py b/api/routers/documents.py index d1e4889..f83c366 100644 --- a/api/routers/documents.py +++ b/api/routers/documents.py @@ -28,7 +28,7 @@ _LIST_RESERVED = { "status", "party", "from_date", "to_date", "docstatus", "include_discarded", "limit", "offset", "order_by", "order", - "date_field", + "date_field", "search", "search_fields", } @@ -45,6 +45,8 @@ def list_docs( order_by: str | None = None, order: str = "desc", date_field: str | None = None, + search: str | None = None, + search_fields: str | None = None, limit: int = Query(default=50, le=500), offset: int = Query(default=0, ge=0), _user: dict = _viewer, @@ -80,6 +82,18 @@ def list_docs( if date_field: filters["date_field"] = date_field + # Free-text search across the doctype's declared search_fields (+ any + # registered related-table expansion). search_fields is a CSV validated to + # real columns, mirroring the arbitrary-filter / order_by contract. + if search: + filters["search"] = search + fields = [f.strip() for f in (search_fields or "").split(",") if f.strip()] + bad = [f for f in fields if f not in columns] + if bad: + raise HTTPException(status_code=400, detail=f"Unknown search field(s): {', '.join(bad)}") + if fields: + filters["search_fields"] = fields + if order_by is not None and order_by not in columns: raise HTTPException(status_code=400, detail=f"Unknown order_by field: {order_by}") if order.lower() not in ("asc", "desc"): @@ -105,6 +119,8 @@ def adjacent_doc( order_by: str | None = None, order: str = "desc", date_field: str | None = None, + search: str | None = None, + search_fields: str | None = None, _user: dict = _viewer, ): """Prev/next record around `name` in the same order+filters the list uses. @@ -130,6 +146,14 @@ def adjacent_doc( filters[key] = value if date_field: filters["date_field"] = date_field + if search: + filters["search"] = search + fields = [f.strip() for f in (search_fields or "").split(",") if f.strip()] + bad = [f for f in fields if f not in columns] + if bad: + raise HTTPException(status_code=400, detail=f"Unknown search field(s): {', '.join(bad)}") + if fields: + filters["search_fields"] = fields if order_by is not None and order_by not in columns: raise HTTPException(status_code=400, detail=f"Unknown order_by field: {order_by}") if order.lower() not in ("asc", "desc"): diff --git a/api/services.py b/api/services.py index e1f2776..fff14ae 100644 --- a/api/services.py +++ b/api/services.py @@ -403,6 +403,57 @@ def _resolve_date_field(db, doctype: str, override: str | None) -> str | None: return DATE_FIELDS.get(doctype) +# --- List search seam --- +# +# The generic list can free-text search across a doctype's own columns (the +# frontend passes `search` + the config's `search_fields`). A plugin doctype +# often also wants to match on a *related* table the core knows nothing about +# (e.g. find a Lead by one of its Contacts). register_search_expansion lets it: +# fn(query, db) returns the primary-key names to also include, and the core ORs +# `name IN (...)` into the search clause. See docs/core-extension-architecture.md. + +_SEARCH_EXPANSIONS: dict[str, list] = {} # slug -> [fn(query: str, db) -> Iterable[str]] + + +def register_search_expansion(doctype_slug: str, fn) -> None: + """Register a related-table search for a doctype's list. `fn(query, db)` + returns an iterable of this doctype's PK names whose related records match + `query` (e.g. Lead names that have a matching Contact). Those names are + OR-ed into the list/count/adjacent search alongside the same-table column + matches. Keep it bounded and indexed — it runs on every search keystroke.""" + _SEARCH_EXPANSIONS.setdefault(doctype_slug, []).append(fn) + + +def _search_clause(db, doctype: str, doctype_slug: str, search, search_fields): + """A parenthesized OR-group matching `search` (case-insensitive substring) + across `search_fields` (kept to real columns) plus any PK names a registered + search expansion returns. Returns (clause, params), or (None, []) when + there's nothing to match on. Same shape for list/count/adjacent so prev/next + tracks the searched list exactly.""" + if not search: + return None, [] + cols = db._get_table_columns(doctype) + like = f"%{search}%" + parts, params = [], [] + for f in (search_fields or []): + if f in cols: + parts.append(f'LOWER("{f}") LIKE LOWER(?)') + params.append(like) + extra = [] + seen = set() + for fn in _SEARCH_EXPANSIONS.get(doctype_slug, []): + for pk in (fn(search, db) or []): + if pk and pk not in seen: + seen.add(pk) + extra.append(pk) + if extra: + parts.append(f'name IN ({",".join("?" for _ in extra)})') + params.extend(extra) + if not parts: + return None, [] + return "(" + " OR ".join(parts) + ")", params + + def _exclude_discarded(db, doctype: str, db_filters: dict, include_discarded: bool) -> None: """Hide voided drafts (discarded=1) unless explicitly requested. Guarded on the column existing so non-submittable doctypes are unaffected.""" @@ -433,6 +484,8 @@ def list_documents(doctype_slug: str, filters: dict = None, limit: int = 50, off from_date = None to_date = None date_field_override = None + search = None + search_fields = None if filters: for key, value in filters.items(): if value is None or value == "": @@ -443,6 +496,10 @@ def list_documents(doctype_slug: str, filters: dict = None, limit: int = 50, off to_date = value elif key == "date_field": date_field_override = value + elif key == "search": + search = value + elif key == "search_fields": + search_fields = value else: db_filters[key] = value @@ -461,18 +518,23 @@ def list_documents(doctype_slug: str, filters: dict = None, limit: int = 50, off else: db_filters[date_field] = ("<=", to_date) + search_where, search_params = _search_clause(db, doctype, doctype_slug, search, search_fields) + # If both from and to are set, the dict can only hold one constraint per key # Fall through to a raw SQL query for that case if date_field and from_date and to_date: return _list_with_date_range( - db, doctype, doctype_slug, db_filters, date_field, from_date, to_date, limit, offset + db, doctype, doctype_slug, db_filters, date_field, from_date, to_date, limit, offset, + extra_where=search_where, extra_params=search_params, ) order_clause = _order_clause(order_by, order) - # get_all doesn't support offset, so use raw SQL when needed - if offset: - return _list_with_offset(db, doctype, doctype_slug, db_filters, limit, offset, order_clause) + # get_all can't express OFFSET or a free-text OR-group, so drop to raw SQL + # whenever either is in play. + if offset or search_where: + return _list_with_offset(db, doctype, doctype_slug, db_filters, limit, offset, order_clause, + extra_where=search_where, extra_params=search_params) rows = db.get_all( doctype, @@ -496,6 +558,8 @@ def count_documents(doctype_slug: str, filters: dict = None, include_discarded: from_date = None to_date = None date_field_override = None + search = None + search_fields = None if filters: for key, value in filters.items(): if value is None or value == "": @@ -506,6 +570,10 @@ def count_documents(doctype_slug: str, filters: dict = None, include_discarded: to_date = value elif key == "date_field": date_field_override = value + elif key == "search": + search = value + elif key == "search_fields": + search_fields = value else: db_filters[key] = value @@ -529,6 +597,11 @@ def count_documents(doctype_slug: str, filters: dict = None, include_discarded: where_parts.append(f'"{k}" = ?') params.append(v) + search_where, search_params = _search_clause(db, doctype, doctype_slug, search, search_fields) + if search_where: + where_parts.append(search_where) + params.extend(search_params) + query = f'SELECT COUNT(*) as c FROM "{doctype}"' if where_parts: query += " WHERE " + " AND ".join(where_parts) @@ -536,12 +609,13 @@ def count_documents(doctype_slug: str, filters: dict = None, include_discarded: return int(rows[0]["c"]) if rows else 0 -def _filter_where(db, doctype: str, filters: dict, include_discarded: bool): +def _filter_where(db, doctype: str, doctype_slug: str, filters: dict, include_discarded: bool): """Build (where_parts, params) for a doctype from a list-style `filters` dict - (equality filters + from_date/to_date on the doctype's date field + discard - exclusion). Same semantics as list_documents/count_documents so prev/next - matches exactly what the list shows.""" + (equality filters + from_date/to_date on the doctype's date field + free-text + search + discard exclusion). Same semantics as list_documents/count_documents + so prev/next matches exactly what the list shows.""" db_filters, from_date, to_date, date_field_override = {}, None, None, None + search = search_fields = None for key, value in (filters or {}).items(): if value is None or value == "": continue @@ -551,6 +625,10 @@ def _filter_where(db, doctype: str, filters: dict, include_discarded: bool): to_date = value elif key == "date_field": date_field_override = value + elif key == "search": + search = value + elif key == "search_fields": + search_fields = value else: db_filters[key] = value _exclude_discarded(db, doctype, db_filters, include_discarded) @@ -567,6 +645,10 @@ def _filter_where(db, doctype: str, filters: dict, include_discarded: bool): where_parts.append(f'"{k}" {op} ?'); params.append(val) else: where_parts.append(f'"{k}" = ?'); params.append(v) + search_where, search_params = _search_clause(db, doctype, doctype_slug, search, search_fields) + if search_where: + where_parts.append(search_where) + params.extend(search_params) return where_parts, params @@ -591,7 +673,7 @@ def adjacent_documents(doctype_slug: str, name: str, filters: dict = None, return {"prev": None, "next": None} cur_oc = cur[0]["oc"] - base_where, base_params = _filter_where(db, doctype, filters or {}, include_discarded) + base_where, base_params = _filter_where(db, doctype, doctype_slug, filters or {}, include_discarded) def neighbor(direction): # DESC list: next = tuple < current, prev = tuple > current (ASC flips). @@ -608,7 +690,8 @@ def neighbor(direction): return {"prev": neighbor("prev"), "next": neighbor("next")} -def _list_with_offset(db, doctype, doctype_slug, db_filters, limit, offset, order_clause="creation DESC"): +def _list_with_offset(db, doctype, doctype_slug, db_filters, limit, offset, order_clause="creation DESC", + extra_where=None, extra_params=None): where_parts = [] params = [] for k, v in db_filters.items(): @@ -619,6 +702,9 @@ def _list_with_offset(db, doctype, doctype_slug, db_filters, limit, offset, orde else: where_parts.append(f'"{k}" = ?') params.append(v) + if extra_where: + where_parts.append(extra_where) + params.extend(extra_params or []) query = f'SELECT * FROM "{doctype}"' if where_parts: query += " WHERE " + " AND ".join(where_parts) @@ -649,7 +735,8 @@ def _attach_children(db, doctype_slug: str, rows: list) -> list: return result -def _list_with_date_range(db, doctype, doctype_slug, extra_filters, date_field, from_date, to_date, limit, offset=0): +def _list_with_date_range(db, doctype, doctype_slug, extra_filters, date_field, from_date, to_date, limit, + offset=0, extra_where=None, extra_params=None): """List documents when both from_date and to_date are set.""" where_parts = [f'"{date_field}" >= ?', f'"{date_field}" <= ?'] params = [from_date, to_date] @@ -661,6 +748,9 @@ def _list_with_date_range(db, doctype, doctype_slug, extra_filters, date_field, else: where_parts.append(f'"{k}" = ?') params.append(v) + if extra_where: + where_parts.append(extra_where) + params.extend(extra_params or []) query = ( f'SELECT * FROM "{doctype}" WHERE ' + " AND ".join(where_parts) + " ORDER BY creation DESC" diff --git a/frontend/package-lock.json b/frontend/package-lock.json index dc6d0d4..665c0c2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "@lambda-development/erp-core", - "version": "0.6.7", + "version": "0.6.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@lambda-development/erp-core", - "version": "0.6.7", + "version": "0.6.8", "license": "Apache-2.0", "dependencies": { "@fontsource/inter": "^5.2.8" diff --git a/frontend/package.json b/frontend/package.json index 350cc13..e9927e1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@lambda-development/erp-core", - "version": "0.6.7", + "version": "0.6.8", "description": "Frontend core of Lambda ERP — app shell, document/master pages, chat UI, reports, and extension registries.", "license": "Apache-2.0", "repository": { diff --git a/frontend/src/lib/doctypes.ts b/frontend/src/lib/doctypes.ts index ad72d80..ac88378 100644 --- a/frontend/src/lib/doctypes.ts +++ b/frontend/src/lib/doctypes.ts @@ -53,6 +53,12 @@ export interface DoctypeConfig { // the right call for non-submittable doctypes whose `status` is a business // field, not a docstatus. Omit to keep the default status filter. listFilters?: string[]; + // Column names the list's free-text search box matches against (case- + // insensitive substring), e.g. ["company_name", "uid", "town"]. When set, the + // list shows a debounced search box; the backend can also match via a + // registered related-table expansion (register_search_expansion). Omit for no + // search box. + searchFields?: string[]; canSubmit: boolean; canCancel: boolean; conversions: ConversionDef[]; diff --git a/frontend/src/pages/documents/document-list.tsx b/frontend/src/pages/documents/document-list.tsx index 5e76971..62d4a5d 100644 --- a/frontend/src/pages/documents/document-list.tsx +++ b/frontend/src/pages/documents/document-list.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Link, useParams, useLocation } from "react-router-dom"; import { setListContext } from "@/lib/doc-list-context"; import { useTranslation } from "react-i18next"; @@ -93,6 +93,12 @@ export default function DocumentListPage() { const [toDate] = useUrlState("to", ""); const [showDiscarded] = useUrlState("discarded", ""); const [pageSize] = useUrlState("per_page", 50); + // Free-text search (opt-in via config.searchFields). The committed query lives + // in the URL (`q`); a local input debounces into it so we don't refetch on + // every keystroke. + const searchFields = config?.searchFields ?? []; + const [urlQ] = useUrlState("q", ""); + const [searchInput, setSearchInput] = useState(urlQ); // URL is human-friendly 1-indexed; internal state stays 0-indexed for // offset calculation. setPage accepts the 0-indexed value. const [urlPage] = useUrlState("page", 1); @@ -109,6 +115,15 @@ export default function DocumentListPage() { const setShowDiscarded = (on: boolean) => patchUrl({ discarded: on ? "1" : null, page: null }); const setPageSize = (n: number) => patchUrl({ per_page: n, page: null }); + // Keep the box in sync when `q` changes from outside typing (back/forward, a + // cleared filter), then debounce local edits back into the URL after a pause. + useEffect(() => { setSearchInput(urlQ); }, [urlQ]); + useEffect(() => { + if (searchInput === urlQ) return; // idle — nothing to commit, no timer + const t = setTimeout(() => patchUrl({ q: searchInput || null, page: null }), 300); + return () => clearTimeout(t); + }, [searchInput, urlQ]); // eslint-disable-line react-hooks/exhaustive-deps + const filters = useMemo(() => { const f: Record = {}; // The default status dropdown is hidden when a doctype opts into its own @@ -123,11 +138,16 @@ export default function DocumentListPage() { for (const key of configFilters) { if (filterValues[key]) f[key] = filterValues[key]; } + // Free-text search across the config's searchFields (the committed URL `q`). + if (searchFields.length > 0 && urlQ) { + f.search = urlQ; + f.search_fields = searchFields.join(","); + } if (showDiscarded) f.include_discarded = "true"; f.limit = pageSize; f.offset = page * pageSize; return f; - }, [status, fromDate, toDate, showDiscarded, pageSize, page, config?.dateField, configFilters, filterValues]); + }, [status, fromDate, toDate, showDiscarded, pageSize, page, config?.dateField, configFilters, filterValues, searchFields, urlQ]); const { data, isLoading } = useDocumentList(doctype ?? "", filters); const rows = data?.rows ?? []; @@ -266,6 +286,16 @@ export default function DocumentListPage() {
+ {searchFields.length > 0 && ( + setSearchInput(e.target.value)} + className="min-w-[16rem]" + /> + )} {configFilters.length === 0 ? (