Compare commits
97 Commits
1c9fb474df
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1b0cccacf8 | |||
|
2b450c6d47
|
|||
|
9d02121c5b
|
|||
|
d2bf6efdb4
|
|||
|
227b1f674d
|
|||
| 017e3a61a8 | |||
| 2c699eb976 | |||
| f19d24ee98 | |||
| 263299ca52 | |||
| 0b7ddc260f | |||
| d9a8835696 | |||
| 029c65da79 | |||
| 008d92d433 | |||
|
9e17b94516
|
|||
|
507353bb48
|
|||
|
a9e148701d
|
|||
| c3de90e805 | |||
|
1d2dfd23af
|
|||
|
395f6e8dea
|
|||
|
abfdd03c6e
|
|||
|
e15b197623
|
|||
|
e12c667572
|
|||
| 874d3e236e | |||
|
f036a246a8
|
|||
|
7751c29529
|
|||
|
5f411b8ae9
|
|||
|
3fb9aa9f84
|
|||
|
138136e285
|
|||
|
2364d868fa
|
|||
|
ce976e8f2e
|
|||
|
c7af814364
|
|||
|
1258c529d2
|
|||
|
48644037f6
|
|||
|
04552aa8f6
|
|||
|
0f0dfc48fb
|
|||
|
763c00c50e
|
|||
|
5fd82c78d4
|
|||
| 58008d6f2c | |||
|
3ff3eed164
|
|||
|
7d46cc24b9
|
|||
|
3f95692746
|
|||
|
0527412265
|
|||
|
0c6c536d07
|
|||
|
544da26a9d
|
|||
|
7104605c06
|
|||
|
9c42d85f52
|
|||
|
bec7a1074c
|
|||
|
022d43a5a5
|
|||
|
1c5bff8651
|
|||
|
925cf007f4
|
|||
| 2d3ae4e04f | |||
| 0819ddb87d | |||
| 4031657bb5 | |||
| f673f3ac80 | |||
| e7db7eb0e8 | |||
| b68a131bae | |||
| 88cf374f33 | |||
| be919c992d | |||
| 0fa860c237 | |||
| 15a97dee9a | |||
| 1822ea8b51 | |||
| f32a88b47d | |||
| 6dfd6c83c9 | |||
| 19f1cdd197 | |||
| b5546ed828 | |||
| 9cb911401a | |||
| 2190b9d590 | |||
| 0c109cf2a1 | |||
| e8a49df2cf | |||
| 3c7ccbdd2b | |||
| 1322e6e71c | |||
| 58b274a452 | |||
| e309ff1b30 | |||
| 35d314768f | |||
| f9032eef9e | |||
| 99af73781b | |||
| 79d1be2852 | |||
| ebfc9aebfc | |||
| 03adcf99a7 | |||
| b1a4da2704 | |||
| 3ce6da708f | |||
| ab079cb447 | |||
| c2996fd91b | |||
| 22c688bd9a | |||
| 4e77934d06 | |||
| b8d807d302 | |||
| 67b40255ed | |||
| eda9d39cdc | |||
| 3a5b6e2d51 | |||
|
e45be806fc
|
|||
|
83aefcb849
|
|||
|
c7c196a054
|
|||
|
c639196266
|
|||
|
ed086c9702
|
|||
|
6f4841eaaa
|
|||
|
5c9bf45c61
|
|||
|
bd228365ed
|
@@ -19,3 +19,6 @@ DATA_DIR=/home/timetracker/app/data
|
||||
|
||||
# CSRF trusted origins
|
||||
CSRF_TRUSTED_ORIGINS=https://tracker.kucharczyk.xyz
|
||||
|
||||
# Create a default admin/admin superuser on startup (for initial setup only)
|
||||
CREATE_DEFAULT_SUPERUSER=false
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
name: Django CI/CD
|
||||
|
||||
on:
|
||||
push:
|
||||
paths-ignore: [ 'README.md' ]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
with:
|
||||
enable-cache: false
|
||||
python-version: "3.14"
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --frozen
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- name: Install pnpm and JS dependencies
|
||||
run: npm install -g pnpm && pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
- name: Build TypeScript
|
||||
run: make ts
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: uv run playwright install --with-deps chromium
|
||||
|
||||
- name: Run Migrations
|
||||
run: uv run python manage.py migrate
|
||||
|
||||
- name: Run Tests
|
||||
run: uv run --with pytest-django pytest
|
||||
|
||||
build-and-push:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set Version
|
||||
run: echo "VERSION_NUMBER=1.7.0" >> $GITHUB_ENV
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: |
|
||||
registry.kucharczyk.xyz/timetracker:latest
|
||||
registry.kucharczyk.xyz/timetracker:${{ env.VERSION_NUMBER }}
|
||||
@@ -0,0 +1,86 @@
|
||||
name: Staging deployment
|
||||
|
||||
on:
|
||||
push:
|
||||
branches-ignore: [main]
|
||||
delete:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
if: github.event_name == 'push'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
BRANCH: ${{ github.ref_name }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Compute staging name
|
||||
run: |
|
||||
SLUG=$(echo "$BRANCH" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9-]+/-/g; s/-+/-/g; s/^-//; s/-$//' | cut -c1-40)
|
||||
echo "SLUG=${SLUG}" >> "$GITHUB_ENV"
|
||||
echo "HOST=tracker-${SLUG}.home.arpa" >> "$GITHUB_ENV"
|
||||
# Per-staging secret so each instance has its own key, decoupling it
|
||||
# from prod even though the database is seeded from a prod snapshot.
|
||||
echo "STAGING_SECRET_KEY=staging-${SLUG}-$(head -c16 /dev/urandom | base64 | tr -dc 'a-zA-Z0-9')" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build image
|
||||
run: docker build -t "timetracker:staging-${SLUG}" .
|
||||
|
||||
- name: Deploy staging container
|
||||
run: |
|
||||
docker rm -f "timetracker-staging-${SLUG}" 2>/dev/null || true
|
||||
docker run -d --name "timetracker-staging-${SLUG}" \
|
||||
--network docker-compose-templates_public \
|
||||
-e TZ=Europe/Prague \
|
||||
-e PUID=1000 \
|
||||
-e PGID=100 \
|
||||
-e DATA_DIR=/home/timetracker/app/data \
|
||||
-e STAGING=true \
|
||||
-e "SECRET_KEY=${STAGING_SECRET_KEY}" \
|
||||
-e "CSRF_TRUSTED_ORIGINS=https://${HOST}" \
|
||||
-v "timetracker-staging-${SLUG}:/home/timetracker/app/data" \
|
||||
-l "caddy=${HOST}" \
|
||||
-l 'caddy.reverse_proxy={{ upstreams 8000 }}' \
|
||||
-l xyz.kucharczyk.staging=timetracker \
|
||||
-l "xyz.kucharczyk.staging.branch=${BRANCH}" \
|
||||
--restart unless-stopped \
|
||||
"timetracker:staging-${SLUG}"
|
||||
|
||||
- name: Summary
|
||||
run: echo "Deployed to https://${HOST}" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Comment staging URL on PR
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
auth="Authorization: token ${GITHUB_TOKEN}"
|
||||
api="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}"
|
||||
pr=$(curl -fsS -H "$auth" "${api}/pulls?state=open&limit=50" \
|
||||
| jq -r --arg branch "$BRANCH" '.[] | select(.head.ref == $branch) | .number' | head -n1)
|
||||
if [ -z "$pr" ]; then
|
||||
echo "No open PR for branch '${BRANCH}', skipping comment"
|
||||
exit 0
|
||||
fi
|
||||
body="Staging deployment: https://${HOST}"
|
||||
if curl -fsS -H "$auth" "${api}/issues/${pr}/comments" \
|
||||
| jq -e --arg body "$body" 'any(.[]; .body == $body)' >/dev/null; then
|
||||
echo "Staging URL already commented on PR #${pr}"
|
||||
exit 0
|
||||
fi
|
||||
curl -fsS -X POST -H "$auth" -H 'Content-Type: application/json' \
|
||||
-d "$(jq -n --arg body "$body" '{body: $body}')" \
|
||||
"${api}/issues/${pr}/comments" >/dev/null
|
||||
echo "Commented staging URL on PR #${pr}"
|
||||
|
||||
teardown:
|
||||
if: github.event_name == 'delete' && github.event.ref_type == 'branch'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
BRANCH: ${{ github.event.ref }}
|
||||
steps:
|
||||
- name: Remove staging container, volume, and image
|
||||
run: |
|
||||
SLUG=$(echo "$BRANCH" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9-]+/-/g; s/-+/-/g; s/^-//; s/-$//' | cut -c1-40)
|
||||
docker rm -f "timetracker-staging-${SLUG}" 2>/dev/null || true
|
||||
docker volume rm "timetracker-staging-${SLUG}" 2>/dev/null || true
|
||||
docker rmi "timetracker:staging-${SLUG}" 2>/dev/null || true
|
||||
@@ -19,6 +19,20 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: uv sync --frozen
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- name: Install pnpm and JS dependencies
|
||||
run: npm install -g pnpm && pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
- name: Build TypeScript
|
||||
run: make ts
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: uv run playwright install --with-deps chromium
|
||||
|
||||
- name: Run Migrations
|
||||
run: uv run python manage.py migrate
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
name: Staging deployment
|
||||
|
||||
on:
|
||||
push:
|
||||
branches-ignore: [main]
|
||||
delete:
|
||||
|
||||
concurrency:
|
||||
group: staging-${{ github.event.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
if: github.event_name == 'push'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
env:
|
||||
BRANCH: ${{ github.ref_name }}
|
||||
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Compute staging name
|
||||
run: |
|
||||
SLUG=$(echo "$BRANCH" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9-]+/-/g; s/-+/-/g; s/^-//; s/-$//' | cut -c1-30)
|
||||
APP="timetracker-staging-${SLUG}"
|
||||
echo "SLUG=${SLUG}" >> "$GITHUB_ENV"
|
||||
echo "APP=${APP}" >> "$GITHUB_ENV"
|
||||
echo "HOST=${APP}.fly.dev" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up flyctl
|
||||
uses: superfly/flyctl-actions/setup-flyctl@master
|
||||
|
||||
- name: Create app if missing
|
||||
run: |
|
||||
if ! flyctl status --app "$APP" >/dev/null 2>&1; then
|
||||
flyctl apps create "$APP" --org personal
|
||||
fi
|
||||
|
||||
- name: Set staging secrets
|
||||
run: |
|
||||
# Per-app SECRET_KEY so each staging instance is independent and no
|
||||
# session cookie is shared across instances or with production.
|
||||
SECRET_KEY="staging-${SLUG}-$(head -c16 /dev/urandom | base64 | tr -dc 'a-zA-Z0-9')"
|
||||
flyctl secrets set --app "$APP" --stage \
|
||||
"SECRET_KEY=${SECRET_KEY}" \
|
||||
"CSRF_TRUSTED_ORIGINS=https://${HOST}"
|
||||
|
||||
- name: Deploy
|
||||
run: flyctl deploy --app "$APP" --config fly.staging.toml --remote-only --yes
|
||||
|
||||
- name: Summary
|
||||
run: echo "Deployed to https://${HOST}" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Comment staging URL on PR
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const host = process.env.HOST;
|
||||
const branch = process.env.BRANCH;
|
||||
const body = `Staging deployment: https://${host}`;
|
||||
const { owner, repo } = context.repo;
|
||||
const pulls = await github.rest.pulls.list({
|
||||
owner, repo, state: "open", head: `${owner}:${branch}`,
|
||||
});
|
||||
const pr = pulls.data[0];
|
||||
if (!pr) {
|
||||
core.info(`No open PR for branch '${branch}', skipping comment`);
|
||||
return;
|
||||
}
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner, repo, issue_number: pr.number,
|
||||
});
|
||||
if (comments.some((comment) => comment.body === body)) {
|
||||
core.info(`Staging URL already commented on PR #${pr.number}`);
|
||||
return;
|
||||
}
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number: pr.number, body,
|
||||
});
|
||||
core.info(`Commented staging URL on PR #${pr.number}`);
|
||||
|
||||
teardown:
|
||||
if: github.event_name == 'delete' && github.event.ref_type == 'branch'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
BRANCH: ${{ github.event.ref }}
|
||||
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
|
||||
steps:
|
||||
- name: Set up flyctl
|
||||
uses: superfly/flyctl-actions/setup-flyctl@master
|
||||
|
||||
- name: Destroy staging app
|
||||
run: |
|
||||
SLUG=$(echo "$BRANCH" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9-]+/-/g; s/-+/-/g; s/^-//; s/-$//' | cut -c1-30)
|
||||
APP="timetracker-staging-${SLUG}"
|
||||
flyctl apps destroy "$APP" --yes 2>/dev/null || true
|
||||
+5
-1
@@ -4,7 +4,6 @@ __pycache__
|
||||
.venv/
|
||||
node_modules
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
db.sqlite3
|
||||
data/
|
||||
/static/
|
||||
@@ -13,3 +12,8 @@ dist/
|
||||
.python-version
|
||||
.direnv
|
||||
.hermes/
|
||||
|
||||
# Build artifacts: generated in CI/Docker assets stage, not committed
|
||||
/games/static/base.css
|
||||
/games/static/js/dist/
|
||||
/ts/generated/
|
||||
|
||||
@@ -35,6 +35,7 @@ games/ — Django app: models, views, templates, forms, signals, tasks,
|
||||
common/ — Shared utilities: time formatting, component system, criteria, layout, icons
|
||||
timetracker/ — Django project: settings, URL root, ASGI/WSGI
|
||||
tests/ — Pytest tests
|
||||
e2e/ — Playwright browser tests (run via `make test-e2e`)
|
||||
contrib/ — One-off scripts (exchange rate import)
|
||||
docs/ — Additional documentation
|
||||
```
|
||||
@@ -57,12 +58,12 @@ docs/ — Additional documentation
|
||||
|
||||
### Key patterns
|
||||
|
||||
**Layout system** (`common/layout.py`): Views call `render_page(request, content, title=...)` instead of Django's `render()`. This assembles a full HTML document via `Page()` — analogous to FastHTML's `fast_app()`. `Page()` handles the `<head>`, navbar, toast container, JS includes, and FOUC-prevention script. The navbar shows today's playtime and last-7-days playtime from the `model_counts` context processor.
|
||||
**Layout system** (`common/layout.py`): Views call `render_page(request, content, title=...)` instead of Django's `render()`. This assembles a full HTML document via `Page()` — analogous to FastHTML's `fast_app()`. `Page()` handles the `<head>`, navbar, toast container, FOUC-prevention script, and **JS includes**: it calls `collect_media(content)` to gather every component's declared `Media` and emits the `<script>` tags automatically — so views do **not** pass `scripts=` for component-owned JS. The `scripts=` argument remains only for page-specific glue not owned by a reusable component (e.g. the add-form helper `add_*.js`). The navbar shows today's/last-7-days playtime from the `model_counts` context processor.
|
||||
|
||||
**Component system** (`common/components/`): Pure-Python HTML builders, split into four submodules re-exported via `common/components/__init__.py`:
|
||||
**Component system** (`common/components/`): a FastHTML-style **lazy node tree**. Components are `Node` objects that render to HTML only when asked (`str(node)` / `Page()`), so `Page()` can walk a finished tree and collect each component's JS. Split into submodules re-exported via `common/components/__init__.py`:
|
||||
|
||||
- **`core.py`** — `Component(tag_name, attributes, children)`: the fundamental builder. `_render_element()` is `@lru_cache`-memoized (4096 entries, always active). Attribute values are always HTML-escaped; children are escaped unless they are `SafeText`. `randomid()` generates stable hash-based IDs.
|
||||
- **`primitives.py`** — Generic HTML: `A()`, `Button()` (with color/size/icon params), `ButtonGroup()`, `Div()`, `Span()`, `Label()`, `Input()`, `Icon()`, `Popover()`, `PopoverTruncated()`, `SearchField()`, `H1()`, `Modal()`, `SimpleTable()`, `TableRow()`, `TableTd()`, `TableHeader()`, `paginated_table_content()`, `AddForm()`, `Pill()`, `CsrfInput()`, `ModuleScript()`
|
||||
- **`core.py`** — the node layer. `Node` (base; `__html__`/`__str__` return a `SafeString`), `Element` (the single class for *any* HTML element), `Safe` (wraps pre-rendered/trusted HTML), `Fragment` (ordered children, no wrapper tag — use instead of `str(a)+str(b)`), `BaseComponent` (base for higher-level components: implement `render()`, declare `media`), and `Media` (declarative JS deps with order-preserving dedup merge; `collect_media()` sums them over a tree, `node.with_media(...)` attaches them). `_render_element()` is `@lru_cache`-memoized (4096). Attribute values are always escaped. **Children: every string child is escaped — `SafeText`/`mark_safe` included; only `Node` children (so `Safe`) render unescaped.** Trusted pre-rendered HTML must be wrapped in `Safe(...)`, never passed as a safe string. `randomid()` generates stable hash-based IDs.
|
||||
- **`primitives.py`** — Generic HTML. Plain leaf builders (`Div`, `Span`, `P`, `Ul`, `Li`, `Strong`, `Label`, `Template`, `Td`, `Tr`, `Th`) are **generated from a whitelist** via the `_html_element(tag)` factory over `Element` — not hand-written per tag. Builders that add classes/behaviour are written out: `A()`, `Button()`, `ButtonGroup()`, `Input()`, `Checkbox()`, `Radio()`, `Pill()`, `Icon()`, `Popover()`, `PopoverTruncated()`, `SearchField()`, `H1()`, `Modal()`, `SimpleTable()`, `TableRow()`, `TableTd()`, `TableHeader()`, `paginated_table_content()`, `AddForm()`, `YearPicker()` (declares datepicker media), `CsrfInput()`/`ModuleScript()`/`StaticScript()` (script-tag string helpers used by `Page()`).
|
||||
- **`domain.py`** — Domain-specific: `GameLink()`, `GameStatus()` (colored dot + label), `GameStatusSelector()` (Alpine.js PATCH dropdown), `SessionDeviceSelector()` (Alpine.js PATCH dropdown), `LinkedPurchase()`, `NameWithIcon()`, `PriceConverted()`, `PurchasePrice()`
|
||||
- **`filters.py`** — Filter UI: `FilterBar()`, `SessionFilterBar()`, `PurchaseFilterBar()` (built from `FilterSelect` widgets)
|
||||
- **`search_select.py`** — `SearchSelect()` (form combobox) + `FilterSelect()` (include/exclude filter combobox with pinned Any/None modifiers) + `SearchSelectOption`, all built on a shared `_combobox_shell`; wired by `games/static/js/search_select.js`
|
||||
@@ -113,17 +114,30 @@ Only a small number of HTML templates remain (platform icon snippets and partial
|
||||
### Frontend stack
|
||||
|
||||
- **HTMX** (`games/static/js/htmx.min.js`) — partial page updates
|
||||
- **Alpine.js** (CDN) — reactive dropdowns (`GameStatusSelector`, `SessionDeviceSelector`), toast store
|
||||
- **Flowbite** (CDN) — navbar collapse, dropdown toggles
|
||||
- **Alpine.js** (vendored: `alpine.min.js`, `alpine-mask.min.js`) — reactive dropdowns (`GameStatusSelector`, `SessionDeviceSelector`), toast store
|
||||
- **Flowbite** (vendored: `flowbite.min.js`; `datepicker.umd.js` for the stats YearPicker) — navbar collapse, dropdown toggles
|
||||
- **Tailwind CSS** — utility classes, compiled from `common/input.css` → `games/static/base.css`
|
||||
- All third-party JS is served locally from `games/static/js/` (no CDNs), so pages and browser tests work offline
|
||||
- **Custom JS** in `games/static/js/`:
|
||||
- `toast.js` — Alpine.js toast store (listens for `show-toast` HTMX event)
|
||||
- `toast.js` — Alpine.js toast store (listens for `show-toast` HTMX event); also defines `window.fetchWithHtmxTriggers`
|
||||
- `search_select.js` — SearchSelect/FilterSelect widgets (search-as-you-type, pills, include/exclude filter mode)
|
||||
- `utils.js` — shared helpers (e.g., `fetchWithHtmxTriggers`)
|
||||
- `utils.js` — shared ES-module helpers (`onSwap`, `toISOUTCString`, …)
|
||||
- **Widget initialization**: widget JS registers with `onSwap(selector, initializeElement)` from `utils.js` — a port of FastHTML's `proc_htmx` built on `htmx.onLoad`. It runs the initializer once per matching element, on initial page load and inside every htmx-swapped fragment. Never hand-roll `DOMContentLoaded`/`htmx:afterSwap` listeners with per-element guard flags.
|
||||
|
||||
### Interactive components: custom elements + TypeScript
|
||||
|
||||
New interactive components are **custom elements**, not inline JS in Python. A component that needs behavior emits a semantic tag via `custom_element("tag", Props(...))` (light DOM, server-rendered inner markup built with the htpy-style node builders). Behavior lives in `ts/elements/<tag>.ts` (TypeScript, vanilla DOM, `customElements.define`); the native `connectedCallback` replaces `onSwap` (it fires on parse *and* htmx swap). The server↔client contract is one Python `TypedDict` per element registered with `register_element(...)` in `common/components/custom_elements.py`; `manage.py gen_element_types` codegens `ts/generated/props.ts` (interface + attribute reader) so renaming a prop fails `tsc`.
|
||||
|
||||
- **Build:** `tsc` per-module (`tsconfig.json`) compiles `ts/` → `games/static/js/dist/` (build-only, gitignored). `make ts` = codegen + compile; `make ts-check` (in `make check`) = codegen + `tsc --noEmit`; `make dev` runs `tsc --watch`. The Docker image builds CSS + TS in a Node stage. Run `make ts` after editing any `.ts` so e2e/local serving sees fresh output.
|
||||
- **htpy-style markup:** generic builders take kwargs attributes and `[]` children — `Div(class_="x", hx_get="/y")[child1, child2]` (`class_`→`class`, `hx_get`→`hx-get`, `True`→bare attr, `False`/`None`→omitted). Still a walkable `Element` tree, so `Media` bubbles.
|
||||
- **Do NOT** author HTML/JS as Python f-strings or add new inline Alpine `x-data` blobs. Alpine remains only for trivial pre-existing toggles (toast store, etc.).
|
||||
- **Tables collect cell media:** `SimpleTable` stringifies cells, so it explicitly `collect_media`s its rows/header and re-attaches it — a custom element in a table cell still gets its `<script>` emitted by `Page()`.
|
||||
|
||||
### Deployment
|
||||
|
||||
Docker-based: multi-stage Dockerfile (uv builder → slim runtime), Caddy as reverse proxy on port 8000, Gunicorn with UvicornWorker (ASGI), Supervisor to manage Caddy + Gunicorn + django-q2. `make dev-prod` mimics production locally. CI/CD via GitHub Actions (`.github/workflows/build-docker.yml`): builds Docker image; Drone CI (`.drone.yml`) also present for deployments via Portainer webhook.
|
||||
Docker-based: multi-stage Dockerfile (uv builder → Node assets stage → slim runtime), Caddy as reverse proxy on port 8000, Gunicorn with UvicornWorker (ASGI), Supervisor to manage Caddy + Gunicorn + django-q2. `make dev-prod` mimics production locally. CI/CD via GitHub Actions (`.github/workflows/build-docker.yml`): builds Docker image; Drone CI (`.drone.yml`) also present for deployments via Portainer webhook.
|
||||
|
||||
**Package manager (pnpm):** front-end deps use **pnpm**, not npm. The pnpm version is pinned in `package.json`'s `packageManager` field and provisioned via **Corepack** (bundled with Node) — the Docker assets stage runs `corepack enable` rather than `npm install -g pnpm`. To bump pnpm, update the `packageManager` field; local, CI, and Docker all follow it. pnpm disables dependency lifecycle scripts by default (opt in via `pnpm.onlyBuiltDependencies`), so the project is unaffected by npm v12's install-script changes.
|
||||
|
||||
### Database
|
||||
|
||||
@@ -155,13 +169,16 @@ Tests live in `tests/`. Run with `make test` or `uv run --with pytest-django pyt
|
||||
|
||||
Pytest settings are in `pyproject.toml` under `[tool.pytest.ini_options]` (`DJANGO_SETTINGS_MODULE = "timetracker.settings"`).
|
||||
|
||||
**Browser/E2E tests** live in `e2e/` and run with `make test-e2e` (`pytest-playwright` driving a real Chromium against pytest-django's `live_server`). `e2e/conftest.py` sets `DJANGO_ALLOW_ASYNC_UNSAFE` and prefers a system Chrome/Chromium; otherwise install browsers once via `uv run playwright install chromium`. All JS (including Alpine/Flowbite) is vendored in `games/static/js/`, so the tests run fully offline. Note that a bare `pytest` (`make test`) collects `e2e/` too, so it needs a browser as well. Key files: `test_widgets_e2e.py` (onSwap initialization lifecycle, FilterSelect/RangeSlider/add-purchase behavior), `test_search_select_e2e.py` (single-select edge cases on a synthetic page).
|
||||
|
||||
## Conventions for AI assistants
|
||||
|
||||
- **Never write to `GeneratedField`s** (`duration_calculated`, `duration_total`, `price_per_game`, `days_to_finish`). They are computed by the database.
|
||||
- **Name variables with complete words** — readable, unabbreviated identifiers in both Python and JavaScript (e.g. `template` not `tpl`, `event` not `e`, `element` not `el`, `removeButton` not `removeBtn`, `option`/`value` not single letters in loops). This applies to new code and to code you touch.
|
||||
- **Use `render_page()` not `render()`** for all full-page HTTP responses. Import from `common.layout`.
|
||||
- **Build UI with Python components** from `common.components`, not raw HTML strings or Django templates. `SafeText` children pass through unescaped; plain strings are auto-escaped.
|
||||
- **Prefer the named element primitives over raw `Component(tag_name=…)`** — use `Div()`, `Span()`, `Input()`, `Label()`, `Template()`, etc. from `common.components` instead of `Component(tag_name="div")`. Reach for `Component` directly only when no primitive fits (e.g. a bare, custom-styled `<button>` where the opinionated `Button()` would inject unwanted classes). Add a new primitive rather than repeating an inline `Component` for a standard tag.
|
||||
- **Components are nodes; use the named builders** — build with `Div()`, `Span()`, `Element("tag", ...)`, etc., which return `Node` objects. For a tag with no builder, add it to the whitelist in `primitives.py` (one line) or use `Element("tag", attrs, children)`. Use `Fragment(a, b, ...)` to group siblings (never `str(a)+str(b)`, which flattens the tree and drops media). Wrap trusted pre-rendered HTML in `Safe(html)` (the `mark_safe` analogue).
|
||||
- **JS-bearing components declare `Media`, they don't rely on the view** — give a component `class Media: js = (...)` (a `BaseComponent`) or `return node.with_media(Media(js=...))`. `Page()` collects and emits it. Never re-add `scripts=ModuleScript(...)` threading in a view for a component that can declare its own dependency.
|
||||
- **Filter views** accept `?filter=<JSON>` (structured) and fall back to `?search_string=` (free-text). New filter criteria go in `games/filters.py`; new criterion types go in `common/criteria.py`.
|
||||
- **Signals handle side-effects** — do not manually recalculate `Game.playtime` or `Purchase.num_purchases`; the signals in `games/signals.py` do this on save/delete.
|
||||
- **Button colors**: `blue` (primary action), `red` (destructive), `gray` (secondary), `green` (positive). Icon buttons use `icon=True`.
|
||||
|
||||
+23
@@ -15,6 +15,25 @@ COPY . .
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --frozen --no-dev
|
||||
|
||||
# Codegen the TypeScript prop contracts (needs Django); tsc compiles them in
|
||||
# the assets stage below.
|
||||
RUN uv run python manage.py gen_element_types
|
||||
|
||||
|
||||
# Front-end assets: Tailwind CSS + the TypeScript custom elements. Built here so
|
||||
# the compiled output ships in the image (dist/ is build-only, not committed).
|
||||
FROM node:22-bookworm-slim AS assets
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
# Corepack ships with Node and activates the pnpm version pinned in
|
||||
# package.json's "packageManager" field — no npm bootstrap needed.
|
||||
RUN corepack enable && pnpm install --frozen-lockfile --ignore-scripts
|
||||
COPY . .
|
||||
COPY --from=builder /home/timetracker/app/ts/generated ./ts/generated
|
||||
RUN pnpm tailwindcss -i ./common/input.css -o ./games/static/base.css \
|
||||
&& pnpm exec tsc
|
||||
|
||||
|
||||
FROM python:3.14-slim-bookworm
|
||||
|
||||
@@ -44,6 +63,10 @@ WORKDIR /home/timetracker/app
|
||||
|
||||
COPY --from=builder --chown=timetracker:timetracker /home/timetracker/app /home/timetracker/app
|
||||
|
||||
# Built front-end assets from the Node stage (Tailwind CSS + compiled TS).
|
||||
COPY --from=assets --chown=timetracker:timetracker /app/games/static/base.css /home/timetracker/app/games/static/base.css
|
||||
COPY --from=assets --chown=timetracker:timetracker /app/games/static/js/dist /home/timetracker/app/games/static/js/dist
|
||||
|
||||
COPY --chown=timetracker:timetracker Caddyfile /etc/caddy/Caddyfile
|
||||
COPY --chown=timetracker:timetracker supervisor.conf /etc/supervisor/conf.d/supervisor.conf
|
||||
COPY --chown=timetracker:timetracker entrypoint.sh /
|
||||
|
||||
@@ -22,12 +22,25 @@ init:
|
||||
pnpm install
|
||||
$(MAKE) loadplatforms
|
||||
|
||||
server:
|
||||
uv run python -Wa manage.py runserver
|
||||
|
||||
gen-element-types:
|
||||
uv run python manage.py gen_element_types
|
||||
|
||||
ts: gen-element-types
|
||||
pnpm exec tsc
|
||||
|
||||
ts-check: gen-element-types
|
||||
pnpm exec tsc --noEmit
|
||||
|
||||
dev:
|
||||
@pnpm concurrently \
|
||||
--names "Django,Tailwind" \
|
||||
--prefix-colors "blue,green" \
|
||||
--names "Django,Tailwind,TS" \
|
||||
--prefix-colors "blue,green,magenta" \
|
||||
"uv run python -Wa manage.py runserver" \
|
||||
"pnpm tailwindcss -i ./common/input.css -o ./games/static/base.css --watch"
|
||||
"pnpm tailwindcss -i ./common/input.css -o ./games/static/base.css --watch" \
|
||||
"pnpm exec tsc --watch"
|
||||
|
||||
|
||||
caddy:
|
||||
@@ -67,6 +80,9 @@ uv.lock: pyproject.toml
|
||||
test: uv.lock
|
||||
uv run --with pytest-django pytest
|
||||
|
||||
test-e2e: uv.lock
|
||||
uv run pytest e2e/
|
||||
|
||||
lint:
|
||||
uv run ruff check
|
||||
|
||||
@@ -79,7 +95,7 @@ format:
|
||||
format-check:
|
||||
uv run ruff format --check
|
||||
|
||||
check: lint format-check test
|
||||
check: lint format-check ts-check test
|
||||
|
||||
date:
|
||||
uv run python -c 'import datetime; from zoneinfo import ZoneInfo; print(datetime.datetime.isoformat(datetime.datetime.now(ZoneInfo("Europe/Prague")), timespec="minutes", sep=" "))'
|
||||
|
||||
@@ -4,48 +4,25 @@ Split into core / primitives / domain / filters submodules; this package
|
||||
re-exports the public API so ``from common.components import X`` keeps working.
|
||||
"""
|
||||
|
||||
from common.utils import truncate
|
||||
|
||||
from common.components.core import (
|
||||
Component,
|
||||
BaseComponent,
|
||||
Element,
|
||||
Fragment,
|
||||
HTMLAttribute,
|
||||
HTMLTag,
|
||||
Media,
|
||||
Node,
|
||||
Safe,
|
||||
_render_element,
|
||||
collect_media,
|
||||
randomid,
|
||||
render,
|
||||
)
|
||||
from common.components.primitives import (
|
||||
A,
|
||||
AddForm,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
CsrfInput,
|
||||
Div,
|
||||
ExternalScript,
|
||||
H1,
|
||||
Icon,
|
||||
Input,
|
||||
Modal,
|
||||
ModuleScript,
|
||||
Pill,
|
||||
Popover,
|
||||
PopoverTruncated,
|
||||
SearchField,
|
||||
SimpleTable,
|
||||
Span,
|
||||
Label,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
TableTd,
|
||||
Template,
|
||||
YearPicker,
|
||||
paginated_table_content,
|
||||
)
|
||||
from common.components.search_select import (
|
||||
FilterSelect,
|
||||
LabeledOption,
|
||||
SearchSelect,
|
||||
SearchSelectOption,
|
||||
searchselect_selected,
|
||||
from common.components.custom_elements import SessionTimestampButtons, register_element
|
||||
from common.components.date_range_picker import (
|
||||
DateRangeCalendar,
|
||||
DateRangeField,
|
||||
DateRangePicker,
|
||||
)
|
||||
from common.components.domain import (
|
||||
GameLink,
|
||||
@@ -59,22 +36,82 @@ from common.components.domain import (
|
||||
_resolve_name_with_icon,
|
||||
)
|
||||
from common.components.filters import (
|
||||
DeviceFilterBar,
|
||||
FilterBar,
|
||||
PlatformFilterBar,
|
||||
PlayEventFilterBar,
|
||||
PurchaseFilterBar,
|
||||
SessionFilterBar,
|
||||
StringFilter,
|
||||
)
|
||||
from common.components.primitives import (
|
||||
H1,
|
||||
A,
|
||||
AddForm,
|
||||
ButtonGroup,
|
||||
Checkbox,
|
||||
CsrfInput,
|
||||
Div,
|
||||
ExternalScript,
|
||||
Icon,
|
||||
Input,
|
||||
Label,
|
||||
Li,
|
||||
Modal,
|
||||
ModuleScript,
|
||||
Pill,
|
||||
Popover,
|
||||
PopoverTruncated,
|
||||
Radio,
|
||||
SearchField,
|
||||
SimpleTable,
|
||||
Span,
|
||||
StaticScript,
|
||||
StyledButton,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
TableTd,
|
||||
Td,
|
||||
Template,
|
||||
Th,
|
||||
Tr,
|
||||
Ul,
|
||||
YearPicker,
|
||||
custom_element_builder,
|
||||
paginated_table_content,
|
||||
)
|
||||
from common.components.search_select import (
|
||||
DEFAULT_PREFETCH,
|
||||
FilterSelect,
|
||||
LabeledOption,
|
||||
SearchSelect,
|
||||
SearchSelectOption,
|
||||
searchselect_selected,
|
||||
)
|
||||
from common.utils import truncate
|
||||
|
||||
__all__ = [
|
||||
"truncate",
|
||||
"Component",
|
||||
"BaseComponent",
|
||||
"register_element",
|
||||
"SessionTimestampButtons",
|
||||
"custom_element_builder",
|
||||
"Element",
|
||||
"Fragment",
|
||||
"Media",
|
||||
"Node",
|
||||
"Safe",
|
||||
"collect_media",
|
||||
"render",
|
||||
"HTMLAttribute",
|
||||
"HTMLTag",
|
||||
"_render_element",
|
||||
"randomid",
|
||||
"A",
|
||||
"AddForm",
|
||||
"Button",
|
||||
"StyledButton",
|
||||
"ButtonGroup",
|
||||
"Checkbox",
|
||||
"CsrfInput",
|
||||
"Div",
|
||||
"ExternalScript",
|
||||
@@ -86,7 +123,9 @@ __all__ = [
|
||||
"Pill",
|
||||
"Popover",
|
||||
"PopoverTruncated",
|
||||
"Radio",
|
||||
"SearchField",
|
||||
"DEFAULT_PREFETCH",
|
||||
"FilterSelect",
|
||||
"LabeledOption",
|
||||
"SearchSelect",
|
||||
@@ -94,7 +133,13 @@ __all__ = [
|
||||
"searchselect_selected",
|
||||
"SimpleTable",
|
||||
"Span",
|
||||
"StaticScript",
|
||||
"Label",
|
||||
"Li",
|
||||
"Td",
|
||||
"Th",
|
||||
"Tr",
|
||||
"Ul",
|
||||
"TableHeader",
|
||||
"TableRow",
|
||||
"TableTd",
|
||||
@@ -110,7 +155,14 @@ __all__ = [
|
||||
"PurchasePrice",
|
||||
"SessionDeviceSelector",
|
||||
"_resolve_name_with_icon",
|
||||
"DateRangeCalendar",
|
||||
"DateRangeField",
|
||||
"DateRangePicker",
|
||||
"FilterBar",
|
||||
"PurchaseFilterBar",
|
||||
"SessionFilterBar",
|
||||
"DeviceFilterBar",
|
||||
"PlatformFilterBar",
|
||||
"PlayEventFilterBar",
|
||||
"StringFilter",
|
||||
]
|
||||
|
||||
+306
-27
@@ -1,6 +1,20 @@
|
||||
"""Escaping core: the Component builder and its memoised renderer."""
|
||||
"""Node layer: the lazy component tree, its renderer, and media collection.
|
||||
|
||||
A FastHTML-style model. Everything renderable is a :class:`Node`. The single
|
||||
:class:`Element` class represents *any* HTML element (tag + attrs + children);
|
||||
named builders like ``Div`` / ``Span`` are generated from a whitelist rather
|
||||
than hand-written per tag (see ``primitives.py``). Higher-level, behaviour- or
|
||||
media-bearing components subclass :class:`BaseComponent` and implement
|
||||
``render()`` returning a node subtree.
|
||||
|
||||
Nodes are *lazy*: they hold structure and render to HTML only when asked
|
||||
(``str(node)`` / ``node.__html__()`` / :func:`render`). This is what lets
|
||||
``Page()`` walk a finished tree and collect every component's declared JS
|
||||
(:class:`Media`) instead of each view threading ``scripts=`` by hand.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
from collections.abc import Sequence
|
||||
from functools import lru_cache
|
||||
|
||||
from django.utils.html import escape
|
||||
@@ -10,24 +24,181 @@ from django.utils.safestring import SafeText, mark_safe
|
||||
HTMLAttribute = tuple[str, str | int | bool]
|
||||
|
||||
|
||||
# Type for a builder's ``attributes`` parameter. Covariant ``Sequence`` so a
|
||||
# caller's ``list[tuple[str, str]]`` is accepted (a plain ``list[HTMLAttribute]``
|
||||
# would be invariant and reject it). Locals that get ``.append()``-ed should
|
||||
# stay a concrete ``list[HTMLAttribute]``.
|
||||
Attributes = Sequence[HTMLAttribute]
|
||||
|
||||
|
||||
HTMLTag = str
|
||||
|
||||
|
||||
# ── Media: declarative JS dependencies ──────────────────────────────────────
|
||||
|
||||
|
||||
def _dedup(*sequences: tuple[str, ...]) -> tuple[str, ...]:
|
||||
"""First-seen dedup that preserves declaration order across sequences."""
|
||||
seen: dict[str, None] = {}
|
||||
for sequence in sequences:
|
||||
for item in sequence:
|
||||
seen.setdefault(item, None)
|
||||
return tuple(seen)
|
||||
|
||||
|
||||
class Media:
|
||||
"""A component's JS dependencies, modelled on ``django.forms.Media``.
|
||||
|
||||
``js`` are static ES-module filenames (rendered as ``ModuleScript``);
|
||||
``js_external`` are vendored UMD / classic bundles (rendered as
|
||||
``StaticScript``). Addition merges with first-seen, order-preserving dedup,
|
||||
so a page that uses a component many times emits each script once.
|
||||
"""
|
||||
|
||||
__slots__ = ("js", "js_external")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
js: tuple[str, ...] | list[str] = (),
|
||||
js_external: tuple[str, ...] | list[str] = (),
|
||||
) -> None:
|
||||
self.js = tuple(js)
|
||||
self.js_external = tuple(js_external)
|
||||
|
||||
def __add__(self, other: "Media | None") -> "Media":
|
||||
if not other:
|
||||
return self
|
||||
return Media(
|
||||
_dedup(self.js, other.js),
|
||||
_dedup(self.js_external, other.js_external),
|
||||
)
|
||||
|
||||
def __radd__(self, other: "Media | None") -> "Media":
|
||||
# Supports ``sum(medias, Media())`` and ``0 + media``.
|
||||
if not other or other == 0:
|
||||
return self
|
||||
return other.__add__(self)
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return bool(self.js or self.js_external)
|
||||
|
||||
def __eq__(self, other: object) -> bool:
|
||||
return (
|
||||
isinstance(other, Media)
|
||||
and self.js == other.js
|
||||
and self.js_external == other.js_external
|
||||
)
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash((self.js, self.js_external))
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"Media(js={self.js!r}, js_external={self.js_external!r})"
|
||||
|
||||
|
||||
# ── Node tree ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class Node:
|
||||
"""Base class for everything renderable to HTML."""
|
||||
|
||||
# Declared dependencies. Class-level default is shared and empty; concrete
|
||||
# components override with their own ``Media(...)``.
|
||||
media: Media = Media()
|
||||
|
||||
def _render(self) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
def collect_media(self) -> Media:
|
||||
"""Total media of this node and its subtree."""
|
||||
return self.media
|
||||
|
||||
def with_media(self, media: Media) -> "Node":
|
||||
"""Attach JS dependencies to this node and return it (for fluent use).
|
||||
|
||||
Lets a function-built node declare its media without becoming a full
|
||||
``BaseComponent`` subclass: ``return Div(...).with_media(Media(js=...))``.
|
||||
"""
|
||||
self.media = self.media + media
|
||||
return self
|
||||
|
||||
# A node's rendered output is always safe HTML by construction (Element
|
||||
# escapes unsafe children; Safe wraps trusted markup; Fragment escapes plain
|
||||
# strings). So both `__html__` (Django's conditional_escape hook) and
|
||||
# `__str__` return a SafeString — this is what keeps ``str(node)`` safe when
|
||||
# fed back into a child list or template, matching the old SafeText shims.
|
||||
def __html__(self) -> SafeText:
|
||||
return mark_safe(self._render())
|
||||
|
||||
def __str__(self) -> SafeText:
|
||||
return mark_safe(self._render())
|
||||
|
||||
|
||||
# A renderable child is a node or a string. Strings are ALWAYS escaped (a string
|
||||
# is untrusted text — ``SafeText``/``mark_safe`` is escaped too); trusted
|
||||
# pre-rendered HTML must be a ``Safe`` node. ``Children`` is the type for a
|
||||
# builder's ``children``
|
||||
# parameter: a sequence of child nodes/strings, a bare string, or nothing. The
|
||||
# sequence is a covariant ``Sequence`` so ``list[Element]`` / ``list[Node]`` are
|
||||
# accepted (a plain ``list[str]`` would be invariant and reject them). A single
|
||||
# bare ``Node`` is accepted only by ``Element`` itself (which wraps it); the
|
||||
# higher-level builders take ``Children``.
|
||||
Child = Node | str
|
||||
Children = Sequence[Child] | Node | str | None
|
||||
|
||||
|
||||
def as_children(children: Children) -> list[Child]:
|
||||
"""Normalise a builder's ``children`` argument to a flat list.
|
||||
|
||||
Accepts ``None`` (→ empty), a single node/string (→ one-element list), or a
|
||||
sequence of them. Lets builders drop the ``children if isinstance(children,
|
||||
list) else [children]`` dance and get a properly typed ``list[Child]``.
|
||||
"""
|
||||
if children is None:
|
||||
return []
|
||||
if isinstance(children, (str, Node)):
|
||||
return [children]
|
||||
return list(children)
|
||||
|
||||
|
||||
def as_attributes(attributes: "Attributes | None") -> list[HTMLAttribute]:
|
||||
"""Normalise an ``attributes`` argument to a mutable ``list[HTMLAttribute]``.
|
||||
|
||||
Builders take a covariant ``Attributes`` (so callers can pass a
|
||||
``list[tuple[str, str]]``) but often append to or concatenate the value;
|
||||
this turns it into a concrete list they can mutate.
|
||||
"""
|
||||
return list(attributes) if attributes else []
|
||||
|
||||
|
||||
def _child_key(child: object) -> tuple[str, bool]:
|
||||
"""Normalise a child to a ``(text, is_safe)`` pair.
|
||||
|
||||
Only :class:`Node` children render unescaped — that includes :class:`Safe`,
|
||||
the one sanctioned way to put trusted pre-rendered HTML into the tree. Every
|
||||
*string* child is escaped, ``SafeText``/``mark_safe`` included: a string is
|
||||
always treated as untrusted text, so trusted markup must be wrapped in
|
||||
``Safe(...)`` rather than smuggled in as a safe string. ``is_safe`` is part
|
||||
of the render cache key so a safe ``"<b>"`` and an unsafe ``"<b>"`` never
|
||||
collide.
|
||||
"""
|
||||
if isinstance(child, Node):
|
||||
return (child._render(), True)
|
||||
if isinstance(child, str):
|
||||
return (child, False)
|
||||
return (str(child), False)
|
||||
|
||||
|
||||
@lru_cache(maxsize=4096)
|
||||
def _render_element(
|
||||
tag_name: str,
|
||||
attrs_key: tuple[tuple[str, str], ...],
|
||||
children_key: tuple[tuple[str, bool], ...],
|
||||
) -> str:
|
||||
"""Pure, memoized HTML builder behind `Component`.
|
||||
"""Pure, memoized HTML builder. Identical (tag, attrs, children) render once.
|
||||
|
||||
Inputs are fully hashable and fully determine the output, so identical
|
||||
elements are rendered once. `attrs_key` is (name, stringified value) pairs
|
||||
(attribute values are always escaped). `children_key` is (child, is_safe)
|
||||
pairs: SafeText children pass through, plain strings are escaped. The
|
||||
`is_safe` flag is part of the key on purpose — otherwise a safe ``"<b>"``
|
||||
and an unsafe ``"<b>"`` (equal as strings) would collide and one would
|
||||
render with the wrong escaping.
|
||||
``attrs_key`` is (name, stringified value) pairs (values always escaped);
|
||||
``children_key`` is (text, is_safe) pairs (safe passes through, else escaped).
|
||||
"""
|
||||
children_blob = "\n".join(
|
||||
child if is_safe else escape(child) for child, is_safe in children_key
|
||||
@@ -41,24 +212,132 @@ def _render_element(
|
||||
return f"<{tag_name}{attributes_blob}>{children_blob}</{tag_name}>"
|
||||
|
||||
|
||||
def Component(
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
tag_name: str = "",
|
||||
) -> SafeText:
|
||||
"""Render an HTML element. Attribute values are always escaped; children are
|
||||
escaped unless they are `SafeText` (so nested components pass through),
|
||||
preventing accidental HTML injection. Rendering is memoized via
|
||||
`_render_element`."""
|
||||
attributes = attributes or []
|
||||
children = children or []
|
||||
if not tag_name:
|
||||
raise ValueError("tag_name is required.")
|
||||
if isinstance(children, str):
|
||||
children = [children]
|
||||
attrs_key = tuple((name, str(value)) for name, value in attributes)
|
||||
children_key = tuple((child, isinstance(child, SafeText)) for child in children)
|
||||
return mark_safe(_render_element(tag_name, attrs_key, children_key))
|
||||
class Element(Node):
|
||||
"""Any HTML element: a tag name, attributes and children.
|
||||
|
||||
Children may be other nodes, ``SafeText``, or plain strings (escaped).
|
||||
Rendering goes through the memoized :func:`_render_element`.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
tag_name: str,
|
||||
attributes: Attributes | None = None,
|
||||
children: "Children | Node" = None,
|
||||
) -> None:
|
||||
if not tag_name:
|
||||
raise ValueError("tag_name is required.")
|
||||
self.tag_name = tag_name
|
||||
self.attributes = attributes or []
|
||||
if children is None:
|
||||
children = []
|
||||
elif isinstance(children, (str, Node)):
|
||||
children = [children]
|
||||
self.children = children
|
||||
|
||||
def __getitem__(self, children: "Children | Node") -> "Element":
|
||||
"""htpy-style children: ``Div(class_="x")[child1, child2]``.
|
||||
|
||||
Returns an Element with the same tag/attributes/media and these
|
||||
children, so the tree stays walkable (Media still bubbles)."""
|
||||
items = children if isinstance(children, tuple) else (children,)
|
||||
clone = Element(self.tag_name, self.attributes, list(items))
|
||||
clone.media = self.media
|
||||
return clone
|
||||
|
||||
def collect_media(self) -> Media:
|
||||
media = self.media
|
||||
for child in self.children:
|
||||
if isinstance(child, Node):
|
||||
media = media + child.collect_media()
|
||||
return media
|
||||
|
||||
def _render(self) -> str:
|
||||
attrs_key = tuple((name, str(value)) for name, value in self.attributes)
|
||||
children_key = tuple(_child_key(child) for child in self.children)
|
||||
return _render_element(self.tag_name, attrs_key, children_key)
|
||||
|
||||
|
||||
class Safe(Node):
|
||||
"""A node wrapping pre-rendered, trusted HTML (the ``mark_safe`` analogue).
|
||||
|
||||
Used as the migration bridge for components still built from f-strings:
|
||||
they return ``Safe(html)`` and declare their ``media`` explicitly rather
|
||||
than atomising their markup into a node tree up front.
|
||||
"""
|
||||
|
||||
def __init__(self, html: object, media: Media | None = None) -> None:
|
||||
self._html = str(html)
|
||||
if media is not None:
|
||||
self.media = media
|
||||
|
||||
def _render(self) -> str:
|
||||
return self._html
|
||||
|
||||
|
||||
class Fragment(Node):
|
||||
"""An ordered group of children with no wrapping tag.
|
||||
|
||||
Replaces ``mark_safe(str(a) + str(b))`` / ``"\\n".join(...)`` composition,
|
||||
so media still bubbles up from the grouped children.
|
||||
"""
|
||||
|
||||
def __init__(self, *children: object, separator: str = "") -> None:
|
||||
self.children = [c for c in children if c is not None and c != ""]
|
||||
self.separator = separator
|
||||
|
||||
def collect_media(self) -> Media:
|
||||
media = Media()
|
||||
for child in self.children:
|
||||
if isinstance(child, Node):
|
||||
media = media + child.collect_media()
|
||||
return media
|
||||
|
||||
def _render(self) -> str:
|
||||
parts = []
|
||||
for child in self.children:
|
||||
text, is_safe = _child_key(child)
|
||||
parts.append(text if is_safe else escape(text))
|
||||
return self.separator.join(parts)
|
||||
|
||||
|
||||
class BaseComponent(Node):
|
||||
"""Base for higher-level components: implement ``render()`` returning a node
|
||||
subtree and declare ``media`` (a :class:`Media`).
|
||||
|
||||
``render()`` is called once and memoized; ``collect_media()`` returns this
|
||||
component's own media merged with the rendered subtree's.
|
||||
"""
|
||||
|
||||
def render(self) -> Node:
|
||||
raise NotImplementedError
|
||||
|
||||
def _tree(self) -> Node:
|
||||
cached = getattr(self, "_tree_cache", None)
|
||||
if cached is None:
|
||||
cached = self.render()
|
||||
self._tree_cache = cached
|
||||
return cached
|
||||
|
||||
def _render(self) -> str:
|
||||
return self._tree()._render()
|
||||
|
||||
def collect_media(self) -> Media:
|
||||
return self.media + self._tree().collect_media()
|
||||
|
||||
|
||||
def render(node: "Node | str") -> SafeText:
|
||||
"""Render a node (or pass a string through) to safe HTML."""
|
||||
if isinstance(node, Node):
|
||||
return mark_safe(node._render())
|
||||
return mark_safe(str(node))
|
||||
|
||||
|
||||
def collect_media(node: "Node | str") -> Media:
|
||||
"""Collect the media of a node tree (empty for a bare string)."""
|
||||
if isinstance(node, Node):
|
||||
return node.collect_media()
|
||||
return Media()
|
||||
|
||||
|
||||
def randomid(seed: str = "", content: str = "", length: int = 10) -> str:
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Custom-element builder, registry, and TypeScript codegen.
|
||||
|
||||
A custom element is a light-DOM Web Component: the Python builder emits a
|
||||
semantic tag whose typed props become kebab-case attributes and whose behavior
|
||||
lives in a compiled TS module (loaded via Media). One ``TypedDict`` per element
|
||||
is the single source of truth for the server<->client contract;
|
||||
``gen_element_types`` turns each registered spec into a TS interface + attribute
|
||||
reader so drift fails ``tsc``.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TypedDict, get_type_hints
|
||||
|
||||
from common.components.core import Media
|
||||
from common.components.primitives import custom_element_builder
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ElementSpec:
|
||||
tag: str # e.g. "game-status-selector"
|
||||
ts_name: str # e.g. "GameStatusSelector"
|
||||
props: type # a TypedDict subclass
|
||||
|
||||
|
||||
ELEMENT_REGISTRY: list[ElementSpec] = []
|
||||
|
||||
|
||||
def register_element(tag: str, ts_name: str, props: type) -> None:
|
||||
"""Register an element so codegen can emit its TS contract."""
|
||||
ELEMENT_REGISTRY.append(ElementSpec(tag, ts_name, props))
|
||||
|
||||
|
||||
def _kebab(name: str) -> str:
|
||||
return name.replace("_", "-")
|
||||
|
||||
|
||||
# ── Codegen ──────────────────────────────────────────────────────────────────
|
||||
|
||||
_TYPE_MAP = {int: "number", float: "number", str: "string", bool: "boolean"}
|
||||
|
||||
|
||||
def _camel(name: str) -> str:
|
||||
head, *tail = name.split("_")
|
||||
return head + "".join(part.title() for part in tail)
|
||||
|
||||
|
||||
def _reader_expr(name: str, python_type: type) -> str:
|
||||
attr = _kebab(name)
|
||||
if python_type in (int, float):
|
||||
return f'Number(el.getAttribute("{attr}"))'
|
||||
if python_type is bool:
|
||||
return f'el.getAttribute("{attr}") === "true"'
|
||||
return f'el.getAttribute("{attr}") ?? ""'
|
||||
|
||||
|
||||
def _ts_for_spec(spec: ElementSpec) -> str:
|
||||
hints = get_type_hints(spec.props)
|
||||
interface_lines = "\n".join(
|
||||
f" {_camel(name)}: {_TYPE_MAP[python_type]};"
|
||||
for name, python_type in hints.items()
|
||||
)
|
||||
reader_lines = "\n".join(
|
||||
f" {_camel(name)}: {_reader_expr(name, python_type)},"
|
||||
for name, python_type in hints.items()
|
||||
)
|
||||
return (
|
||||
f"export interface {spec.ts_name}Props {{\n{interface_lines}\n}}\n\n"
|
||||
f"export function read{spec.ts_name}Props(el: HTMLElement): "
|
||||
f"{spec.ts_name}Props {{\n return {{\n{reader_lines}\n }};\n}}"
|
||||
)
|
||||
|
||||
|
||||
def render_props_module() -> str:
|
||||
"""The full ``ts/generated/props.ts`` content for every registered element."""
|
||||
header = "// GENERATED by `manage.py gen_element_types` — do not edit.\n"
|
||||
blocks = [_ts_for_spec(spec) for spec in ELEMENT_REGISTRY]
|
||||
return header + "\n" + "\n\n".join(blocks) + "\n"
|
||||
|
||||
|
||||
# ── Element prop schemas (registered at import time) ─────────────────────────
|
||||
|
||||
|
||||
class GameStatusSelectorProps(TypedDict):
|
||||
game_id: int
|
||||
status: str
|
||||
csrf: str
|
||||
|
||||
|
||||
register_element("game-status-selector", "GameStatusSelector", GameStatusSelectorProps)
|
||||
|
||||
|
||||
class SessionDeviceSelectorProps(TypedDict):
|
||||
session_id: int
|
||||
csrf: str
|
||||
|
||||
|
||||
register_element(
|
||||
"session-device-selector", "SessionDeviceSelector", SessionDeviceSelectorProps
|
||||
)
|
||||
|
||||
|
||||
class PlayEventRowProps(TypedDict):
|
||||
game_id: int
|
||||
csrf: str
|
||||
api_create_url: str
|
||||
|
||||
|
||||
register_element("play-event-row", "PlayEventRow", PlayEventRowProps)
|
||||
|
||||
|
||||
class SessionTimestampButtonsProps(TypedDict):
|
||||
pass
|
||||
|
||||
|
||||
register_element(
|
||||
"session-timestamp-buttons", "SessionTimestampButtons", SessionTimestampButtonsProps
|
||||
)
|
||||
|
||||
|
||||
# ── Named tag builders (consistent htpy-style with Div/Span) ─────────────────
|
||||
# Underscore-prefixed: used internally by domain wrappers.
|
||||
# Public ones (no domain wrapper): exported directly.
|
||||
|
||||
_GameStatusSelector = custom_element_builder("game-status-selector")
|
||||
_SessionDeviceSelector = custom_element_builder("session-device-selector")
|
||||
_PlayEventRow = custom_element_builder("play-event-row")
|
||||
SessionTimestampButtons = custom_element_builder("session-timestamp-buttons")
|
||||
@@ -0,0 +1,354 @@
|
||||
"""DateRangePicker: a segmented date-range input with a calendar popup.
|
||||
|
||||
``DateRangePicker`` composes two parts:
|
||||
|
||||
- ``DateRangeField`` — the visible widget, styled as a single input. Each
|
||||
date is split into per-part segments (``DD``/``MM``/``YYYY``, ordered by
|
||||
``common.time.dateformat_hyphenated``) that the user fills digit by digit,
|
||||
plus a calendar icon that opens the popup.
|
||||
- ``DateRangeCalendar`` — the popup: a preset column (today, yesterday,
|
||||
last 7 days, …), a month grid rendered client-side, and a
|
||||
Cancel / Clear / Select footer.
|
||||
|
||||
The committed value lives in two hidden ISO-date inputs named
|
||||
``{input_name_prefix}-min`` / ``{input_name_prefix}-max`` — the same contract
|
||||
as the older ``DateRangeFilter``, so ``filter_bar.js`` serializes either
|
||||
widget into a ``DateCriterion`` unchanged. All behaviour is wired by
|
||||
``games/static/js/date_range_picker.js``.
|
||||
"""
|
||||
|
||||
from common.components.core import Element, HTMLAttribute, Media, Node, Safe
|
||||
from common.components.primitives import Div, Input, Span
|
||||
from common.time import DatePartSpec, date_parts
|
||||
|
||||
# Wired by date_range_picker.js.
|
||||
_DATE_RANGE_MEDIA = Media(js=("date_range_picker.js",))
|
||||
|
||||
_FIELD_CONTAINER_CLASS = (
|
||||
"flex items-center gap-0.5 w-full rounded-base border border-default-medium "
|
||||
"bg-neutral-secondary-medium text-sm text-heading p-1.5 cursor-text "
|
||||
"focus-within:ring-1 focus-within:ring-brand focus-within:border-brand"
|
||||
)
|
||||
|
||||
# The segments must not stand out from the container: transparent background,
|
||||
# no border, and only a subtle highlight when active (focused).
|
||||
_SEGMENT_INPUT_CLASS = (
|
||||
"bg-transparent border-0 p-0 text-center text-sm text-heading "
|
||||
"placeholder:text-body rounded-xs focus:outline-none focus:ring-0 "
|
||||
"focus:bg-brand/30 caret-transparent"
|
||||
)
|
||||
|
||||
_SEGMENT_WIDTH_CLASSES = {2: "w-[2.5ch]", 4: "w-[4.5ch]"}
|
||||
|
||||
_CALENDAR_ICON_SVG = (
|
||||
'<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" '
|
||||
'stroke="currentColor" aria-hidden="true">'
|
||||
'<path stroke-linecap="round" stroke-linejoin="round" '
|
||||
'd="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5'
|
||||
"A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5"
|
||||
"A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5"
|
||||
'A2.25 2.25 0 0 1 21 11.25v7.5"/>'
|
||||
"</svg>"
|
||||
)
|
||||
|
||||
_PRESET_OPTIONS: list[tuple[str, str]] = [
|
||||
("today", "Today"),
|
||||
("yesterday", "Yesterday"),
|
||||
("last_7_days", "Last 7 days"),
|
||||
("last_30_days", "Last 30 days"),
|
||||
("this_month", "This month"),
|
||||
("last_month", "Last month"),
|
||||
("this_year", "This year"),
|
||||
]
|
||||
|
||||
_PRESET_BUTTON_CLASS = (
|
||||
"px-3 py-1.5 text-sm text-start text-body hover:text-heading "
|
||||
"hover:bg-neutral-tertiary-medium rounded-base cursor-pointer whitespace-nowrap"
|
||||
)
|
||||
|
||||
_NAV_BUTTON_CLASS = (
|
||||
"p-1.5 text-body hover:text-heading hover:bg-neutral-tertiary-medium "
|
||||
"rounded-base cursor-pointer"
|
||||
)
|
||||
|
||||
_FOOTER_BUTTON_CLASS = (
|
||||
"px-3 py-1.5 text-sm font-medium rounded-base cursor-pointer "
|
||||
"text-heading bg-neutral-secondary-medium border border-default-medium "
|
||||
"hover:bg-neutral-tertiary-medium"
|
||||
)
|
||||
|
||||
_FOOTER_SELECT_BUTTON_CLASS = (
|
||||
"px-3 py-1.5 text-sm font-medium rounded-base cursor-pointer "
|
||||
"text-white bg-brand border border-transparent hover:bg-brand-strong"
|
||||
)
|
||||
|
||||
|
||||
def _iso_part_values(iso_value: str, parts: list[DatePartSpec]) -> dict[str, str]:
|
||||
"""Split an ISO ``YYYY-MM-DD`` string into per-part initial values.
|
||||
|
||||
Returns an empty mapping for empty/malformed input so a bad stored filter
|
||||
renders as empty segments instead of crashing."""
|
||||
if not iso_value:
|
||||
return {}
|
||||
pieces = iso_value.split("-")
|
||||
if len(pieces) != 3:
|
||||
return {}
|
||||
year, month, day = pieces
|
||||
values = {"year": year, "month": month, "day": day}
|
||||
if any(not values[part.name].isdigit() for part in parts):
|
||||
return {}
|
||||
return values
|
||||
|
||||
|
||||
def _segment_input(*, part: DatePartSpec, side: str, label: str, value: str) -> Node:
|
||||
side_label = "from" if side == "min" else "to"
|
||||
return Input(
|
||||
attributes=[
|
||||
("inputmode", "numeric"),
|
||||
("autocomplete", "off"),
|
||||
("maxlength", str(part.length)),
|
||||
("placeholder", part.placeholder),
|
||||
("value", value),
|
||||
("data-date-part", part.name),
|
||||
("data-date-side", side),
|
||||
("aria-label", f"{label} {side_label} {part.name}"),
|
||||
(
|
||||
"class",
|
||||
f"{_SEGMENT_INPUT_CLASS} "
|
||||
f"{_SEGMENT_WIDTH_CLASSES.get(part.length, 'w-[4.5ch]')}",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _segment_group(*, side: str, label: str, iso_value: str) -> Node:
|
||||
"""One date's worth of segments (``DD - MM - YYYY``) for a range side."""
|
||||
parts = date_parts()
|
||||
initial_values = _iso_part_values(iso_value, parts)
|
||||
children: list[Node] = []
|
||||
for index, part in enumerate(parts):
|
||||
if index > 0:
|
||||
children.append(
|
||||
Span(
|
||||
attributes=[("class", "text-body select-none")],
|
||||
children=["-"],
|
||||
)
|
||||
)
|
||||
children.append(
|
||||
_segment_input(
|
||||
part=part,
|
||||
side=side,
|
||||
label=label,
|
||||
value=initial_values.get(part.name, ""),
|
||||
)
|
||||
)
|
||||
return Span(
|
||||
attributes=[
|
||||
("class", "flex items-center gap-0.5"),
|
||||
("data-date-range-side", side),
|
||||
],
|
||||
children=children,
|
||||
)
|
||||
|
||||
|
||||
def DateRangeField(
|
||||
*,
|
||||
label: str,
|
||||
input_name_prefix: str,
|
||||
min_value: str = "",
|
||||
max_value: str = "",
|
||||
) -> Node:
|
||||
"""The visible half of the DateRangePicker: a single-input-looking
|
||||
container holding two segmented dates, a calendar toggle, and the two
|
||||
hidden ISO inputs (``{prefix}-min`` / ``{prefix}-max``) that carry the
|
||||
committed value to ``filter_bar.js``."""
|
||||
min_input_id = f"{input_name_prefix}-min"
|
||||
max_input_id = f"{input_name_prefix}-max"
|
||||
return Div(
|
||||
attributes=[
|
||||
("class", _FIELD_CONTAINER_CLASS),
|
||||
("data-date-range-field", ""),
|
||||
],
|
||||
children=[
|
||||
Input(
|
||||
type="hidden",
|
||||
attributes=[
|
||||
("name", min_input_id),
|
||||
("id", min_input_id),
|
||||
("value", min_value),
|
||||
("data-date-range-hidden", "min"),
|
||||
],
|
||||
),
|
||||
Input(
|
||||
type="hidden",
|
||||
attributes=[
|
||||
("name", max_input_id),
|
||||
("id", max_input_id),
|
||||
("value", max_value),
|
||||
("data-date-range-hidden", "max"),
|
||||
],
|
||||
),
|
||||
_segment_group(side="min", label=label, iso_value=min_value),
|
||||
Span(
|
||||
attributes=[("class", "text-body select-none px-0.5")],
|
||||
children=["–"],
|
||||
),
|
||||
_segment_group(side="max", label=label, iso_value=max_value),
|
||||
Element(
|
||||
"button",
|
||||
attributes=[
|
||||
("type", "button"),
|
||||
("data-date-range-calendar-toggle", ""),
|
||||
("aria-label", f"Open {label} calendar"),
|
||||
(
|
||||
"class",
|
||||
"ms-auto p-1 text-body hover:text-heading rounded "
|
||||
"cursor-pointer shrink-0",
|
||||
),
|
||||
],
|
||||
children=[Safe(_CALENDAR_ICON_SVG)],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _calendar_nav_button(direction: str, arrow: str, label: str) -> Node:
|
||||
return Element(
|
||||
"button",
|
||||
attributes=[
|
||||
("type", "button"),
|
||||
(f"data-date-range-{direction}", ""),
|
||||
("aria-label", label),
|
||||
("class", _NAV_BUTTON_CLASS),
|
||||
],
|
||||
children=[arrow],
|
||||
)
|
||||
|
||||
|
||||
def _footer_button(action: str, label: str, button_class: str) -> Node:
|
||||
return Element(
|
||||
"button",
|
||||
attributes=[
|
||||
("type", "button"),
|
||||
(f"data-date-range-{action}", ""),
|
||||
("class", button_class),
|
||||
],
|
||||
children=[label],
|
||||
)
|
||||
|
||||
|
||||
def DateRangeCalendar(*, input_name_prefix: str) -> Node:
|
||||
"""The popup half of the DateRangePicker: preset column, month grid
|
||||
(filled client-side into ``[data-date-range-grid]``), and the
|
||||
Cancel / Clear / Select footer. Hidden until the calendar toggle opens it."""
|
||||
preset_buttons = [
|
||||
Element(
|
||||
"button",
|
||||
attributes=[
|
||||
("type", "button"),
|
||||
("data-date-range-preset", preset_value),
|
||||
("class", _PRESET_BUTTON_CLASS),
|
||||
],
|
||||
children=[preset_label],
|
||||
)
|
||||
for preset_value, preset_label in _PRESET_OPTIONS
|
||||
]
|
||||
return Div(
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
"hidden absolute z-20 top-full start-0 mt-1 flex "
|
||||
"rounded-base border border-default-medium "
|
||||
"bg-neutral-secondary-medium shadow-lg",
|
||||
),
|
||||
("data-date-range-calendar", ""),
|
||||
("data-input-name-prefix", input_name_prefix),
|
||||
],
|
||||
children=[
|
||||
Div(
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
"flex flex-col gap-0.5 p-2 border-e border-default-medium",
|
||||
),
|
||||
("data-date-range-presets", ""),
|
||||
],
|
||||
children=preset_buttons,
|
||||
),
|
||||
Div(
|
||||
attributes=[("class", "p-2")],
|
||||
children=[
|
||||
Div(
|
||||
attributes=[
|
||||
("class", "flex items-center justify-between gap-2"),
|
||||
],
|
||||
children=[
|
||||
_calendar_nav_button("prev", "‹", "Previous month"),
|
||||
Span(
|
||||
attributes=[
|
||||
("class", "text-sm font-medium text-heading"),
|
||||
("data-date-range-month-label", ""),
|
||||
],
|
||||
),
|
||||
_calendar_nav_button("next", "›", "Next month"),
|
||||
],
|
||||
),
|
||||
Div(
|
||||
attributes=[
|
||||
("class", "grid grid-cols-7 gap-y-0.5 mt-1"),
|
||||
("data-date-range-grid", ""),
|
||||
],
|
||||
),
|
||||
Div(
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
"flex justify-end gap-2 mt-2 pt-2 border-t "
|
||||
"border-default-medium",
|
||||
),
|
||||
],
|
||||
children=[
|
||||
_footer_button("cancel", "Cancel", _FOOTER_BUTTON_CLASS),
|
||||
_footer_button("clear", "Clear", _FOOTER_BUTTON_CLASS),
|
||||
_footer_button(
|
||||
"select", "Select", _FOOTER_SELECT_BUTTON_CLASS
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def DateRangePicker(
|
||||
*,
|
||||
label: str,
|
||||
input_name_prefix: str,
|
||||
min_value: str = "",
|
||||
max_value: str = "",
|
||||
) -> Node:
|
||||
"""A date-range widget: segmented manual entry plus a calendar popup.
|
||||
|
||||
Drop-in replacement for ``DateRangeFilter`` — exposes the same hidden
|
||||
``{prefix}-min`` / ``{prefix}-max`` ISO inputs, so the filter-bar
|
||||
serializer needs no changes. ``min_value`` / ``max_value`` are ISO
|
||||
``YYYY-MM-DD`` strings used to prefill both the segments and the hidden
|
||||
inputs."""
|
||||
attributes: list[HTMLAttribute] = [
|
||||
("class", "date-range-picker relative"),
|
||||
("data-date-range-picker", ""),
|
||||
("data-input-name-prefix", input_name_prefix),
|
||||
]
|
||||
return Div(
|
||||
attributes=attributes,
|
||||
children=[
|
||||
DateRangeField(
|
||||
label=label,
|
||||
input_name_prefix=input_name_prefix,
|
||||
min_value=min_value,
|
||||
max_value=max_value,
|
||||
),
|
||||
DateRangeCalendar(input_name_prefix=input_name_prefix),
|
||||
],
|
||||
).with_media(_DATE_RANGE_MEDIA)
|
||||
+111
-145
@@ -4,9 +4,8 @@ from typing import Any
|
||||
|
||||
from django.template.defaultfilters import floatformat
|
||||
from django.urls import reverse
|
||||
from django.utils.safestring import SafeText, mark_safe
|
||||
|
||||
from common.components.core import Component, HTMLTag
|
||||
from common.components.core import Children, Node, Safe, as_children
|
||||
from common.components.primitives import (
|
||||
A,
|
||||
Div,
|
||||
@@ -21,25 +20,23 @@ from games.models import Game, Purchase, Session
|
||||
def GameLink(
|
||||
game_id: int,
|
||||
name: str = "",
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
) -> SafeText:
|
||||
children: Children = None,
|
||||
) -> Node:
|
||||
"""Link to a game's detail page. Uses children (slot) if provided, otherwise name."""
|
||||
from django.urls import reverse
|
||||
|
||||
children = children or []
|
||||
display = children if children else [name]
|
||||
display = as_children(children) or [name]
|
||||
link = reverse("games:view_game", args=[game_id])
|
||||
|
||||
return Span(
|
||||
attributes=[("class", "truncate-container")],
|
||||
children=[
|
||||
Component(
|
||||
tag_name="a",
|
||||
A(
|
||||
href=link,
|
||||
attributes=[
|
||||
("href", link),
|
||||
("class", "underline decoration-slate-500 sm:decoration-2"),
|
||||
],
|
||||
children=display if isinstance(display, list) else [display],
|
||||
children=display,
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -55,11 +52,11 @@ _STATUS_COLORS = {
|
||||
|
||||
|
||||
def GameStatus(
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
children: Children = None,
|
||||
status: str = "u",
|
||||
display: str = "",
|
||||
class_: str = "",
|
||||
) -> SafeText:
|
||||
) -> Node:
|
||||
"""Colored status dot with label. Status codes: u/p/f/a/r."""
|
||||
children = children or []
|
||||
outer_class = (
|
||||
@@ -77,13 +74,13 @@ def GameStatus(
|
||||
|
||||
return Span(
|
||||
attributes=[("class", outer_class)],
|
||||
children=[dot] + (children if isinstance(children, list) else [children]),
|
||||
children=[dot] + as_children(children),
|
||||
)
|
||||
|
||||
|
||||
def PriceConverted(
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
) -> SafeText:
|
||||
children: Children = None,
|
||||
) -> Node:
|
||||
"""Wrap content in a span that indicates the price was converted."""
|
||||
children = children or []
|
||||
return Span(
|
||||
@@ -91,11 +88,11 @@ def PriceConverted(
|
||||
("title", "Price is a result of conversion and rounding."),
|
||||
("class", "decoration-dotted underline"),
|
||||
],
|
||||
children=children if isinstance(children, list) else [children],
|
||||
children=as_children(children),
|
||||
)
|
||||
|
||||
|
||||
def LinkedPurchase(purchase: Purchase) -> SafeText:
|
||||
def LinkedPurchase(purchase: Purchase) -> Node:
|
||||
link = reverse("games:view_purchase", args=[int(purchase.id)])
|
||||
link_content = ""
|
||||
popover_content = ""
|
||||
@@ -132,7 +129,7 @@ def LinkedPurchase(purchase: Purchase) -> SafeText:
|
||||
),
|
||||
PopoverTruncated(
|
||||
input_string=link_content,
|
||||
popover_content=mark_safe(popover_content),
|
||||
popover_content=Safe(popover_content),
|
||||
popover_if_not_truncated=popover_if_not_truncated,
|
||||
),
|
||||
],
|
||||
@@ -146,7 +143,7 @@ def NameWithIcon(
|
||||
session: Session | None = None,
|
||||
linkify: bool = True,
|
||||
emulated: bool = False,
|
||||
) -> SafeText:
|
||||
) -> Node:
|
||||
_name, platform, final_emulated, create_link, link = _resolve_name_with_icon(
|
||||
name, game, session, linkify
|
||||
)
|
||||
@@ -204,7 +201,7 @@ def _resolve_name_with_icon(
|
||||
return _name, platform, final_emulated, create_link, link
|
||||
|
||||
|
||||
def PurchasePrice(purchase) -> SafeText:
|
||||
def PurchasePrice(purchase) -> Node:
|
||||
return Popover(
|
||||
popover_content=f"{floatformat(purchase.price)} {purchase.price_currency}",
|
||||
wrapped_content=f"{floatformat(purchase.converted_price)} {purchase.converted_currency}",
|
||||
@@ -212,131 +209,100 @@ def PurchasePrice(purchase) -> SafeText:
|
||||
)
|
||||
|
||||
|
||||
def GameStatusSelector(game, game_statuses, csrf_token: str) -> SafeText:
|
||||
"""Alpine.js dropdown to change a game's status."""
|
||||
options_html = "\n".join(
|
||||
f"<template x-if=\"status == '{value}'\">"
|
||||
f"{GameStatus(status=value, children=[label], display='flex')}"
|
||||
f"</template>"
|
||||
_SELECTOR_MENU_CLASS = (
|
||||
"absolute top-[105%] left-0 w-full whitespace-nowrap z-10 text-sm "
|
||||
"font-medium bg-gray-800/20 backdrop-blur-lg rounded-md rounded-t-none "
|
||||
"border border-gray-200 dark:border-gray-700"
|
||||
)
|
||||
_SELECTOR_TOGGLE_CLASS = (
|
||||
"relative px-4 py-2 text-sm font-medium bg-white border border-gray-200 "
|
||||
"rounded-lg hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-700 "
|
||||
"dark:hover:text-white dark:hover:bg-gray-700 hover:cursor-pointer"
|
||||
)
|
||||
_SELECTOR_OPTION_CLASS = (
|
||||
"block w-full text-left px-4 py-2 rounded-sm cursor-pointer "
|
||||
"hover:bg-gray-700 hover:text-white dark:hover:bg-gray-700 "
|
||||
"dark:hover:text-white border-0"
|
||||
)
|
||||
|
||||
|
||||
def GameStatusSelector(game, game_statuses, csrf_token: str) -> Node:
|
||||
"""Light-DOM custom element; behavior in ts/elements/game-status-selector.ts."""
|
||||
from common.components.core import Element
|
||||
from common.components.custom_elements import _GameStatusSelector, GameStatusSelectorProps
|
||||
from common.components.primitives import Li, Ul
|
||||
|
||||
options = [
|
||||
Li()[
|
||||
Element(
|
||||
"button",
|
||||
[
|
||||
("type", "button"),
|
||||
("data-option", ""),
|
||||
("data-value", str(value)),
|
||||
("class", _SELECTOR_OPTION_CLASS),
|
||||
],
|
||||
GameStatus(status=value, children=[label], display="flex"),
|
||||
)
|
||||
]
|
||||
for value, label in game_statuses
|
||||
)
|
||||
list_items = "\n".join(
|
||||
f"<li><a href=\"#\" @click.prevent.stop=\"setStatus('{value}', '{label}'); open = false;\" "
|
||||
f'class="block px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 '
|
||||
f'dark:focus:ring-blue-500 dark:focus:text-white rounded-sm no-underline! border-0!" '
|
||||
f":class=\"{{'font-bold': status === '{value}'}}\">"
|
||||
f"{GameStatus(status=value, children=[label], display='flex', class_='text-slate-300')}"
|
||||
f"</a></li>"
|
||||
for value, label in game_statuses
|
||||
)
|
||||
|
||||
return mark_safe(f"""
|
||||
<div class="flex gap-2 items-center"
|
||||
x-data="{{
|
||||
status: '{game.status}',
|
||||
status_display: '{game.get_status_display()}',
|
||||
open: false,
|
||||
saving: false,
|
||||
setStatus(newStatus, newStatusDisplay) {{
|
||||
this.status = newStatus;
|
||||
this.status_display = newStatusDisplay;
|
||||
this.saving = true;
|
||||
fetchWithHtmxTriggers(`/api/games/{game.id}/status`, {{
|
||||
method: 'PATCH',
|
||||
headers: {{
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': '{csrf_token}'
|
||||
}},
|
||||
body: JSON.stringify({{ status: newStatus }})
|
||||
}})
|
||||
.then(() => {{
|
||||
document.body.dispatchEvent(new CustomEvent('status-changed'));
|
||||
}})
|
||||
.catch(() => {{
|
||||
console.error('Failed to update status');
|
||||
}})
|
||||
.finally(() => this.saving = false);
|
||||
}}
|
||||
}}">
|
||||
{_dropdown_button_html(options_html, list_items)}
|
||||
</div>
|
||||
""")
|
||||
|
||||
|
||||
def SessionDeviceSelector(session, session_devices, csrf_token: str) -> SafeText:
|
||||
"""Alpine.js dropdown to change a session's device."""
|
||||
device_id = session.device_id or "null"
|
||||
device_name = (session.device.name if session.device else "Unknown").replace(
|
||||
"'", "\\'"
|
||||
)
|
||||
|
||||
list_items = "\n".join(
|
||||
f'<li><a href="#" @click.prevent.stop="setDevice({d.id}, \'{d.name.replace(chr(39), chr(92) + chr(39))}\'); open = false;" '
|
||||
f'class="block px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 '
|
||||
f'dark:focus:ring-blue-500 dark:focus:text-white rounded-sm no-underline! border-0!" '
|
||||
f":class=\"{{'font-bold': deviceId === {d.id}}}\">{d.name}</a></li>"
|
||||
for d in session_devices
|
||||
)
|
||||
|
||||
return mark_safe(f"""
|
||||
<div class="flex gap-2 items-center"
|
||||
x-data="{{
|
||||
originalDeviceId: {device_id},
|
||||
originalDeviceName: '{device_name}',
|
||||
deviceId: {device_id},
|
||||
deviceName: '{device_name}',
|
||||
open: false,
|
||||
saving: false,
|
||||
setDevice(newDeviceId, newDeviceName) {{
|
||||
this.deviceId = newDeviceId;
|
||||
this.deviceName = newDeviceName;
|
||||
this.saving = true;
|
||||
fetchWithHtmxTriggers(`/api/session/{session.id}/device`, {{
|
||||
method: 'PATCH',
|
||||
headers: {{
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': '{csrf_token}'
|
||||
}},
|
||||
body: JSON.stringify({{ device_id: newDeviceId }})
|
||||
}})
|
||||
.then((res) => {{
|
||||
document.body.dispatchEvent(new CustomEvent('device-changed'));
|
||||
}})
|
||||
.catch(() => {{
|
||||
this.deviceName = this.originalDeviceName;
|
||||
this.deviceId = this.originalDeviceId;
|
||||
console.error('Failed to update device');
|
||||
}})
|
||||
.finally(() => this.saving = false);
|
||||
}}
|
||||
}}">
|
||||
{
|
||||
_dropdown_button_html(
|
||||
'<span x-text="deviceName"></span>' + str(Icon("arrowdown")), list_items
|
||||
]
|
||||
current_label = Span(data_label="")[
|
||||
GameStatus(
|
||||
status=game.status,
|
||||
children=[game.get_status_display()],
|
||||
display="flex",
|
||||
)
|
||||
}
|
||||
</div>
|
||||
""")
|
||||
|
||||
|
||||
def _dropdown_button_html(button_content: str, list_items: str) -> str:
|
||||
"""Shared dropdown button + list structure for Alpine.js selectors."""
|
||||
return (
|
||||
'<div class="inline-flex rounded-md shadow-2xs" role="group" @click.outside="open = false">'
|
||||
'<button type="button" @click="open = !open" '
|
||||
'class="relative px-4 py-2 text-sm font-medium bg-white border border-gray-200 '
|
||||
"rounded-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 "
|
||||
"focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 "
|
||||
"dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 "
|
||||
'dark:focus:text-white align-middle hover:cursor-pointer">'
|
||||
f'<span class="flex flex-row gap-4 justify-between items-center">{button_content}</span>'
|
||||
'<div class="absolute top-[105%] left-0 w-full whitespace-nowrap z-10 text-sm '
|
||||
"font-medium bg-gray-800/20 backdrop-blur-lg rounded-md rounded-t-none border "
|
||||
'border-gray-200 dark:border-gray-700" x-show="open" style="display: none;">'
|
||||
'<ul class="[&_li:first-of-type_a]:rounded-none [&_li:last-of-type_a]:rounded-t-none">'
|
||||
f"{list_items}"
|
||||
"</ul>"
|
||||
"</div>"
|
||||
"</button>"
|
||||
"</div>"
|
||||
]
|
||||
toggle = Element(
|
||||
"button",
|
||||
[("type", "button"), ("data-toggle", ""), ("class", _SELECTOR_TOGGLE_CLASS)],
|
||||
Span(class_="flex flex-row gap-4 justify-between items-center")[
|
||||
current_label, Icon("arrowdown")
|
||||
],
|
||||
)
|
||||
menu = Div(data_menu="", hidden=True, class_=_SELECTOR_MENU_CLASS)[Ul()[*options]]
|
||||
dropdown = Div(
|
||||
data_dropdown="", class_="inline-flex rounded-md shadow-2xs relative"
|
||||
)[toggle, menu]
|
||||
return _GameStatusSelector(game_id=game.id, status=game.status, csrf=csrf_token)[
|
||||
Div(class_="flex gap-2 items-center")[dropdown]
|
||||
]
|
||||
|
||||
|
||||
def SessionDeviceSelector(session, session_devices, csrf_token: str) -> Node:
|
||||
"""Light-DOM custom element; behavior in ts/elements/session-device-selector.ts."""
|
||||
from common.components.core import Element
|
||||
from common.components.custom_elements import _SessionDeviceSelector, SessionDeviceSelectorProps
|
||||
from common.components.primitives import Li, Ul
|
||||
|
||||
current_name = session.device.name if session.device else "Unknown"
|
||||
options = [
|
||||
Li()[
|
||||
Element(
|
||||
"button",
|
||||
[
|
||||
("type", "button"),
|
||||
("data-option", ""),
|
||||
("data-value", str(device.id)),
|
||||
("class", _SELECTOR_OPTION_CLASS),
|
||||
],
|
||||
children=[device.name],
|
||||
)
|
||||
]
|
||||
for device in session_devices
|
||||
]
|
||||
toggle = Element(
|
||||
"button",
|
||||
[("type", "button"), ("data-toggle", ""), ("class", _SELECTOR_TOGGLE_CLASS)],
|
||||
Span(class_="flex flex-row gap-4 justify-between items-center")[
|
||||
Span(data_label="")[current_name], Icon("arrowdown")
|
||||
],
|
||||
)
|
||||
menu = Div(data_menu="", hidden=True, class_=_SELECTOR_MENU_CLASS)[Ul()[*options]]
|
||||
dropdown = Div(
|
||||
data_dropdown="", class_="inline-flex rounded-md shadow-2xs relative"
|
||||
)[toggle, menu]
|
||||
return _SessionDeviceSelector(session_id=session.id, csrf=csrf_token)[
|
||||
Div(class_="flex gap-2 items-center")[dropdown]
|
||||
]
|
||||
|
||||
+884
-226
File diff suppressed because it is too large
Load Diff
+278
-182
@@ -1,15 +1,34 @@
|
||||
"""Generic HTML primitives (no domain knowledge)."""
|
||||
"""Generic HTML primitives (no domain knowledge).
|
||||
|
||||
Generic leaf elements (``Div``, ``Span``, ``Td`` …) are *not* hand-written one
|
||||
per tag: they are generated from a whitelist via :func:`_html_element`, each a
|
||||
thin builder over the single :class:`Element` node class. Only elements that add
|
||||
classes or behaviour (``Button``, ``Pill``, ``Checkbox`` …) are written out.
|
||||
Everything returns a :class:`Node`; string-built widgets return :class:`Safe`.
|
||||
"""
|
||||
|
||||
from django.middleware.csrf import get_token
|
||||
from django.templatetags.static import static
|
||||
from django.urls import reverse
|
||||
from django.utils.html import conditional_escape
|
||||
from django.utils.safestring import SafeText, mark_safe
|
||||
|
||||
from common.components.core import (
|
||||
Attributes,
|
||||
Child,
|
||||
Children,
|
||||
Element,
|
||||
Fragment,
|
||||
HTMLAttribute,
|
||||
Media,
|
||||
Node,
|
||||
Safe,
|
||||
as_attributes,
|
||||
as_children,
|
||||
collect_media,
|
||||
randomid,
|
||||
)
|
||||
from common.icons import get_icon
|
||||
from common.utils import truncate
|
||||
from common.components.core import Component, HTMLAttribute, HTMLTag, randomid
|
||||
|
||||
|
||||
_COLOR_CLASSES = {
|
||||
"blue": "text-white bg-brand box-border border border-transparent hover:bg-brand-strong focus:ring-4 focus:ring-brand-medium",
|
||||
@@ -28,18 +47,79 @@ _SIZE_CLASSES = {
|
||||
}
|
||||
|
||||
|
||||
# ── Generic leaf elements ────────────────────────────────────────────────────
|
||||
# A whitelist of plain tags, each turned into a builder over `Element`. The
|
||||
# tag name is data, not a separate class/function body. Add a tag = one line.
|
||||
|
||||
|
||||
def _attrs_from_kwargs(attrs: dict[str, object]) -> list[HTMLAttribute]:
|
||||
"""Translate htpy-style attribute kwargs to (name, value) pairs.
|
||||
|
||||
``class_`` -> ``class`` (trailing underscore stripped); ``hx_get`` ->
|
||||
``hx-get`` (inner underscores to hyphens); ``True`` -> bare attribute;
|
||||
``False`` / ``None`` -> omitted."""
|
||||
result: list[HTMLAttribute] = []
|
||||
for key, value in attrs.items():
|
||||
if value is None or value is False:
|
||||
continue
|
||||
name = key.rstrip("_").replace("_", "-")
|
||||
result.append((name, name if value is True else value)) # type: ignore[arg-type]
|
||||
return result
|
||||
|
||||
|
||||
def custom_element_builder(tag_name: str):
|
||||
"""Create a tag builder for a custom element with auto-attached Media.
|
||||
|
||||
The module path follows the convention ``ts/elements/<tag>.ts`` →
|
||||
``dist/elements/<tag>.js``.
|
||||
"""
|
||||
return _html_element(tag_name, Media(js=(f"dist/elements/{tag_name}.js",)))
|
||||
|
||||
|
||||
def _html_element(tag_name: str, media: Media | None = None):
|
||||
"""Build a generic element builder for ``tag_name`` (the whitelist factory).
|
||||
|
||||
If ``media`` is provided, every node created by the builder will carry it
|
||||
(used for custom elements whose compiled JS must be loaded automatically).
|
||||
"""
|
||||
|
||||
def element(
|
||||
attributes: Attributes | None = None,
|
||||
children: Children = None,
|
||||
**attrs: object,
|
||||
) -> Element:
|
||||
merged = as_attributes(attributes) + _attrs_from_kwargs(attrs)
|
||||
node = Element(tag_name, merged, children)
|
||||
return node.with_media(media) if media else node
|
||||
|
||||
element.__name__ = element.__qualname__ = tag_name[:1].upper() + tag_name[1:]
|
||||
element.__doc__ = f"Builder for the <{tag_name}> element."
|
||||
return element
|
||||
|
||||
|
||||
A = _html_element("a")
|
||||
Button = _html_element("button")
|
||||
Div = _html_element("div")
|
||||
P = _html_element("p")
|
||||
Ul = _html_element("ul")
|
||||
Li = _html_element("li")
|
||||
Strong = _html_element("strong")
|
||||
Span = _html_element("span")
|
||||
Label = _html_element("label")
|
||||
Template = _html_element("template")
|
||||
Td = _html_element("td")
|
||||
Tr = _html_element("tr")
|
||||
Th = _html_element("th")
|
||||
|
||||
|
||||
def _popover_html(
|
||||
id: str,
|
||||
popover_content: str,
|
||||
popover_content: Child,
|
||||
wrapped_content: str = "",
|
||||
wrapped_classes: str = "",
|
||||
slot: str = "",
|
||||
) -> SafeText:
|
||||
"""Generate popover HTML using Component(tag_name=...).
|
||||
|
||||
Single source of truth for popover HTML structure.
|
||||
Used by Popover() and the python_popover template tag bridge.
|
||||
"""
|
||||
slot: "Node | str" = "",
|
||||
) -> Node:
|
||||
"""Generate popover HTML. Single source of truth for popover structure."""
|
||||
display_content = wrapped_content if wrapped_content else slot
|
||||
|
||||
span = Span(
|
||||
@@ -57,8 +137,7 @@ def _popover_html(
|
||||
"dark:bg-purple-800"
|
||||
)
|
||||
|
||||
div = Component(
|
||||
tag_name="div",
|
||||
div = Div(
|
||||
attributes=[
|
||||
("data-popover", ""),
|
||||
("id", id),
|
||||
@@ -66,13 +145,12 @@ def _popover_html(
|
||||
("class", popover_tooltip_class),
|
||||
],
|
||||
children=[
|
||||
Component(
|
||||
tag_name="div",
|
||||
Div(
|
||||
attributes=[("class", "px-3 py-2")],
|
||||
children=[popover_content],
|
||||
),
|
||||
Component(tag_name="div", attributes=[("data-popper-arrow", "")]),
|
||||
mark_safe( # nosec — intentional HTML comment for Tailwind JIT
|
||||
Div(attributes=[("data-popper-arrow", "")]),
|
||||
Safe( # nosec — intentional HTML comment for Tailwind JIT
|
||||
"<!-- for Tailwind CSS to generate decoration-dotted CSS "
|
||||
"from Python component -->"
|
||||
),
|
||||
@@ -82,24 +160,24 @@ def _popover_html(
|
||||
],
|
||||
)
|
||||
|
||||
return mark_safe(span + "\n" + div)
|
||||
return Fragment(span, div, separator="\n")
|
||||
|
||||
|
||||
def Popover(
|
||||
popover_content: str,
|
||||
popover_content: Child,
|
||||
wrapped_content: str = "",
|
||||
wrapped_classes: str = "",
|
||||
children: list[HTMLTag] | None = None,
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
children: Children = None,
|
||||
attributes: Attributes | None = None,
|
||||
id: str = "",
|
||||
) -> str:
|
||||
children = children or []
|
||||
) -> Node:
|
||||
children = as_children(children)
|
||||
if not wrapped_content and not children:
|
||||
raise ValueError("One of wrapped_content or children is required.")
|
||||
if not id:
|
||||
id = randomid(content=f"{wrapped_content}:{popover_content}:{wrapped_classes}")
|
||||
|
||||
slot = mark_safe("\n".join(children))
|
||||
slot = Fragment(*children, separator="\n") if children else ""
|
||||
return _popover_html(
|
||||
id=id,
|
||||
popover_content=popover_content,
|
||||
@@ -111,12 +189,12 @@ def Popover(
|
||||
|
||||
def PopoverTruncated(
|
||||
input_string: str,
|
||||
popover_content: str = "",
|
||||
popover_content: Child = "",
|
||||
popover_if_not_truncated: bool = False,
|
||||
length: int = 30,
|
||||
ellipsis: str = "…",
|
||||
endpart: str = "",
|
||||
) -> str:
|
||||
) -> "Node | str":
|
||||
"""
|
||||
Returns `input_string` truncated after `length` of characters
|
||||
and displays the untruncated text in a popover HTML element.
|
||||
@@ -141,37 +219,9 @@ def PopoverTruncated(
|
||||
return input_string
|
||||
|
||||
|
||||
def A(
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
url_name: str | None = None,
|
||||
href: str | None = None,
|
||||
) -> SafeText:
|
||||
"""
|
||||
Returns an anchor <a> tag.
|
||||
|
||||
Accepts one of two mutually-exclusive URL specifications:
|
||||
- url_name: URL pattern name, resolved via reverse()
|
||||
- href: Literal path string passed through as-is
|
||||
"""
|
||||
attributes = attributes or []
|
||||
children = children or []
|
||||
if url_name is not None and href is not None:
|
||||
raise ValueError("Provide exactly one of 'url_name' or 'href', not both.")
|
||||
|
||||
additional_attributes = []
|
||||
if url_name is not None:
|
||||
additional_attributes = [("href", reverse(url_name))]
|
||||
elif href is not None:
|
||||
additional_attributes = [("href", href)]
|
||||
return Component(
|
||||
tag_name="a", attributes=attributes + additional_attributes, children=children
|
||||
)
|
||||
|
||||
|
||||
def Button(
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
def StyledButton(
|
||||
attributes: Attributes | None = None,
|
||||
children: Children = None,
|
||||
size: str = "base",
|
||||
icon: bool = False,
|
||||
color: str = "blue",
|
||||
@@ -182,8 +232,9 @@ def Button(
|
||||
title: str = "",
|
||||
onclick: str = "",
|
||||
name: str = "",
|
||||
) -> SafeText:
|
||||
attributes = attributes or []
|
||||
**attrs: object,
|
||||
) -> Element:
|
||||
attributes = as_attributes(attributes) + _attrs_from_kwargs(attrs)
|
||||
children = children or []
|
||||
|
||||
# Separate custom class from other generic attributes
|
||||
@@ -227,8 +278,8 @@ def Button(
|
||||
button_attrs.append(("name", name))
|
||||
button_attrs.extend(other_attrs)
|
||||
|
||||
return Component(
|
||||
tag_name="button",
|
||||
return Element(
|
||||
"button",
|
||||
attributes=button_attrs,
|
||||
children=children,
|
||||
)
|
||||
@@ -270,7 +321,7 @@ def _button_group_button(
|
||||
title: str = "",
|
||||
hx_get: str = "",
|
||||
hx_target: str = "",
|
||||
) -> SafeText:
|
||||
) -> Element:
|
||||
"""Generate a single button-group button (inner <button> inside <a>)."""
|
||||
color_classes = _GROUP_BUTTON_COLORS.get(color, _GROUP_BUTTON_COLORS["gray"])
|
||||
|
||||
@@ -287,8 +338,8 @@ def _button_group_button(
|
||||
)
|
||||
)
|
||||
|
||||
button = Component(
|
||||
tag_name="button",
|
||||
button = Element(
|
||||
"button",
|
||||
attributes=[
|
||||
("type", "button"),
|
||||
("title", title),
|
||||
@@ -297,10 +348,10 @@ def _button_group_button(
|
||||
children=[slot],
|
||||
)
|
||||
|
||||
return Component(tag_name="a", attributes=a_attrs, children=[button])
|
||||
return Element("a", attributes=a_attrs, children=[button])
|
||||
|
||||
|
||||
def ButtonGroup(buttons: list[dict] | None = None) -> SafeText:
|
||||
def ButtonGroup(buttons: list[dict] | None = None) -> Element:
|
||||
"""Generate a button group div.
|
||||
|
||||
Each button dict accepts: href, slot (required), color, title, hx_get, hx_target.
|
||||
@@ -308,7 +359,7 @@ def ButtonGroup(buttons: list[dict] | None = None) -> SafeText:
|
||||
for conditional buttons (e.g., end-session only when session is active).
|
||||
"""
|
||||
buttons = buttons or []
|
||||
children: list[SafeText] = []
|
||||
children: list[Node] = []
|
||||
for btn in buttons:
|
||||
if not btn or not btn.get("slot"):
|
||||
continue
|
||||
@@ -323,60 +374,84 @@ def ButtonGroup(buttons: list[dict] | None = None) -> SafeText:
|
||||
)
|
||||
)
|
||||
|
||||
return Component(
|
||||
tag_name="div",
|
||||
return Div(
|
||||
attributes=[("class", "inline-flex rounded-md shadow-xs"), ("role", "group")],
|
||||
children=children,
|
||||
)
|
||||
|
||||
|
||||
def Div(
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
) -> SafeText:
|
||||
attributes = attributes or []
|
||||
children = children or []
|
||||
return Component(tag_name="div", attributes=attributes, children=children)
|
||||
|
||||
|
||||
def Input(
|
||||
type: str = "text",
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
) -> SafeText:
|
||||
attributes = attributes or []
|
||||
attributes: Attributes | None = None,
|
||||
children: Children = None,
|
||||
) -> Element:
|
||||
attributes = as_attributes(attributes)
|
||||
children = children or []
|
||||
return Component(
|
||||
tag_name="input", attributes=attributes + [("type", type)], children=children
|
||||
return Element("input", attributes=attributes + [("type", type)], children=children)
|
||||
|
||||
|
||||
def Checkbox(
|
||||
name: str,
|
||||
label: str | None = None,
|
||||
checked: bool = False,
|
||||
value: str = "1",
|
||||
attributes: Attributes | None = None,
|
||||
) -> Node:
|
||||
"""A filter-agnostic Checkbox component."""
|
||||
attributes = as_attributes(attributes)
|
||||
input_attrs = [
|
||||
("name", name),
|
||||
("value", value),
|
||||
(
|
||||
"class",
|
||||
"rounded border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand",
|
||||
),
|
||||
] + attributes
|
||||
if checked:
|
||||
input_attrs.append(("checked", "true"))
|
||||
|
||||
input_el = Input(type="checkbox", attributes=input_attrs)
|
||||
if label is None:
|
||||
return input_el
|
||||
|
||||
return Label(
|
||||
attributes=[
|
||||
("class", "flex items-center gap-2 text-sm text-heading cursor-pointer")
|
||||
],
|
||||
children=[input_el, label],
|
||||
)
|
||||
|
||||
|
||||
def Span(
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
) -> SafeText:
|
||||
attributes = attributes or []
|
||||
children = children or []
|
||||
return Component(tag_name="span", attributes=attributes, children=children)
|
||||
def Radio(
|
||||
name: str,
|
||||
label: str | None = None,
|
||||
checked: bool = False,
|
||||
value: str = "",
|
||||
attributes: Attributes | None = None,
|
||||
) -> Node:
|
||||
"""A filter-agnostic Radio component."""
|
||||
attributes = as_attributes(attributes)
|
||||
input_attrs = [
|
||||
("name", name),
|
||||
("value", value),
|
||||
(
|
||||
"class",
|
||||
"rounded-full border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand",
|
||||
),
|
||||
] + attributes
|
||||
if checked:
|
||||
input_attrs.append(("checked", "true"))
|
||||
|
||||
input_el = Input(type="radio", attributes=input_attrs)
|
||||
if label is None:
|
||||
return input_el
|
||||
|
||||
def Label(
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
) -> SafeText:
|
||||
attributes = attributes or []
|
||||
children = children or []
|
||||
return Component(tag_name="label", attributes=attributes, children=children)
|
||||
|
||||
|
||||
def Template(
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
) -> SafeText:
|
||||
"""An inert ``<template>`` whose contents are not rendered until cloned by JS."""
|
||||
attributes = attributes or []
|
||||
children = children or []
|
||||
return Component(tag_name="template", attributes=attributes, children=children)
|
||||
return Label(
|
||||
attributes=[
|
||||
("class", "flex items-center gap-1 text-sm text-heading cursor-pointer")
|
||||
],
|
||||
children=[input_el, label],
|
||||
)
|
||||
|
||||
|
||||
# Inline Tailwind utilities for Pill (mirrors the .sf-tag / .sf-remove rules in
|
||||
@@ -397,8 +472,8 @@ def Pill(
|
||||
removable: bool = False,
|
||||
extra_class: str = "",
|
||||
label_slot: bool = False,
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
) -> SafeText:
|
||||
attributes: Attributes | None = None,
|
||||
) -> Node:
|
||||
"""A small label pill, optionally removable (× button).
|
||||
|
||||
Styling is inline Tailwind utilities; ``data-pill`` / ``data-pill-remove``
|
||||
@@ -409,23 +484,23 @@ def Pill(
|
||||
fill it when cloning the pill from a server-rendered ``<template>`` (keeps the
|
||||
markup single-sourced — see ``search_select.py``).
|
||||
"""
|
||||
attributes = attributes or []
|
||||
attributes = as_attributes(attributes)
|
||||
pill_class = f"{_PILL_CLASS} {extra_class}".strip()
|
||||
pill_attrs: list[HTMLAttribute] = [("class", pill_class), ("data-pill", "")]
|
||||
if value != "":
|
||||
pill_attrs.append(("data-value", str(value)))
|
||||
pill_attrs.extend(attributes)
|
||||
|
||||
label_child: HTMLTag = (
|
||||
label_child: "Node | str" = (
|
||||
Span(attributes=[("data-search-select-label", "")], children=[label])
|
||||
if label_slot
|
||||
else label
|
||||
)
|
||||
children: list[HTMLTag] = [label_child]
|
||||
children: list["Node | str"] = [label_child]
|
||||
if removable:
|
||||
children.append(
|
||||
Component(
|
||||
tag_name="button",
|
||||
Element(
|
||||
"button",
|
||||
attributes=[
|
||||
("type", "button"),
|
||||
("data-pill-remove", ""),
|
||||
@@ -439,9 +514,12 @@ def Pill(
|
||||
return Span(attributes=pill_attrs, children=children)
|
||||
|
||||
|
||||
def CsrfInput(request) -> SafeText:
|
||||
"""Hidden CSRF input, equivalent to the `{% csrf_token %}` template tag."""
|
||||
return mark_safe(
|
||||
def CsrfInput(request) -> Node:
|
||||
"""Hidden CSRF input, equivalent to the `{% csrf_token %}` template tag.
|
||||
|
||||
Returns a ``Safe`` node (not a safe string): it is always used as a tree
|
||||
child, and only nodes render unescaped now."""
|
||||
return Safe(
|
||||
f'<input type="hidden" name="csrfmiddlewaretoken" value="{get_token(request)}">'
|
||||
)
|
||||
|
||||
@@ -458,11 +536,22 @@ def ExternalScript(url: str) -> SafeText:
|
||||
return mark_safe(f'<script src="{url}"></script>')
|
||||
|
||||
|
||||
def StaticScript(filename: str) -> SafeText:
|
||||
"""A plain (classic, non-module) `<script src=...>` tag for a static JS
|
||||
file — for vendored UMD bundles, which break inside module scope."""
|
||||
return mark_safe(f'<script src="{static("js/" + filename)}"></script>')
|
||||
|
||||
|
||||
# Media for the Flowbite-datepicker year picker (vendored UMD bundle). Declared
|
||||
# on the YearPicker node so Page() loads it wherever a YearPicker appears.
|
||||
_YEAR_PICKER_MEDIA = Media(js_external=("datepicker.umd.js",))
|
||||
|
||||
|
||||
def YearPicker(
|
||||
year: int | None = None,
|
||||
available_years: tuple[int, ...] = (),
|
||||
url_template: str = "",
|
||||
) -> SafeText:
|
||||
) -> Node:
|
||||
"""A Flowbite-datepicker year picker.
|
||||
|
||||
`year` is the selected year, or ``None`` for the all-time view (the empty
|
||||
@@ -471,8 +560,8 @@ def YearPicker(
|
||||
placeholder, substituted with the chosen year in JS (keeps this component
|
||||
decoupled from the project's URL names).
|
||||
|
||||
The Flowbite-datepicker UMD bundle is *not* loaded here — the view hoists it
|
||||
via ``render_page(scripts=...)``.
|
||||
The Flowbite-datepicker UMD bundle is declared as ``media`` on the returned
|
||||
node, so ``Page()`` loads it automatically.
|
||||
"""
|
||||
label = str(year) if year is not None else "Choose a year"
|
||||
selected = str(year) if year is not None else ""
|
||||
@@ -483,7 +572,8 @@ def YearPicker(
|
||||
"hover:bg-neutral-tertiary-medium focus:ring-4 focus:ring-brand-medium"
|
||||
)
|
||||
years_csv = ",".join(str(y) for y in available_years)
|
||||
return mark_safe(f"""<div class="relative inline-block" x-data="{{ pickerOpen: false }}"
|
||||
return Safe(
|
||||
f"""<div class="relative inline-block" x-data="{{ pickerOpen: false }}"
|
||||
@keydown.escape.window="pickerOpen = false">
|
||||
<button type="button"
|
||||
x-on:click="pickerOpen = !pickerOpen; $refs.pickerInput._pickerInstance && ($refs.pickerInput._pickerInstance.active ? $refs.pickerInput._pickerInstance.hide() : $refs.pickerInput._pickerInstance.show())"
|
||||
@@ -536,17 +626,19 @@ document.addEventListener('DOMContentLoaded', () => {{
|
||||
picker.update();
|
||||
}}
|
||||
}});
|
||||
</script>""")
|
||||
</script>""",
|
||||
media=_YEAR_PICKER_MEDIA,
|
||||
)
|
||||
|
||||
|
||||
def AddForm(
|
||||
form,
|
||||
*,
|
||||
request,
|
||||
fields: SafeText | str | None = None,
|
||||
additional_row: SafeText | str = "",
|
||||
fields: Node | SafeText | str | None = None,
|
||||
additional_row: Node | SafeText | str = "",
|
||||
submit_class: str = "mt-3",
|
||||
) -> SafeText:
|
||||
) -> Node:
|
||||
"""Page body for the generic add/edit form (Python equivalent of add.html).
|
||||
|
||||
`fields` overrides the default ``form.as_div()`` field markup (used by the
|
||||
@@ -555,16 +647,16 @@ def AddForm(
|
||||
is applied to the main Submit button (the session form passes "" to match
|
||||
its original markup).
|
||||
"""
|
||||
field_markup = fields if fields is not None else mark_safe(form.as_div())
|
||||
field_markup = fields if fields is not None else Safe(form.as_div())
|
||||
submit_attrs = [("class", submit_class)] if submit_class else []
|
||||
|
||||
inner_form = Component(
|
||||
tag_name="form",
|
||||
inner_form = Element(
|
||||
"form",
|
||||
attributes=[("method", "post"), ("enctype", "multipart/form-data")],
|
||||
children=[
|
||||
CsrfInput(request),
|
||||
field_markup,
|
||||
Div(children=[Button(submit_attrs, "Submit", type="submit")]),
|
||||
Div(children=[StyledButton(submit_attrs, "Submit", type="submit")]),
|
||||
Div(
|
||||
[("class", "submit-button-container")],
|
||||
[additional_row] if additional_row else [],
|
||||
@@ -587,10 +679,10 @@ def SearchField(
|
||||
search_string: str = "",
|
||||
id: str = "search_string",
|
||||
placeholder: str = "Search",
|
||||
) -> SafeText:
|
||||
) -> Element:
|
||||
"""Generate a search form with icon, input field, and submit button."""
|
||||
return Component(
|
||||
tag_name="form",
|
||||
return Element(
|
||||
"form",
|
||||
attributes=[("class", "max-w-md")],
|
||||
children=[
|
||||
Label(
|
||||
@@ -600,11 +692,10 @@ def SearchField(
|
||||
],
|
||||
children=["Search"],
|
||||
),
|
||||
Component(
|
||||
tag_name="div",
|
||||
Div(
|
||||
attributes=[("class", "relative")],
|
||||
children=[
|
||||
mark_safe(
|
||||
Safe(
|
||||
'<div class="absolute inset-y-0 start-0 flex items-center ps-3 pointer-events-none">'
|
||||
'<svg class="w-4 h-4 text-body" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" '
|
||||
'fill="none" viewBox="0 0 24 24">'
|
||||
@@ -612,10 +703,9 @@ def SearchField(
|
||||
'd="m21 21-3.5-3.5M17 10a7 7 0 1 1-14 0 7 7 0 0 1 14 0Z"/>'
|
||||
"</svg></div>"
|
||||
),
|
||||
Component(
|
||||
tag_name="input",
|
||||
Input(
|
||||
type="search",
|
||||
attributes=[
|
||||
("type", "search"),
|
||||
("id", id),
|
||||
("name", id),
|
||||
("value", search_string),
|
||||
@@ -630,8 +720,8 @@ def SearchField(
|
||||
("required", ""),
|
||||
],
|
||||
),
|
||||
Component(
|
||||
tag_name="button",
|
||||
Element(
|
||||
"button",
|
||||
attributes=[
|
||||
("type", "submit"),
|
||||
(
|
||||
@@ -652,13 +742,13 @@ def SearchField(
|
||||
|
||||
|
||||
def H1(
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
children: Children = None,
|
||||
badge: str = "",
|
||||
) -> SafeText:
|
||||
) -> Element:
|
||||
"""Heading with optional badge count."""
|
||||
children = children or []
|
||||
heading_class = "mb-4 text-3xl font-extrabold leading-none tracking-tight text-gray-900 dark:text-white"
|
||||
badge_html = ""
|
||||
badge_html: Node | str = ""
|
||||
|
||||
if badge:
|
||||
heading_class = "flex items-center " + heading_class
|
||||
@@ -673,22 +763,20 @@ def H1(
|
||||
children=[badge],
|
||||
)
|
||||
|
||||
return Component(
|
||||
tag_name="h1",
|
||||
return Element(
|
||||
"h1",
|
||||
attributes=[("class", heading_class)],
|
||||
children=(children if isinstance(children, list) else [children])
|
||||
+ ([badge_html] if badge_html else []),
|
||||
children=as_children(children) + ([badge_html] if badge_html else []),
|
||||
)
|
||||
|
||||
|
||||
def Modal(
|
||||
modal_id: str,
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
) -> SafeText:
|
||||
children: Children = None,
|
||||
) -> Node:
|
||||
"""Modal overlay with container. Content (form, buttons) goes in children."""
|
||||
children = children or []
|
||||
outer = Component(
|
||||
tag_name="div",
|
||||
return Div(
|
||||
attributes=[
|
||||
("id", modal_id),
|
||||
(
|
||||
@@ -698,8 +786,7 @@ def Modal(
|
||||
),
|
||||
],
|
||||
children=[
|
||||
Component(
|
||||
tag_name="div",
|
||||
Div(
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
@@ -707,26 +794,24 @@ def Modal(
|
||||
"shadow-lg/50 rounded-md bg-white dark:bg-gray-900",
|
||||
),
|
||||
],
|
||||
children=(children if isinstance(children, list) else [children]),
|
||||
children=as_children(children),
|
||||
),
|
||||
],
|
||||
)
|
||||
return mark_safe(str(outer))
|
||||
|
||||
|
||||
def TableTd(
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
) -> SafeText:
|
||||
children: Children = None,
|
||||
) -> Element:
|
||||
"""Styled table cell."""
|
||||
children = children or []
|
||||
return Component(
|
||||
tag_name="td",
|
||||
return Td(
|
||||
attributes=[("class", "px-6 py-4 min-w-20-char max-w-20-char")],
|
||||
children=children if isinstance(children, list) else [children],
|
||||
children=as_children(children),
|
||||
)
|
||||
|
||||
|
||||
def TableRow(data: dict | list | None = None) -> SafeText:
|
||||
def TableRow(data: dict | list | None = None) -> Element:
|
||||
"""Generate a <tr> from a row data dict or list.
|
||||
|
||||
Dict form: {"row_id": "...", "cell_data": [...], "hx_trigger": ..., ...}
|
||||
@@ -761,12 +846,11 @@ def TableRow(data: dict | list | None = None) -> SafeText:
|
||||
if data.get("hx_swap"):
|
||||
tr_attrs.append(("hx-swap", data["hx_swap"]))
|
||||
|
||||
cell_elements: list[SafeText] = []
|
||||
cell_elements: list[Node] = []
|
||||
for i, cell in enumerate(cells):
|
||||
if i == 0:
|
||||
cell_elements.append(
|
||||
Component(
|
||||
tag_name="th",
|
||||
Th(
|
||||
attributes=[
|
||||
("scope", "row"),
|
||||
(
|
||||
@@ -781,23 +865,23 @@ def TableRow(data: dict | list | None = None) -> SafeText:
|
||||
else:
|
||||
cell_elements.append(TableTd(children=[cell]))
|
||||
|
||||
return Component(tag_name="tr", attributes=tr_attrs, children=cell_elements)
|
||||
return Tr(attributes=tr_attrs, children=cell_elements)
|
||||
|
||||
|
||||
def Icon(
|
||||
name: str,
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
) -> SafeText:
|
||||
return mark_safe(get_icon(name))
|
||||
attributes: Attributes | None = None,
|
||||
) -> Node:
|
||||
return Safe(get_icon(name))
|
||||
|
||||
|
||||
def TableHeader(
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
) -> SafeText:
|
||||
children: Children = None,
|
||||
) -> Element:
|
||||
"""Table caption."""
|
||||
children = children or []
|
||||
return Component(
|
||||
tag_name="caption",
|
||||
return Element(
|
||||
"caption",
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
@@ -805,7 +889,7 @@ def TableHeader(
|
||||
"text-gray-900 bg-white dark:text-white dark:bg-gray-900",
|
||||
),
|
||||
],
|
||||
children=children if isinstance(children, list) else [children],
|
||||
children=as_children(children),
|
||||
)
|
||||
|
||||
|
||||
@@ -884,30 +968,41 @@ def _pagination_nav(page_obj, elided_page_range, request) -> str:
|
||||
def SimpleTable(
|
||||
columns: list[str] | None = None,
|
||||
rows: list | None = None,
|
||||
header_action: SafeText | str | None = None,
|
||||
header_action: Child | None = None,
|
||||
page_obj=None,
|
||||
elided_page_range=None,
|
||||
request=None,
|
||||
) -> SafeText:
|
||||
) -> Node:
|
||||
"""Paginated table. Python equivalent of the old simple_table.html."""
|
||||
columns = columns or []
|
||||
rows = rows or []
|
||||
|
||||
# Rows/header are stringified into the table markup, so their components'
|
||||
# declared Media would be lost; collect it from the nodes first and attach
|
||||
# it to the returned node so Page() still emits each cell component's JS
|
||||
# (e.g. a <game-status-selector> in a cell).
|
||||
media = Media()
|
||||
|
||||
header_html = ""
|
||||
if header_action:
|
||||
header_html = str(TableHeader(children=[header_action]))
|
||||
header_node = TableHeader(children=[header_action])
|
||||
header_html = str(header_node)
|
||||
media = media + collect_media(header_node)
|
||||
|
||||
columns_html = "".join(
|
||||
f'<th scope="col" class="px-6 py-3">{conditional_escape(col)}</th>'
|
||||
for col in columns
|
||||
)
|
||||
rows_html = "".join(str(TableRow(data=row)) for row in rows)
|
||||
row_nodes = [TableRow(data=row) for row in rows]
|
||||
rows_html = "".join(str(node) for node in row_nodes)
|
||||
for node in row_nodes:
|
||||
media = media + collect_media(node)
|
||||
|
||||
pagination_html = ""
|
||||
if page_obj and elided_page_range:
|
||||
pagination_html = _pagination_nav(page_obj, elided_page_range, request)
|
||||
|
||||
return mark_safe(
|
||||
return Safe(
|
||||
'<div class="shadow-md" hx-boost="false">'
|
||||
'<div class="relative overflow-x-auto sm:rounded-t-lg">'
|
||||
'<table class="w-full text-sm text-left rtl:text-right text-gray-500 dark:text-gray-400">'
|
||||
@@ -917,7 +1012,8 @@ def SimpleTable(
|
||||
f"<tr>{columns_html}</tr></thead>"
|
||||
'<tbody class="dark:divide-y max-sm:[&_td:not(:first-child):not(:last-child)]:hidden">'
|
||||
f"{rows_html}</tbody></table></div>"
|
||||
f"{pagination_html}</div>"
|
||||
f"{pagination_html}</div>",
|
||||
media=media,
|
||||
)
|
||||
|
||||
|
||||
@@ -927,7 +1023,7 @@ def paginated_table_content(
|
||||
page_obj=None,
|
||||
elided_page_range=None,
|
||||
request=None,
|
||||
) -> SafeText:
|
||||
) -> Node:
|
||||
"""Standard list-page body: a max-width Div wrapping a SimpleTable.
|
||||
|
||||
`data` is the table dict with keys ``columns``, ``rows`` and
|
||||
|
||||
@@ -21,11 +21,13 @@ user types.
|
||||
from collections.abc import Callable, Iterable
|
||||
from typing import TypedDict
|
||||
|
||||
from django.utils.safestring import SafeText
|
||||
|
||||
from common.components.core import Component, HTMLAttribute
|
||||
from common.components.core import Attributes, Element, HTMLAttribute, Media, Node
|
||||
from common.components.primitives import Div, Input, Pill, Span, Template
|
||||
|
||||
# Both comboboxes are wired by search_select.js.
|
||||
_SEARCH_SELECT_MEDIA = Media(js=("search_select.js",))
|
||||
|
||||
|
||||
class SearchSelectOption(TypedDict):
|
||||
value: str | int
|
||||
@@ -60,18 +62,29 @@ _OPTIONS_CLASS = (
|
||||
"absolute z-10 top-full left-0 right-0 mt-1 overflow-y-auto "
|
||||
"border border-default-medium rounded-base bg-neutral-secondary-medium shadow-lg"
|
||||
)
|
||||
_OPTION_ROW_CLASS = "px-3 py-2 text-sm text-heading cursor-pointer hover:bg-brand/15"
|
||||
_OPTION_ROW_CLASS = (
|
||||
"px-3 py-2 text-sm text-heading cursor-pointer "
|
||||
"hover:bg-brand/15 data-[search-select-highlighted]:bg-brand/15"
|
||||
)
|
||||
_NO_RESULTS_CLASS = "px-3 py-2 text-sm italic text-body hidden"
|
||||
|
||||
# Approximate rendered height of one option row (px-3 py-2 text-sm) in rem,
|
||||
# used to derive the panel's max-height from items_visible.
|
||||
_ROW_HEIGHT_REM = 2.25
|
||||
|
||||
# Default number of rows to fetch on first focus when a search_url is set.
|
||||
# Shared by filter and form widgets so the dropdown is populated for keyboard
|
||||
# navigation as soon as the user opens it.
|
||||
DEFAULT_PREFETCH = 20
|
||||
|
||||
# ── FilterSelect styling ───────────────────────────────────────────────────
|
||||
# Inline class strings (ported verbatim from the retired SelectableFilter CSS)
|
||||
# so the filter combobox is fully self-styled — nothing in input.css. JS-added
|
||||
# rows/pills are cloned from server-rendered <template>s, so these strings live
|
||||
# only here — never duplicated in search_select.js.
|
||||
# only here — never duplicated in search_select.js. The keyboard-highlighted
|
||||
# state is expressed via Tailwind `data-[search-select-highlighted]` and
|
||||
# `group-data-[search-select-highlighted]` variants on the row/label/button
|
||||
# classes below; the JS only toggles the data attribute on the row.
|
||||
_FILTER_INCLUDE_PILL_CLASS = (
|
||||
"inline-flex items-center gap-1 px-2 py-0.5 text-sm rounded "
|
||||
"bg-brand/15 text-heading"
|
||||
@@ -86,17 +99,28 @@ _FILTER_MODIFIER_PILL_CLASS = (
|
||||
)
|
||||
_FILTER_PILL_REMOVE_CLASS = "ml-1 text-body hover:text-heading font-bold cursor-pointer"
|
||||
_FILTER_OPTION_ROW_CLASS = (
|
||||
"flex items-center justify-between px-2 py-1 rounded text-sm "
|
||||
"hover:bg-neutral-secondary-strong cursor-pointer"
|
||||
"group flex items-center justify-between px-2 py-1 rounded text-sm "
|
||||
"hover:bg-neutral-secondary-strong cursor-pointer "
|
||||
"data-[search-select-highlighted]:bg-brand "
|
||||
"data-[search-select-highlighted]:outline data-[search-select-highlighted]:outline-1 "
|
||||
"data-[search-select-highlighted]:outline-brand-strong"
|
||||
)
|
||||
_FILTER_OPTION_LABEL_CLASS = (
|
||||
"truncate text-body group-data-[search-select-highlighted]:text-white"
|
||||
)
|
||||
_FILTER_OPTION_LABEL_CLASS = "truncate text-body"
|
||||
_FILTER_OPTION_BUTTONS_CLASS = "flex gap-1 ml-2 shrink-0"
|
||||
# text-body keeps the +/− readable on dark backgrounds; hover:border-brand-strong
|
||||
# keeps the edge visible against the brand hover fill.
|
||||
# keeps the edge visible against the brand hover fill. When the row is the
|
||||
# keyboard-highlighted one its bg is brand, so the button text/border switch
|
||||
# to white and the hover fill shifts to brand-strong for contrast.
|
||||
_FILTER_ACTION_BUTTON_CLASS = (
|
||||
"w-5 h-5 flex items-center justify-center text-xs font-bold rounded text-body "
|
||||
"border border-brand "
|
||||
"hover:bg-brand hover:text-white hover:border-brand-strong"
|
||||
"hover:bg-brand hover:text-white hover:border-brand-strong "
|
||||
"group-data-[search-select-highlighted]:text-white "
|
||||
"group-data-[search-select-highlighted]:border-white "
|
||||
"group-data-[search-select-highlighted]:hover:bg-brand-strong "
|
||||
"group-data-[search-select-highlighted]:hover:border-white"
|
||||
)
|
||||
_FILTER_MODIFIER_ROW_CLASS = (
|
||||
"px-2 py-1 text-sm text-body hover:bg-neutral-secondary-strong cursor-pointer"
|
||||
@@ -119,11 +143,11 @@ def _data_attributes(data: dict[str, str]) -> list[HTMLAttribute]:
|
||||
return [(f"data-{key}", str(value)) for key, value in data.items()]
|
||||
|
||||
|
||||
def _hidden_input(name: str, value) -> SafeText:
|
||||
def _hidden_input(name: str, value) -> Node:
|
||||
return Input(type="hidden", attributes=[("name", name), ("value", str(value))])
|
||||
|
||||
|
||||
def _label_slot(text: str, *, extra_class: str = "") -> SafeText:
|
||||
def _label_slot(text: str, *, extra_class: str = "") -> Node:
|
||||
"""A ``<span data-search-select-label>`` holding a row/pill's visible label. JS fills this
|
||||
one node when cloning the shape from a ``<template>``, so labels are the only
|
||||
thing the JS sets — all classes and structure stay server-side."""
|
||||
@@ -137,7 +161,7 @@ def _label_slot(text: str, *, extra_class: str = "") -> SafeText:
|
||||
_BLANK_OPTION: SearchSelectOption = {"value": "", "label": "", "data": {}}
|
||||
|
||||
|
||||
def _option_row(option: SearchSelectOption) -> SafeText:
|
||||
def _option_row(option: SearchSelectOption) -> Node:
|
||||
return Div(
|
||||
attributes=[
|
||||
("data-search-select-option", ""),
|
||||
@@ -152,14 +176,14 @@ def _option_row(option: SearchSelectOption) -> SafeText:
|
||||
|
||||
def _combobox_shell(
|
||||
*,
|
||||
container_attributes: list[HTMLAttribute],
|
||||
pills: SafeText,
|
||||
search_attributes: list[HTMLAttribute],
|
||||
options_children: list[SafeText],
|
||||
container_attributes: Attributes,
|
||||
pills: Node,
|
||||
search_attributes: Attributes,
|
||||
options_children: list[Node],
|
||||
always_visible: bool,
|
||||
items_visible: int,
|
||||
templates: list[SafeText] | None = None,
|
||||
) -> SafeText:
|
||||
templates: list[Node] | None = None,
|
||||
) -> Node:
|
||||
"""Assemble the shared, domain-agnostic combobox skeleton.
|
||||
|
||||
Every combobox built on top of this shell has the same three regions in the
|
||||
@@ -191,7 +215,7 @@ def _combobox_shell(
|
||||
children=[*options_children, no_results],
|
||||
)
|
||||
|
||||
children: list[SafeText] = [pills, search, options_panel, *(templates or [])]
|
||||
children: list[Node] = [pills, search, options_panel, *(templates or [])]
|
||||
return Div(attributes=container_attributes, children=children)
|
||||
|
||||
|
||||
@@ -210,7 +234,7 @@ def SearchSelect(
|
||||
id: str = "",
|
||||
sync_url: bool = False,
|
||||
autofocus: bool = False,
|
||||
) -> SafeText:
|
||||
) -> Node:
|
||||
"""Render the search-select widget. See module docstring for the contract."""
|
||||
selected = [_normalize_option(option) for option in (selected or [])]
|
||||
options = [_normalize_option(option) for option in (options or [])]
|
||||
@@ -220,7 +244,7 @@ def SearchSelect(
|
||||
# pill — the committed label shows inside the search box instead, with a
|
||||
# lone hidden input carrying the value. Both keep the hidden input(s) inside
|
||||
# `[data-search-select-pills]` so the JS reads/writes values uniformly.
|
||||
pills_children: list[SafeText] = []
|
||||
pills_children: list[Node] = []
|
||||
search_value = ""
|
||||
if multi_select:
|
||||
for option in selected:
|
||||
@@ -261,7 +285,7 @@ def SearchSelect(
|
||||
|
||||
# ── Templates the JS clones: a row when results are fetched, a pill when
|
||||
# multi-select adds chosen items. ──
|
||||
templates: list[SafeText] = []
|
||||
templates: list[Node] = []
|
||||
if search_url:
|
||||
templates.append(
|
||||
Template(
|
||||
@@ -300,12 +324,12 @@ def SearchSelect(
|
||||
always_visible=always_visible,
|
||||
items_visible=items_visible,
|
||||
templates=templates,
|
||||
)
|
||||
).with_media(_SEARCH_SELECT_MEDIA)
|
||||
|
||||
|
||||
def _filter_remove_button() -> SafeText:
|
||||
return Component(
|
||||
tag_name="button",
|
||||
def _filter_remove_button() -> Node:
|
||||
return Element(
|
||||
"button",
|
||||
attributes=[
|
||||
("type", "button"),
|
||||
("data-pill-remove", ""),
|
||||
@@ -316,7 +340,7 @@ def _filter_remove_button() -> SafeText:
|
||||
)
|
||||
|
||||
|
||||
def _filter_value_pill(option: SearchSelectOption, kind: str) -> SafeText:
|
||||
def _filter_value_pill(option: SearchSelectOption, kind: str) -> Node:
|
||||
"""An include (✓) or exclude (✗) value pill. ``kind`` is "include"/"exclude"."""
|
||||
symbol = "✓" if kind == "include" else "✗"
|
||||
css = (
|
||||
@@ -335,7 +359,7 @@ def _filter_value_pill(option: SearchSelectOption, kind: str) -> SafeText:
|
||||
)
|
||||
|
||||
|
||||
def _filter_modifier_pill(modifier_value: str, label: str) -> SafeText:
|
||||
def _filter_modifier_pill(modifier_value: str, label: str) -> Node:
|
||||
"""The lone, sticky modifier pill (e.g. "(Any)"/"(None)")."""
|
||||
return Span(
|
||||
attributes=[
|
||||
@@ -347,9 +371,9 @@ def _filter_modifier_pill(modifier_value: str, label: str) -> SafeText:
|
||||
)
|
||||
|
||||
|
||||
def _filter_action_button(action: str, symbol: str, title: str) -> SafeText:
|
||||
return Component(
|
||||
tag_name="button",
|
||||
def _filter_action_button(action: str, symbol: str, title: str) -> Node:
|
||||
return Element(
|
||||
"button",
|
||||
attributes=[
|
||||
("type", "button"),
|
||||
("data-search-select-action", action),
|
||||
@@ -360,7 +384,7 @@ def _filter_action_button(action: str, symbol: str, title: str) -> SafeText:
|
||||
)
|
||||
|
||||
|
||||
def _filter_option_row(value: str | int, label: str) -> SafeText:
|
||||
def _filter_option_row(value: str | int, label: str) -> Node:
|
||||
"""A value row with include (+) and exclude (−) buttons."""
|
||||
return Div(
|
||||
attributes=[
|
||||
@@ -382,7 +406,7 @@ def _filter_option_row(value: str | int, label: str) -> SafeText:
|
||||
)
|
||||
|
||||
|
||||
def _filter_modifier_row(modifier_value: str, label: str) -> SafeText:
|
||||
def _filter_modifier_row(modifier_value: str, label: str) -> Node:
|
||||
"""A pinned pseudo-option row. It carries no ``data-search-select-option`` so the text
|
||||
filter never hides it — modifiers stay visible at the top of the panel."""
|
||||
return Div(
|
||||
@@ -409,7 +433,8 @@ def FilterSelect(
|
||||
items_scroll: int = 10,
|
||||
placeholder: str = "Search…",
|
||||
id: str = "",
|
||||
) -> SafeText:
|
||||
free_text: bool = False,
|
||||
) -> Node:
|
||||
"""Include/exclude filter combobox built on the shared ``_combobox_shell``.
|
||||
|
||||
Like ``SearchSelect`` but each value row carries +/− buttons that add an
|
||||
@@ -425,6 +450,11 @@ def FilterSelect(
|
||||
``included``/``excluded`` are resolved options (value + label) so pills show
|
||||
labels even when the value rows come from ``search_url``. ``options``
|
||||
pre-renders the value rows for the complete-set (no ``search_url``) case.
|
||||
|
||||
``free_text`` turns the widget into a typed-pill input: there is no backing
|
||||
option list, the JS builds an ephemeral option row from whatever the user
|
||||
types so the +/− buttons (and Enter) commit the typed string itself as an
|
||||
include / exclude pill.
|
||||
"""
|
||||
options = [_normalize_option(option) for option in (options or [])]
|
||||
included = [_normalize_option(option) for option in (included or [])]
|
||||
@@ -442,7 +472,7 @@ def FilterSelect(
|
||||
# pills — but the stored state guarantees they never coexist, so we render
|
||||
# both channels unconditionally. Non-presence modifiers (INCLUDES_ALL /
|
||||
# INCLUDES_ONLY) coexist with value pills and render side by side.
|
||||
pills_children: list[SafeText] = []
|
||||
pills_children: list[Node] = []
|
||||
if active_modifier_label:
|
||||
pills_children.append(_filter_modifier_pill(modifier, active_modifier_label))
|
||||
for option in included:
|
||||
@@ -476,7 +506,7 @@ def FilterSelect(
|
||||
|
||||
# ── Templates the JS clones: include/exclude pills (added on click), the
|
||||
# modifier pill (when modifiers exist), and a value row (when fetched). ──
|
||||
templates: list[SafeText] = [
|
||||
templates: list[Node] = [
|
||||
Template(
|
||||
attributes=[("data-search-select-template", "pill-include")],
|
||||
children=[_filter_value_pill(_BLANK_OPTION, "include")],
|
||||
@@ -493,7 +523,7 @@ def FilterSelect(
|
||||
children=[_filter_modifier_pill("", "")],
|
||||
)
|
||||
)
|
||||
if search_url:
|
||||
if search_url or free_text:
|
||||
templates.append(
|
||||
Template(
|
||||
attributes=[("data-search-select-template", "row")],
|
||||
@@ -514,6 +544,8 @@ def FilterSelect(
|
||||
("data-sync-url", "false"),
|
||||
("class", _CONTAINER_CLASS),
|
||||
]
|
||||
if free_text:
|
||||
container_attributes.append(("data-search-select-free-text", "true"))
|
||||
if modifier:
|
||||
container_attributes.append(("data-modifier", modifier))
|
||||
if id:
|
||||
@@ -527,7 +559,7 @@ def FilterSelect(
|
||||
always_visible=False,
|
||||
items_visible=items_visible,
|
||||
templates=templates,
|
||||
)
|
||||
).with_media(_SEARCH_SELECT_MEDIA)
|
||||
|
||||
|
||||
def searchselect_selected(
|
||||
|
||||
+1
-4
@@ -206,12 +206,9 @@ textarea:disabled {
|
||||
label {
|
||||
@apply mb-2.5 text-sm font-medium text-heading;
|
||||
}
|
||||
input:not([type="checkbox"]) {
|
||||
input:not([type="checkbox"]):not([data-search-select-search]) {
|
||||
@apply mb-3 bg-neutral-secondary-medium border border-default-medium text-heading text-sm rounded-base focus:ring-brand focus:border-brand block w-full px-3 py-2.5 shadow-xs placeholder:text-body;
|
||||
}
|
||||
input[type="checkbox"] {
|
||||
@apply w-4 h-4 border border-default-medium rounded-xs bg-neutral-secondary-medium focus:ring-2 focus:ring-brand-soft;
|
||||
}
|
||||
select {
|
||||
@apply w-full px-3 py-2.5 bg-neutral-secondary-medium border border-default-medium text-heading text-sm rounded-base focus:ring-brand focus:border-brand shadow-xs placeholder:text-body;
|
||||
}
|
||||
|
||||
+45
-13
@@ -8,9 +8,11 @@ it hoists shared `<head>` content (the `_HEADERS` block, analogous to
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from django.contrib.messages import get_messages
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.middleware.csrf import get_token
|
||||
from django.templatetags.static import static
|
||||
from django.urls import reverse
|
||||
from django.utils.html import conditional_escape
|
||||
@@ -19,6 +21,9 @@ from django_htmx.jinja import django_htmx_script
|
||||
|
||||
from games.templatetags.version import version, version_date
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from common.components import Node
|
||||
|
||||
# Static head script that sets the dark/light class before paint (avoids FOUC).
|
||||
_THEME_FOUC_SCRIPT = """<script>
|
||||
if (localStorage.getItem('color-theme') === 'dark' || (!('color-theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
@@ -182,10 +187,16 @@ def _main_script(mastered: bool) -> str:
|
||||
return _MAIN_SCRIPT_A + ("true" if mastered else "false") + _MAIN_SCRIPT_B
|
||||
|
||||
|
||||
def Navbar(*, today_played: str, last_7_played: str, current_year: int) -> SafeText:
|
||||
"""Top navigation bar."""
|
||||
def Navbar(*, today_played: str, last_7_played: str, current_year: int, csrf_token: str) -> "Node":
|
||||
"""Top navigation bar.
|
||||
|
||||
Static chrome, so it's a single ``Safe`` node wrapping its markup rather
|
||||
than a hand-built element tree — trusted HTML belongs in a ``Safe`` node,
|
||||
not a ``mark_safe`` string."""
|
||||
from common.components import Safe
|
||||
|
||||
logo = static("icons/schedule.png")
|
||||
return mark_safe(f"""<nav class="bg-neutral-primary-soft border-b border-default">
|
||||
return Safe(f"""<nav class="bg-neutral-primary-soft border-b border-default">
|
||||
<div class="max-w-(--breakpoint-xl) flex flex-wrap items-center justify-between mx-auto p-4">
|
||||
<a href="{reverse("games:index")}"
|
||||
class="flex items-center space-x-3 rtl:space-x-reverse">
|
||||
@@ -260,7 +271,10 @@ def Navbar(*, today_played: str, last_7_played: str, current_year: int) -> SafeT
|
||||
<a href="{reverse("games:stats_by_year", args=[current_year])}" class="block py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent">Stats</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{reverse("logout")}" class="block py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent">Log out</a>
|
||||
<form method="post" action="{reverse("logout")}">
|
||||
<input type="hidden" name="csrfmiddlewaretoken" value="{csrf_token}">
|
||||
<button type="submit" class="block py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent">Log out</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -269,22 +283,37 @@ def Navbar(*, today_played: str, last_7_played: str, current_year: int) -> SafeT
|
||||
|
||||
|
||||
def Page(
|
||||
content: SafeText | str,
|
||||
content: "Node | SafeText | str",
|
||||
*,
|
||||
request: HttpRequest,
|
||||
title: str = "",
|
||||
scripts: SafeText | str = "",
|
||||
scripts: "Node | SafeText | str" = "",
|
||||
mastered: bool = False,
|
||||
) -> SafeText:
|
||||
"""Assemble a full HTML document around `content` (the fast_app equivalent)."""
|
||||
"""Assemble a full HTML document around `content` (the fast_app equivalent).
|
||||
|
||||
Scripts are collected from `content`'s component tree: every component
|
||||
declares its JS via `Media`, and `collect_media` gathers (deduped) the union
|
||||
for the whole page. The `scripts` argument remains for page-specific glue
|
||||
that isn't owned by a reusable component (e.g. the add-form helpers).
|
||||
"""
|
||||
from common.components import ModuleScript, StaticScript, collect_media
|
||||
from games.views.general import global_current_year, model_counts
|
||||
|
||||
media = collect_media(content)
|
||||
collected_scripts = "".join(
|
||||
[str(ModuleScript(name)) for name in media.js]
|
||||
+ [str(StaticScript(name)) for name in media.js_external]
|
||||
)
|
||||
all_scripts = collected_scripts + (str(scripts) if scripts else "")
|
||||
|
||||
counts = model_counts(request)
|
||||
year = global_current_year(request)["global_current_year"]
|
||||
navbar = Navbar(
|
||||
today_played=counts["today_played"],
|
||||
last_7_played=counts["last_7_played"],
|
||||
current_year=year,
|
||||
csrf_token=get_token(request),
|
||||
)
|
||||
|
||||
messages = [
|
||||
@@ -309,9 +338,12 @@ def Page(
|
||||
f' <script src="{static("js/htmx-redirect-toast.js")}"></script>\n'
|
||||
f" {django_htmx_script(nonce=None)}\n"
|
||||
f' <link rel="stylesheet" href="{static("base.css")}" />\n'
|
||||
' <script src="https://cdn.jsdelivr.net/npm/flowbite@2.4.1/dist/flowbite.min.js"></script>\n'
|
||||
' <script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/mask@3.x.x/dist/cdn.min.js"></script>\n'
|
||||
' <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>\n'
|
||||
# Vendored bundles (flowbite 2.4.1, alpinejs/@alpinejs/mask 3.15.12) —
|
||||
# served locally so pages work offline (and in browser tests). The mask
|
||||
# plugin must load before Alpine core; both stay deferred.
|
||||
f' <script src="{static("js/flowbite.min.js")}"></script>\n'
|
||||
f' <script defer src="{static("js/alpine-mask.min.js")}"></script>\n'
|
||||
f' <script defer src="{static("js/alpine.min.js")}"></script>\n'
|
||||
f" {_THEME_FOUC_SCRIPT}\n"
|
||||
" </head>\n"
|
||||
)
|
||||
@@ -325,7 +357,7 @@ def Page(
|
||||
f' <div id="main-container" class="flex flex-1 flex-col pt-8 pb-16">{content}</div>\n'
|
||||
f' <span class="fixed left-2 bottom-2 text-xs text-slate-300 dark:text-slate-600">{version()} ({version_date()})</span>\n'
|
||||
" </div>\n"
|
||||
f" {scripts}\n"
|
||||
f" {all_scripts}\n"
|
||||
f" {_main_script(mastered)}\n"
|
||||
" <!-- hx-swap-oob makes sure the modal gets removed upon any HTMX response -->\n"
|
||||
' <div id="global-modal-container" hx-swap-oob="true"></div>\n'
|
||||
@@ -339,10 +371,10 @@ def Page(
|
||||
|
||||
def render_page(
|
||||
request: HttpRequest,
|
||||
content: SafeText | str,
|
||||
content: "Node | SafeText | str",
|
||||
*,
|
||||
title: str = "",
|
||||
scripts: SafeText | str = "",
|
||||
scripts: "Node | SafeText | str" = "",
|
||||
mastered: bool = False,
|
||||
status: int = 200,
|
||||
) -> HttpResponse:
|
||||
|
||||
@@ -1,17 +1,43 @@
|
||||
import re
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import NamedTuple
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
from common.utils import generate_split_ranges
|
||||
|
||||
dateformat: str = "%d/%m/%Y"
|
||||
dateformat_hyphenated: str = "%d-%m-%Y"
|
||||
datetimeformat: str = "%d/%m/%Y %H:%M"
|
||||
timeformat: str = "%H:%M"
|
||||
durationformat: str = "%2.1H hours"
|
||||
durationformat_manual: str = "%H hours"
|
||||
|
||||
|
||||
class DatePartSpec(NamedTuple):
|
||||
"""One date part (day/month/year) of a hyphenated date format."""
|
||||
|
||||
name: str
|
||||
placeholder: str
|
||||
length: int
|
||||
|
||||
|
||||
_DATE_PART_SPECS: dict[str, DatePartSpec] = {
|
||||
"%d": DatePartSpec("day", "DD", 2),
|
||||
"%m": DatePartSpec("month", "MM", 2),
|
||||
"%Y": DatePartSpec("year", "YYYY", 4),
|
||||
}
|
||||
|
||||
|
||||
def date_parts(format_string: str = dateformat_hyphenated) -> list[DatePartSpec]:
|
||||
"""Split a hyphenated strftime date format into its ordered parts.
|
||||
|
||||
``"%d-%m-%Y"`` becomes ``[day, month, year]`` specs, each carrying the
|
||||
placeholder text (``DD``/``MM``/``YYYY``) and digit length shown by the
|
||||
DateRangeField segments."""
|
||||
return [_DATE_PART_SPECS[directive] for directive in format_string.split("-")]
|
||||
|
||||
|
||||
def _safe_timedelta(duration: timedelta | int | None):
|
||||
if duration is None:
|
||||
return timedelta(0)
|
||||
|
||||
@@ -12,6 +12,7 @@ services:
|
||||
- PUID=${PUID:-1000}
|
||||
- PGID=${PGID:-100}
|
||||
- DATA_DIR=${DATA_DIR:-/home/timetracker/app/data}
|
||||
- CREATE_DEFAULT_SUPERUSER=${CREATE_DEFAULT_SUPERUSER:-false}
|
||||
ports:
|
||||
- "${TIMETRACKER_EXTERNAL_PORT:-8000}:8000"
|
||||
volumes:
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# Custom Element API: Two patterns, one goal
|
||||
|
||||
## Pattern 1: Named builder (current, preferred)
|
||||
|
||||
A tag builder with auto-attached `Media`, created via `custom_element_builder()`:
|
||||
|
||||
```python
|
||||
# definition (custom_elements.py)
|
||||
SessionTimestampButtons = custom_element_builder("session-timestamp-buttons")
|
||||
|
||||
# usage (session.py)
|
||||
SessionTimestampButtons(class_="form-row-button-group", hx_boost="false")[
|
||||
Button(data_target="timestamp_start", data_type="now", size="xs")["Set to now"],
|
||||
Button(data_target="timestamp_start", data_type="toggle", size="xs")["Toggle text"],
|
||||
]
|
||||
```
|
||||
|
||||
**Pros:** explicit dependency, visible import, fails loudly if builder deleted
|
||||
**Cons:** one line of ceremony per element
|
||||
|
||||
## Pattern 2: Element + registry (proposed, not implemented)
|
||||
|
||||
A global `CUSTOM_ELEMENT_MEDIA` dict in `core.py` that maps tag names to their `Media`. `register_element()` populates it automatically at import time, so `Element("session-timestamp-buttons")` silently picks up its JS dependency:
|
||||
|
||||
```python
|
||||
# definition (custom_elements.py)
|
||||
register_element("session-timestamp-buttons", "SessionTimestampButtons", EmptyProps)
|
||||
# CUSTOM_ELEMENT_MEDIA["session-timestamp-buttons"] = Media(js=("dist/elements/...",))
|
||||
|
||||
# usage (session.py) — no builder import needed
|
||||
Element("session-timestamp-buttons",
|
||||
[("class", "form-row-button-group"), ("hx-boost", "false")],
|
||||
children=[...],
|
||||
)
|
||||
```
|
||||
|
||||
**Pros:** one universal API — `Div(...)`, `Button(...)`, `Element("custom-tag")` all same pattern
|
||||
**Cons:** implicit dependency — deleting a `register_element()` call produces no error, just broken JS at runtime
|
||||
|
||||
## Recommendation
|
||||
|
||||
Start with Pattern 1 (named builders) — safe by default. Add Pattern 2 later if the ceremony becomes annoying. The two are **not mutually exclusive**: a named builder is just a thin wrapper around an `Element`; the registry can be added without changing any call sites.
|
||||
|
||||
## Quick reference
|
||||
|
||||
| Want | Write |
|
||||
|------|-------|
|
||||
| Plain HTML tag | `Div(class_="flex")["text"]` |
|
||||
| Custom element (builder) | `SessionTimestampButtons(class_="...")[child]` |
|
||||
| Raw element | `Element("custom-tag", attributes_list, children=[...])` |
|
||||
| Builder from scratch | `custom_element_builder("tag-name")` |
|
||||
@@ -0,0 +1,485 @@
|
||||
# Boolean Filters Overhaul Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Overhaul the boolean criterion filters from a single checkbox (representing True/Not set) to a 2-radio-button UI representing True, False, and Unset states across all filter bars.
|
||||
|
||||
**Architecture:**
|
||||
1. Generalize `_filter_checkbox` into a filter-agnostic `Checkbox` component and introduce a `Radio` component in `common/components/primitives.py`.
|
||||
2. Implement a nullable boolean filter JSON parsing helper `_parse_bool_nullable` and a component helper `_filter_boolean_radio` in `common/components/filters.py`.
|
||||
3. Update `GameFilterBar`, `SessionFilterBar`, and `PurchaseFilterBar` in `common/components/filters.py` to leverage these new helpers.
|
||||
4. Enhance `games/static/js/filter_bar.js` with deselectable radio toggling behavior and updated checked-radio state serialization.
|
||||
|
||||
**Tech Stack:** Python, Django, vanilla JavaScript, HTML.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Generalize Checkbox and Introduce Radio in Primitives
|
||||
|
||||
**Files:**
|
||||
- Modify: `common/components/primitives.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing test for the new Checkbox and Radio primitives**
|
||||
|
||||
Create a new test class `ComponentPrimitivesTest` in `tests/test_components.py` (or verify where to append) to check the output of `Checkbox` and `Radio`.
|
||||
Add the following code to `tests/test_components.py`:
|
||||
|
||||
```python
|
||||
from common.components.primitives import Checkbox, Radio
|
||||
|
||||
class ComponentPrimitivesTest(SimpleTestCase):
|
||||
def test_checkbox_primitive(self):
|
||||
html = Checkbox(name="test-check", label="Accept Terms", checked=True, value="yes")
|
||||
self.assertIn('type="checkbox"', html)
|
||||
self.assertIn('name="test-check"', html)
|
||||
self.assertIn('value="yes"', html)
|
||||
self.assertIn('checked="true"', html)
|
||||
self.assertIn("Accept Terms", html)
|
||||
|
||||
def test_radio_primitive(self):
|
||||
html = Radio(name="test-radio", label="Option A", checked=False, value="A")
|
||||
self.assertIn('type="radio"', html)
|
||||
self.assertIn('name="test-radio"', html)
|
||||
self.assertIn('value="A"', html)
|
||||
self.assertNotIn('checked="true"', html)
|
||||
self.assertIn("Option A", html)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `pytest tests/test_components.py -k ComponentPrimitivesTest`
|
||||
Expected output: Failures/errors due to `Checkbox` and `Radio` not being defined/imported.
|
||||
|
||||
- [ ] **Step 3: Implement Checkbox and Radio in `common/components/primitives.py`**
|
||||
|
||||
Open `common/components/primitives.py` and find the other basic primitives (e.g. `Input`, `Label`). Add the following implementations and ensure they are exported / added to imports/exports:
|
||||
|
||||
```python
|
||||
def Checkbox(
|
||||
name: str,
|
||||
label: str,
|
||||
checked: bool = False,
|
||||
value: str = "1",
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
) -> SafeText:
|
||||
"""A filter-agnostic Checkbox component."""
|
||||
attributes = attributes or []
|
||||
input_attrs = [
|
||||
("name", name),
|
||||
("value", value),
|
||||
("class", "rounded border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand"),
|
||||
] + attributes
|
||||
if checked:
|
||||
input_attrs.append(("checked", "true"))
|
||||
|
||||
return Label(
|
||||
attributes=[("class", "flex items-center gap-2 text-sm text-heading cursor-pointer")],
|
||||
children=[
|
||||
Input(type="checkbox", attributes=input_attrs),
|
||||
label,
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def Radio(
|
||||
name: str,
|
||||
label: str,
|
||||
checked: bool = False,
|
||||
value: str = "",
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
) -> SafeText:
|
||||
"""A filter-agnostic Radio component."""
|
||||
attributes = attributes or []
|
||||
input_attrs = [
|
||||
("name", name),
|
||||
("value", value),
|
||||
("class", "rounded-full border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand"),
|
||||
] + attributes
|
||||
if checked:
|
||||
input_attrs.append(("checked", "true"))
|
||||
|
||||
return Label(
|
||||
attributes=[("class", "flex items-center gap-1.5 text-sm text-heading cursor-pointer")],
|
||||
children=[
|
||||
Input(type="radio", attributes=input_attrs),
|
||||
label,
|
||||
],
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `pytest tests/test_components.py -k ComponentPrimitivesTest`
|
||||
Expected output: `2 passed`
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
git add common/components/primitives.py tests/test_components.py
|
||||
git commit -m "refactor: generalize Checkbox and add Radio primitive component"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Implement Filter Parsers & Helpers in filters.py
|
||||
|
||||
**Files:**
|
||||
- Modify: `common/components/filters.py`
|
||||
- Modify: `tests/test_filter_helpers.py`
|
||||
|
||||
- [ ] **Step 1: Write failing unit tests for `_parse_bool_nullable` in `tests/test_filter_helpers.py`**
|
||||
|
||||
Add a new test class `ParseBoolNullableTest` to `tests/test_filter_helpers.py`:
|
||||
|
||||
```python
|
||||
from common.components.filters import _parse_bool_nullable
|
||||
|
||||
class ParseBoolNullableTest(SimpleTestCase):
|
||||
def test_missing_key(self):
|
||||
self.assertIsNone(_parse_bool_nullable({}, "field"))
|
||||
|
||||
def test_null_value(self):
|
||||
self.assertIsNone(_parse_bool_nullable({"field": None}, "field"))
|
||||
self.assertIsNone(_parse_bool_nullable({"field": {}}, "field"))
|
||||
|
||||
def test_boolean_values(self):
|
||||
self.assertTrue(_parse_bool_nullable({"field": {"value": True}}, "field"))
|
||||
self.assertFalse(_parse_bool_nullable({"field": {"value": False}}, "field"))
|
||||
|
||||
def test_string_values(self):
|
||||
self.assertTrue(_parse_bool_nullable({"field": {"value": "true"}}, "field"))
|
||||
self.assertTrue(_parse_bool_nullable({"field": {"value": "1"}}, "field"))
|
||||
self.assertFalse(_parse_bool_nullable({"field": {"value": "false"}}, "field"))
|
||||
self.assertFalse(_parse_bool_nullable({"field": {"value": "0"}}, "field"))
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `pytest tests/test_filter_helpers.py -k ParseBoolNullableTest`
|
||||
Expected output: Failures/errors due to `_parse_bool_nullable` not found.
|
||||
|
||||
- [ ] **Step 3: Implement `_parse_bool_nullable` and `_filter_boolean_radio` in `common/components/filters.py`**
|
||||
|
||||
1. Import `Checkbox` and `Radio` from `common.components.primitives` at the top of `common/components/filters.py`.
|
||||
2. Define `_FILTER_RADIO_CLASS` and add `_parse_bool_nullable`.
|
||||
3. Create `_filter_boolean_radio`.
|
||||
4. Refactor `_filter_checkbox` to use `Checkbox` instead of raw `Label` and `Input`.
|
||||
|
||||
Code to implement:
|
||||
```python
|
||||
_FILTER_RADIO_CLASS = (
|
||||
"rounded-full border-default-medium bg-neutral-secondary-medium "
|
||||
"text-brand focus:ring-brand"
|
||||
)
|
||||
|
||||
def _parse_bool_nullable(existing: dict, key: str) -> bool | None:
|
||||
"""Extract a nullable boolean value from a filter criterion."""
|
||||
if key not in existing:
|
||||
return None
|
||||
field = existing[key]
|
||||
if not isinstance(field, dict):
|
||||
return None
|
||||
val = field.get("value")
|
||||
if val is None:
|
||||
return None
|
||||
if isinstance(val, str):
|
||||
if val.lower() in ("true", "1", "yes"):
|
||||
return True
|
||||
if val.lower() in ("false", "0", "no"):
|
||||
return False
|
||||
return bool(val)
|
||||
|
||||
|
||||
def _filter_checkbox(name: str, label: str, checked: bool) -> SafeText:
|
||||
"""Thin adapter mapping legacy checkbox filters to the generalized Checkbox primitive."""
|
||||
return Checkbox(name=name, label=label, checked=checked)
|
||||
|
||||
|
||||
def _filter_boolean_radio(name: str, label: str, value: bool | None) -> SafeText:
|
||||
"""Renders a filter-specific boolean radio button group with 'True' and 'False' options."""
|
||||
return Div(
|
||||
attributes=[("class", "flex flex-col gap-1")],
|
||||
children=[
|
||||
Span(
|
||||
attributes=[("class", _FILTER_LABEL_CLASS)],
|
||||
children=[label],
|
||||
),
|
||||
Div(
|
||||
attributes=[("class", "flex items-center gap-4 h-9")],
|
||||
children=[
|
||||
Radio(name=name, label="True", checked=value is True, value="true"),
|
||||
Radio(name=name, label="False", checked=value is False, value="false"),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run unit tests to verify they pass**
|
||||
|
||||
Run: `pytest tests/test_filter_helpers.py`
|
||||
Expected output: All helper tests passed (including `ParseBoolNullableTest`).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
git add common/components/filters.py tests/test_filter_helpers.py
|
||||
git commit -m "feat: implement _parse_bool_nullable and _filter_boolean_radio helper"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Replace Single Checkboxes with Radio Groups in Filter Bars
|
||||
|
||||
**Files:**
|
||||
- Modify: `common/components/filters.py`
|
||||
|
||||
- [ ] **Step 1: Update GameFilterBar**
|
||||
|
||||
In `common/components/filters.py` inside `GameFilterBar`:
|
||||
1. Parse using `_parse_bool_nullable` instead of `_parse_bool` for:
|
||||
- `mastered_value`
|
||||
- `purchase_refunded_value`
|
||||
- `purchase_infinite_value`
|
||||
- `session_emulated_value`
|
||||
2. Update the fields list to replace `_filter_checkbox` with `_filter_boolean_radio`, changing the wrapper div to have `gap-6` for better horizontal radio button spacing.
|
||||
|
||||
Code snippet modification:
|
||||
```python
|
||||
# Parsing:
|
||||
mastered_value = _parse_bool_nullable(existing, "mastered")
|
||||
# ...
|
||||
purchase_refunded_value = _parse_bool_nullable(existing, "purchase_refunded")
|
||||
purchase_infinite_value = _parse_bool_nullable(existing, "purchase_infinite")
|
||||
session_emulated_value = _parse_bool_nullable(existing, "session_emulated")
|
||||
|
||||
# Rendering (in fields):
|
||||
Div(
|
||||
attributes=[("class", "flex items-end gap-6 mb-4 flex-wrap")],
|
||||
children=[
|
||||
_filter_boolean_radio("filter-mastered", "Mastered", mastered_value),
|
||||
_filter_boolean_radio(
|
||||
"filter-purchase-refunded", "Refunded", purchase_refunded_value
|
||||
),
|
||||
_filter_boolean_radio(
|
||||
"filter-purchase-infinite", "Infinite", purchase_infinite_value
|
||||
),
|
||||
_filter_boolean_radio(
|
||||
"filter-session-emulated", "Emulated", session_emulated_value
|
||||
),
|
||||
],
|
||||
),
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update SessionFilterBar**
|
||||
|
||||
In `common/components/filters.py` inside `SessionFilterBar`:
|
||||
1. Parse using `_parse_bool_nullable` for:
|
||||
- `emulated_value`
|
||||
- `is_active_value`
|
||||
2. Update the fields to replace `_filter_checkbox` with `_filter_boolean_radio`.
|
||||
|
||||
Code snippet modification:
|
||||
```python
|
||||
# Parsing:
|
||||
emulated_value = _parse_bool_nullable(existing, "emulated")
|
||||
is_active_value = _parse_bool_nullable(existing, "is_active")
|
||||
|
||||
# Rendering (in fields):
|
||||
Div(
|
||||
attributes=[("class", "flex gap-6 mb-4")],
|
||||
children=[
|
||||
_filter_boolean_radio("filter-emulated", "Emulated", emulated_value),
|
||||
_filter_boolean_radio("filter-active", "Active", is_active_value),
|
||||
],
|
||||
),
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update PurchaseFilterBar**
|
||||
|
||||
In `common/components/filters.py` inside `PurchaseFilterBar`:
|
||||
1. Parse using `_parse_bool_nullable` for:
|
||||
- `is_refunded_value`
|
||||
- `infinite_value`
|
||||
- `needs_price_update_value`
|
||||
2. Update the fields to replace `_filter_checkbox` with `_filter_boolean_radio`.
|
||||
|
||||
Code snippet modification:
|
||||
```python
|
||||
# Parsing:
|
||||
is_refunded_value = _parse_bool_nullable(existing, "is_refunded")
|
||||
infinite_value = _parse_bool_nullable(existing, "infinite")
|
||||
needs_price_update_value = _parse_bool_nullable(existing, "needs_price_update")
|
||||
|
||||
# Rendering (in fields):
|
||||
Div(
|
||||
attributes=[("class", "flex flex-col items-start gap-4 mb-4")],
|
||||
children=[
|
||||
_filter_boolean_radio(
|
||||
"filter-refunded", "Refunded", is_refunded_value
|
||||
),
|
||||
_filter_boolean_radio("filter-infinite", "Infinite", infinite_value),
|
||||
_filter_boolean_radio(
|
||||
"filter-needs-price-update",
|
||||
"Needs Price Update",
|
||||
needs_price_update_value,
|
||||
),
|
||||
],
|
||||
),
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run component tests to verify output**
|
||||
|
||||
Run: `pytest tests/test_filter_bars.py`
|
||||
Expected output: Since we only changed the internal input type from checkbox to radio but kept the `name="..."` attribute intact, the tests asserting name occurrences should still pass!
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
git add common/components/filters.py
|
||||
git commit -m "feat: replace single boolean checkboxes with radio groups in all FilterBars"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Frontend Behavior and Serialization in JS
|
||||
|
||||
**Files:**
|
||||
- Modify: `games/static/js/filter_bar.js`
|
||||
|
||||
- [ ] **Step 1: Update Radio Serialization in `buildFilterJSON`**
|
||||
|
||||
In `games/static/js/filter_bar.js`, locate the `// 2. Boolean Fields (Checkboxes)` section.
|
||||
Update the loop to check for `:checked` radio options:
|
||||
|
||||
```javascript
|
||||
// 2. Boolean Fields (Radio Button Groups)
|
||||
var booleanFields = [
|
||||
{ name: "filter-mastered", key: "mastered" },
|
||||
{ name: "filter-emulated", key: "emulated" },
|
||||
{ name: "filter-active", key: "is_active" },
|
||||
{ name: "filter-refunded", key: "is_refunded" },
|
||||
{ name: "filter-infinite", key: "infinite" },
|
||||
{ name: "filter-needs-price-update", key: "needs_price_update" },
|
||||
{ name: "filter-purchase-refunded", key: "purchase_refunded" },
|
||||
{ name: "filter-purchase-infinite", key: "purchase_infinite" },
|
||||
{ name: "filter-session-emulated", key: "session_emulated" }
|
||||
];
|
||||
booleanFields.forEach(function (bf) {
|
||||
var el = form.querySelector('[name="' + bf.name + '"]:checked');
|
||||
if (el) {
|
||||
var val = el.value === "true";
|
||||
filter[bf.key] = criterion(val, null, "EQUALS");
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add click-to-deselect functionality for radios**
|
||||
|
||||
In `games/static/js/filter_bar.js`, add `setupDeselectableRadios` and call it inside `DOMContentLoaded`:
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* Enable deselect-on-click behavior for filter radio buttons.
|
||||
*/
|
||||
function setupDeselectableRadios() {
|
||||
document.querySelectorAll('input[type="radio"]').forEach(function (radio) {
|
||||
radio.addEventListener('click', function (e) {
|
||||
if (this.wasChecked) {
|
||||
this.checked = false;
|
||||
this.wasChecked = false;
|
||||
this.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
} else {
|
||||
var name = this.getAttribute('name');
|
||||
if (name) {
|
||||
document.querySelectorAll('input[type="radio"][name="' + name + '"]').forEach(function (r) {
|
||||
r.wasChecked = false;
|
||||
});
|
||||
}
|
||||
this.wasChecked = true;
|
||||
}
|
||||
});
|
||||
if (radio.checked) {
|
||||
radio.wasChecked = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Locate the `document.addEventListener("DOMContentLoaded", ...)` callback at the bottom of the file and update it:
|
||||
```javascript
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
injectSearchInputs();
|
||||
setupDeselectableRadios();
|
||||
loadPresets();
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run existing frontend / component tests to verify no syntax errors or simple breaks**
|
||||
|
||||
Run: `pytest tests/test_filter_bars.py`
|
||||
Expected output: PASS
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
git add games/static/js/filter_bar.js
|
||||
git commit -m "feat: add click-to-deselect behavior and update checked-radio serialization in JS"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Add Comprehensive Test Coverage & Verification
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/test_filter_bars.py`
|
||||
|
||||
- [ ] **Step 1: Write explicit tests for boolean radio elements in filter bars**
|
||||
|
||||
Add a test case checking that the filter bars output `type="radio"` and contain `value="true"` and `value="false"` for boolean fields:
|
||||
|
||||
In `tests/test_filter_bars.py`, add the following test method:
|
||||
|
||||
```python
|
||||
def test_boolean_fields_render_as_radio_groups(self):
|
||||
"""Boolean fields must render as radio groups with True/False choices."""
|
||||
from common.components import FilterBar, SessionFilterBar, PurchaseFilterBar
|
||||
|
||||
# 1. Games Filter Bar
|
||||
games_html = str(FilterBar(filter_json=""))
|
||||
self.assertIn('type="radio"', games_html)
|
||||
self.assertIn('name="filter-mastered"', games_html)
|
||||
self.assertIn('value="true"', games_html)
|
||||
self.assertIn('value="false"', games_html)
|
||||
|
||||
# 2. Session Filter Bar
|
||||
session_html = str(SessionFilterBar(filter_json=""))
|
||||
self.assertIn('type="radio"', session_html)
|
||||
self.assertIn('name="filter-emulated"', session_html)
|
||||
self.assertIn('value="true"', session_html)
|
||||
self.assertIn('value="false"', session_html)
|
||||
|
||||
# 3. Purchase Filter Bar
|
||||
purchase_html = str(PurchaseFilterBar(filter_json=""))
|
||||
self.assertIn('type="radio"', purchase_html)
|
||||
self.assertIn('name="filter-refunded"', purchase_html)
|
||||
self.assertIn('value="true"', purchase_html)
|
||||
self.assertIn('value="false"', purchase_html)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run pytest to verify all tests (including new ones) pass**
|
||||
|
||||
Run: `pytest`
|
||||
Expected output: `356 passed` (including the new test case).
|
||||
|
||||
- [ ] **Step 3: Commit final tests**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
git add tests/test_filter_bars.py
|
||||
git commit -m "test: add explicit radio group and True/False choice checks for boolean fields"
|
||||
```
|
||||
@@ -0,0 +1,662 @@
|
||||
# Comprehensive Filters Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Implement a comprehensive suite of backend filter classes and filter field expansions across all 6 main models (Game, Session, Purchase, Device, Platform, PlayEvent) using a subquery-based cross-entity approach.
|
||||
|
||||
**Architecture:** We will implement missing filter classes (`DeviceFilter`, `PlatformFilter`, `PlayEventFilter`) in `games/filters.py`. We will extend all filters to support powerful, deeply linked "cross-entity" subqueries (e.g. `GameFilter.session_filter` or `PlatformFilter.game_filter`) which builds robust `Q` objects without causing duplicate join rows in list queries.
|
||||
|
||||
**Tech Stack:** Django, Python dataclasses, Pytest.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Implement New Filter Classes (Device, Platform, PlayEvent)
|
||||
|
||||
**Files:**
|
||||
- Modify: `games/filters.py`
|
||||
- Test: `tests/test_filters.py`
|
||||
|
||||
- [ ] **Step 1: Implement DeviceFilter, PlatformFilter, and PlayEventFilter**
|
||||
|
||||
Add the three new operator filters to `games/filters.py`. Ensure we import all necessary criterion types and add the `parse_device_filter`, `parse_platform_filter`, and `parse_playevent_filter` helper functions at the end of the file.
|
||||
|
||||
```python
|
||||
# Insert new filter imports and classes in games/filters.py
|
||||
|
||||
@dataclass
|
||||
class DeviceFilter(OperatorFilter):
|
||||
"""Filter for the Device model."""
|
||||
|
||||
AND: DeviceFilter | None = None
|
||||
OR: DeviceFilter | None = None
|
||||
NOT: DeviceFilter | None = None
|
||||
|
||||
name: StringCriterion | None = None
|
||||
type: ChoiceCriterion | None = None
|
||||
created_at: StringCriterion | None = None
|
||||
|
||||
# Free-text search
|
||||
search: StringCriterion | None = None
|
||||
|
||||
# Cross-entity: Devices that have sessions matching these criteria
|
||||
session_filter: SessionFilter | None = None
|
||||
|
||||
def to_q(self) -> Q:
|
||||
q = Q()
|
||||
|
||||
if self.name is not None:
|
||||
q &= self.name.to_q("name")
|
||||
if self.type is not None:
|
||||
q &= self.type.to_q("type")
|
||||
if self.created_at is not None:
|
||||
q &= self.created_at.to_q("created_at")
|
||||
|
||||
# Free-text search
|
||||
if self.search is not None and self.search.value:
|
||||
search_q = (
|
||||
Q(name__icontains=self.search.value)
|
||||
| Q(type__icontains=self.search.value)
|
||||
)
|
||||
if self.search.modifier == Modifier.EXCLUDES:
|
||||
search_q = ~search_q
|
||||
q &= search_q
|
||||
|
||||
# Cross-entity filter: session_filter
|
||||
if self.session_filter is not None:
|
||||
from games.models import Session
|
||||
session_q = self.session_filter.to_q()
|
||||
matching_ids = Session.objects.filter(session_q).values_list("device_id", flat=True)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
sub = self.sub_filter()
|
||||
if sub is not None:
|
||||
if self.AND is not None:
|
||||
q &= sub.to_q()
|
||||
elif self.OR is not None:
|
||||
q |= sub.to_q()
|
||||
elif self.NOT is not None:
|
||||
q &= ~sub.to_q()
|
||||
|
||||
return q
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlatformFilter(OperatorFilter):
|
||||
"""Filter for the Platform model."""
|
||||
|
||||
AND: PlatformFilter | None = None
|
||||
OR: PlatformFilter | None = None
|
||||
NOT: PlatformFilter | None = None
|
||||
|
||||
name: StringCriterion | None = None
|
||||
group: StringCriterion | None = None
|
||||
icon: StringCriterion | None = None
|
||||
created_at: StringCriterion | None = None
|
||||
|
||||
# Free-text search
|
||||
search: StringCriterion | None = None
|
||||
|
||||
# Cross-entity
|
||||
game_filter: GameFilter | None = None
|
||||
purchase_filter: PurchaseFilter | None = None
|
||||
|
||||
def to_q(self) -> Q:
|
||||
q = Q()
|
||||
|
||||
if self.name is not None:
|
||||
q &= self.name.to_q("name")
|
||||
if self.group is not None:
|
||||
q &= self.group.to_q("group")
|
||||
if self.icon is not None:
|
||||
q &= self.icon.to_q("icon")
|
||||
if self.created_at is not None:
|
||||
q &= self.created_at.to_q("created_at")
|
||||
|
||||
# Free-text search
|
||||
if self.search is not None and self.search.value:
|
||||
search_q = (
|
||||
Q(name__icontains=self.search.value)
|
||||
| Q(group__icontains=self.search.value)
|
||||
)
|
||||
if self.search.modifier == Modifier.EXCLUDES:
|
||||
search_q = ~search_q
|
||||
q &= search_q
|
||||
|
||||
# Cross-entity filter: game_filter
|
||||
if self.game_filter is not None:
|
||||
from games.models import Game
|
||||
game_q = self.game_filter.to_q()
|
||||
matching_ids = Game.objects.filter(game_q).values_list("platform_id", flat=True)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
# Cross-entity filter: purchase_filter
|
||||
if self.purchase_filter is not None:
|
||||
from games.models import Purchase
|
||||
purchase_q = self.purchase_filter.to_q()
|
||||
matching_ids = Purchase.objects.filter(purchase_q).values_list("platform_id", flat=True)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
sub = self.sub_filter()
|
||||
if sub is not None:
|
||||
if self.AND is not None:
|
||||
q &= sub.to_q()
|
||||
elif self.OR is not None:
|
||||
q |= sub.to_q()
|
||||
elif self.NOT is not None:
|
||||
q &= ~sub.to_q()
|
||||
|
||||
return q
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlayEventFilter(OperatorFilter):
|
||||
"""Filter for the PlayEvent model."""
|
||||
|
||||
AND: PlayEventFilter | None = None
|
||||
OR: PlayEventFilter | None = None
|
||||
NOT: PlayEventFilter | None = None
|
||||
|
||||
game: MultiCriterion | None = None # filters on game_id
|
||||
started: StringCriterion | None = None # date string
|
||||
ended: StringCriterion | None = None # date string
|
||||
days_to_finish: IntCriterion | None = None
|
||||
note: StringCriterion | None = None
|
||||
created_at: StringCriterion | None = None
|
||||
|
||||
# Free-text search
|
||||
search: StringCriterion | None = None
|
||||
|
||||
# Cross-entity: PlayEvents for games matching these criteria
|
||||
game_filter: GameFilter | None = None
|
||||
|
||||
def to_q(self) -> Q:
|
||||
q = Q()
|
||||
|
||||
if self.game is not None:
|
||||
q &= self.game.to_q("game_id")
|
||||
if self.started is not None:
|
||||
q &= self.started.to_q("started")
|
||||
if self.ended is not None:
|
||||
q &= self.ended.to_q("ended")
|
||||
if self.days_to_finish is not None:
|
||||
q &= self.days_to_finish.to_q("days_to_finish")
|
||||
if self.note is not None:
|
||||
q &= self.note.to_q("note")
|
||||
if self.created_at is not None:
|
||||
q &= self.created_at.to_q("created_at")
|
||||
|
||||
# Free-text search
|
||||
if self.search is not None and self.search.value:
|
||||
search_q = (
|
||||
Q(game__name__icontains=self.search.value)
|
||||
| Q(note__icontains=self.search.value)
|
||||
)
|
||||
if self.search.modifier == Modifier.EXCLUDES:
|
||||
search_q = ~search_q
|
||||
q &= search_q
|
||||
|
||||
# Cross-entity filter: game_filter
|
||||
if self.game_filter is not None:
|
||||
from games.models import Game
|
||||
game_q = self.game_filter.to_q()
|
||||
matching_ids = Game.objects.filter(game_q).values_list("id", flat=True)
|
||||
q &= Q(game_id__in=matching_ids)
|
||||
|
||||
sub = self.sub_filter()
|
||||
if sub is not None:
|
||||
if self.AND is not None:
|
||||
q &= sub.to_q()
|
||||
elif self.OR is not None:
|
||||
q |= sub.to_q()
|
||||
elif self.NOT is not None:
|
||||
q &= ~sub.to_q()
|
||||
|
||||
return q
|
||||
|
||||
|
||||
# Add to convenience helpers section:
|
||||
def parse_device_filter(json_str: str) -> DeviceFilter | None:
|
||||
return filter_from_json(DeviceFilter, json_str)
|
||||
|
||||
|
||||
def parse_platform_filter(json_str: str) -> PlatformFilter | None:
|
||||
return filter_from_json(PlatformFilter, json_str)
|
||||
|
||||
|
||||
def parse_playevent_filter(json_str: str) -> PlayEventFilter | None:
|
||||
return filter_from_json(PlayEventFilter, json_str)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run existing tests to verify everything compiles**
|
||||
|
||||
Run: `pytest tests/test_filters.py -v`
|
||||
Expected: All existing tests PASS without issues.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Expand SessionFilter (Duration Fields + Cross-Entity)
|
||||
|
||||
**Files:**
|
||||
- Modify: `games/filters.py:SessionFilter`
|
||||
- Test: `tests/test_filters.py`
|
||||
|
||||
- [ ] **Step 1: Refactor SessionFilter and add new duration fields & device_filter**
|
||||
|
||||
Modify `SessionFilter` to replace `duration_minutes: IntCriterion` with `duration_total_minutes`, `duration_manual_minutes`, and `duration_calculated_minutes`. Add `device_filter: DeviceFilter`.
|
||||
|
||||
Update `to_q()` inside `SessionFilter` to map duration fields correctly to their respective GeneratedFields (`duration_total`, `duration_calculated`) or manual field (`duration_manual`). Use standard Python `timedelta` logic.
|
||||
|
||||
```python
|
||||
# Inside SessionFilter class:
|
||||
duration_total_minutes: IntCriterion | None = None
|
||||
duration_manual_minutes: IntCriterion | None = None
|
||||
duration_calculated_minutes: IntCriterion | None = None
|
||||
|
||||
# Cross-entity: sessions for devices matching these criteria
|
||||
device_filter: DeviceFilter | None = None
|
||||
```
|
||||
|
||||
```python
|
||||
# Helper inside SessionFilter or refactored:
|
||||
def _duration_to_q(self, c: IntCriterion, field: str) -> Q:
|
||||
from datetime import timedelta
|
||||
q = Q()
|
||||
td_val = timedelta(minutes=c.value)
|
||||
m = c.modifier
|
||||
if m == Modifier.EQUALS:
|
||||
q &= Q(
|
||||
**{
|
||||
f"{field}__gte": td_val,
|
||||
f"{field}__lt": timedelta(minutes=c.value + 1),
|
||||
}
|
||||
)
|
||||
elif m == Modifier.NOT_EQUALS:
|
||||
q &= ~Q(
|
||||
**{
|
||||
f"{field}__gte": td_val,
|
||||
f"{field}__lt": timedelta(minutes=c.value + 1),
|
||||
}
|
||||
)
|
||||
elif m == Modifier.GREATER_THAN:
|
||||
q &= Q(**{f"{field}__gt": td_val})
|
||||
elif m == Modifier.LESS_THAN:
|
||||
q &= Q(**{f"{field}__lt": td_val})
|
||||
elif m == Modifier.BETWEEN and c.value2 is not None:
|
||||
lo = timedelta(minutes=min(c.value, c.value2))
|
||||
hi = timedelta(minutes=max(c.value, c.value2))
|
||||
q &= Q(**{f"{field}__gte": lo, f"{f_field}__lte": hi})
|
||||
elif m == Modifier.NOT_BETWEEN and c.value2 is not None:
|
||||
lo = timedelta(minutes=min(c.value, c.value2))
|
||||
hi = timedelta(minutes=max(c.value, c.value2))
|
||||
q &= Q(**{f"{field}__lt": lo}) | Q(**{f"{field}__gt": hi})
|
||||
elif m == Modifier.IS_NULL:
|
||||
q &= Q(**{f"{field}": timedelta(0)})
|
||||
elif m == Modifier.NOT_NULL:
|
||||
q &= ~Q(**{f"{field}": timedelta(0)})
|
||||
return q
|
||||
```
|
||||
|
||||
Then in `to_q()` inside `SessionFilter`:
|
||||
```python
|
||||
if self.duration_total_minutes is not None:
|
||||
q &= self._duration_to_q(self.duration_total_minutes, "duration_total")
|
||||
if self.duration_manual_minutes is not None:
|
||||
q &= self._duration_to_q(self.duration_manual_minutes, "duration_manual")
|
||||
if self.duration_calculated_minutes is not None:
|
||||
q &= self._duration_to_q(self.duration_calculated_minutes, "duration_calculated")
|
||||
|
||||
# Cross-entity filter: device_filter
|
||||
if self.device_filter is not None:
|
||||
from games.models import Device
|
||||
device_q = self.device_filter.to_q()
|
||||
matching_ids = Device.objects.filter(device_q).values_list("id", flat=True)
|
||||
q &= Q(device_id__in=matching_ids)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify compiles correctly**
|
||||
|
||||
Run: `pytest tests/test_filters.py -v`
|
||||
Expected: PASS (existing tests may need updating if they referenced `duration_minutes`).
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Expand PurchaseFilter (Original Currency, Infinite, Needs Price Update, Converted Currency)
|
||||
|
||||
**Files:**
|
||||
- Modify: `games/filters.py:PurchaseFilter`
|
||||
- Test: `tests/test_filters.py`
|
||||
|
||||
- [ ] **Step 1: Add new fields to PurchaseFilter and platform_filter**
|
||||
|
||||
Expand `PurchaseFilter` with `infinite: BoolCriterion`, `needs_price_update: BoolCriterion`, `converted_currency: StringCriterion`, and `platform_filter: PlatformFilter`.
|
||||
|
||||
```python
|
||||
# Inside PurchaseFilter class:
|
||||
infinite: BoolCriterion | None = None
|
||||
needs_price_update: BoolCriterion | None = None
|
||||
converted_currency: StringCriterion | None = None
|
||||
|
||||
# Cross-entity
|
||||
platform_filter: PlatformFilter | None = None
|
||||
```
|
||||
|
||||
Update `to_q()` inside `PurchaseFilter`:
|
||||
```python
|
||||
if self.infinite is not None:
|
||||
q &= self.infinite.to_q("infinite")
|
||||
if self.needs_price_update is not None:
|
||||
q &= self.needs_price_update.to_q("needs_price_update")
|
||||
if self.converted_currency is not None:
|
||||
q &= self.converted_currency.to_q("converted_currency")
|
||||
|
||||
# Cross-entity filter: platform_filter
|
||||
if self.platform_filter is not None:
|
||||
from games.models import Platform
|
||||
platform_q = self.platform_filter.to_q()
|
||||
matching_ids = Platform.objects.filter(platform_q).values_list("id", flat=True)
|
||||
q &= Q(platform_id__in=matching_ids)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify test suite continues to pass**
|
||||
|
||||
Run: `pytest tests/test_filters.py -v`
|
||||
Expected: PASS
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Expand GameFilter (Has Purchases, Has PlayEvents, Session Stats, Cross-Entity)
|
||||
|
||||
**Files:**
|
||||
- Modify: `games/filters.py:GameFilter`
|
||||
- Test: `tests/test_filters.py`
|
||||
|
||||
- [ ] **Step 1: Expand GameFilter with session stats, purchase/playevent existence, and cross-entity filters**
|
||||
|
||||
Add fields and cross-entity filters to `GameFilter`:
|
||||
```python
|
||||
# Inside GameFilter class:
|
||||
has_purchases: BoolCriterion | None = None
|
||||
has_playevents: BoolCriterion | None = None
|
||||
session_count: IntCriterion | None = None
|
||||
session_average: IntCriterion | None = None # average in minutes
|
||||
|
||||
# Cross-entity filters
|
||||
session_filter: SessionFilter | None = None
|
||||
purchase_filter: PurchaseFilter | None = None
|
||||
playevent_filter: PlayEventFilter | None = None
|
||||
platform_filter: PlatformFilter | None = None
|
||||
```
|
||||
|
||||
Update `to_q()` inside `GameFilter`.
|
||||
For existence and session stats filters, we use Subqueries to avoid complex inline annotations during the generic filter generation (which is much cleaner and less bug-prone):
|
||||
|
||||
```python
|
||||
if self.has_purchases is not None:
|
||||
from games.models import Purchase
|
||||
purchased_ids = Purchase.objects.values_list("games__id", flat=True).distinct()
|
||||
if self.has_purchases.value:
|
||||
q &= Q(id__in=purchased_ids)
|
||||
else:
|
||||
q &= ~Q(id__in=purchased_ids)
|
||||
|
||||
if self.has_playevents is not None:
|
||||
from games.models import PlayEvent
|
||||
played_ids = PlayEvent.objects.values_list("game_id", flat=True).distinct()
|
||||
if self.has_playevents.value:
|
||||
q &= Q(id__in=played_ids)
|
||||
else:
|
||||
q &= ~Q(id__in=played_ids)
|
||||
|
||||
if self.session_count is not None:
|
||||
from games.models import Game
|
||||
from django.db.models import Count
|
||||
matching_ids = Game.objects.annotate(s_count=Count("sessions")).filter(self.session_count.to_q("s_count")).values_list("id", flat=True)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
if self.session_average is not None:
|
||||
from games.models import Game, Session
|
||||
from django.db.models import Avg, F, ExpressionWrapper, DurationField
|
||||
# Compute average session total duration.
|
||||
# Avg returns an interval/duration type, so we can convert it to minutes in Python or do duration comparisons directly.
|
||||
# To match the criterion easily, we can filter Game objects using Avg:
|
||||
matching_ids = Game.objects.annotate(s_avg=Avg("sessions__duration_total")).filter(self._playtime_to_q_for_field(self.session_average, "s_avg")).values_list("id", flat=True)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
# Cross-entity filters
|
||||
if self.session_filter is not None:
|
||||
from games.models import Session
|
||||
session_q = self.session_filter.to_q()
|
||||
matching_ids = Session.objects.filter(session_q).values_list("game_id", flat=True)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
if self.purchase_filter is not None:
|
||||
from games.models import Purchase
|
||||
purchase_q = self.purchase_filter.to_q()
|
||||
matching_ids = Purchase.objects.filter(purchase_q).values_list("games__id", flat=True)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
if self.playevent_filter is not None:
|
||||
from games.models import PlayEvent
|
||||
playevent_q = self.playevent_filter.to_q()
|
||||
matching_ids = PlayEvent.objects.filter(playevent_q).values_list("game_id", flat=True)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
if self.platform_filter is not None:
|
||||
from games.models import Platform
|
||||
platform_q = self.platform_filter.to_q()
|
||||
matching_ids = Platform.objects.filter(platform_q).values_list("id", flat=True)
|
||||
q &= Q(platform_id__in=matching_ids)
|
||||
```
|
||||
|
||||
Add a helper `_playtime_to_q_for_field` in `GameFilter` that works exactly like `_playtime_to_q` but accepts a customized field name (e.g. `s_avg`):
|
||||
```python
|
||||
@staticmethod
|
||||
def _playtime_to_q_for_field(c: IntCriterion, field: str) -> Q:
|
||||
from datetime import timedelta
|
||||
m = c.modifier
|
||||
td_val = timedelta(minutes=c.value)
|
||||
|
||||
if m == Modifier.EQUALS:
|
||||
return Q(
|
||||
**{
|
||||
f"{field}__gte": td_val,
|
||||
f"{field}__lt": timedelta(minutes=c.value + 1),
|
||||
}
|
||||
)
|
||||
if m == Modifier.NOT_EQUALS:
|
||||
return ~Q(
|
||||
**{
|
||||
f"{field}__gte": td_val,
|
||||
f"{field}__lt": timedelta(minutes=c.value + 1),
|
||||
}
|
||||
)
|
||||
if m == Modifier.GREATER_THAN:
|
||||
return Q(**{f"{field}__gt": td_val})
|
||||
if m == Modifier.LESS_THAN:
|
||||
return Q(**{f"{field}__lt": td_val})
|
||||
if m == Modifier.BETWEEN and c.value2 is not None:
|
||||
lo = timedelta(minutes=min(c.value, c.value2))
|
||||
hi = timedelta(minutes=max(c.value, c.value2))
|
||||
return Q(**{f"{field}__gte": lo, f"{field}__lte": hi})
|
||||
if m == Modifier.NOT_BETWEEN and c.value2 is not None:
|
||||
lo = timedelta(minutes=min(c.value, c.value2))
|
||||
hi = timedelta(minutes=max(c.value, c.value2))
|
||||
return Q(**{f"{field}__lt": lo}) | Q(**{f"{field}__gt": hi})
|
||||
if m == Modifier.IS_NULL:
|
||||
return Q(**{f"{field}": timedelta(0)})
|
||||
if m == Modifier.NOT_NULL:
|
||||
return ~Q(**{f"{field}": timedelta(0)})
|
||||
return Q()
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update existing `_playtime_to_q` to delegate to `_playtime_to_q_for_field`**
|
||||
```python
|
||||
@staticmethod
|
||||
def _playtime_to_q(c: IntCriterion) -> Q:
|
||||
return GameFilter._playtime_to_q_for_field(c, "playtime")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Add Exhaustive DB Tests for the Expanded and New Filters
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/test_filters.py`
|
||||
|
||||
- [ ] **Step 1: Write DB-backed unit tests for the new filter behaviors**
|
||||
|
||||
Add comprehensive test cases inside `tests/test_filters.py` covering:
|
||||
- New cross-entity filters (e.g. Platform -> Game -> Session -> Device chain).
|
||||
- Session total vs manual vs calculated duration filters.
|
||||
- Game session stats (`session_count`, `session_average`) and presence flags (`has_purchases`, `has_playevents`).
|
||||
- Device, Platform, and PlayEvent specific filters.
|
||||
|
||||
```python
|
||||
# Add test class at the end of tests/test_filters.py:
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestExpandedFiltersAgainstDB:
|
||||
def _setup_entities(self):
|
||||
from games.models import Game, Platform, Device, Session, Purchase, PlayEvent
|
||||
import datetime
|
||||
from datetime import timedelta
|
||||
|
||||
# 1. Platform & Game
|
||||
plat, _ = Platform.objects.get_or_create(name="Retro Console", group="Nintendo", icon="retro")
|
||||
game, _ = Game.objects.get_or_create(name="Super Mario World", defaults={"platform": plat, "status": "f"})
|
||||
game2, _ = Game.objects.get_or_create(name="Zelda", defaults={"platform": plat, "status": "u"})
|
||||
|
||||
# 2. Device & Session
|
||||
dev, _ = Device.objects.get_or_create(name="Super Famicom", type="Console")
|
||||
|
||||
# Session 1: total 40 minutes (30 calc, 10 manual)
|
||||
s1 = Session.objects.create(
|
||||
game=game,
|
||||
device=dev,
|
||||
timestamp_start=datetime.datetime(2026, 6, 1, 12, 0, 0, tzinfo=datetime.timezone.utc),
|
||||
timestamp_end=datetime.datetime(2026, 6, 1, 12, 30, 0, tzinfo=datetime.timezone.utc),
|
||||
duration_manual=timedelta(minutes=10)
|
||||
)
|
||||
|
||||
# 3. Purchase
|
||||
pur = Purchase.objects.create(
|
||||
platform=plat,
|
||||
date_purchased=datetime.date(2026, 1, 1),
|
||||
infinite=True,
|
||||
price=49.99,
|
||||
price_currency="JPY",
|
||||
converted_price=45.00,
|
||||
converted_currency="USD",
|
||||
needs_price_update=False
|
||||
)
|
||||
pur.games.add(game)
|
||||
|
||||
# 4. PlayEvent
|
||||
pe = PlayEvent.objects.create(
|
||||
game=game,
|
||||
started=datetime.date(2026, 6, 1),
|
||||
ended=datetime.date(2026, 6, 2),
|
||||
note="Completed 100%"
|
||||
)
|
||||
|
||||
return {
|
||||
"plat": plat,
|
||||
"game": game,
|
||||
"game2": game2,
|
||||
"dev": dev,
|
||||
"s1": s1,
|
||||
"pur": pur,
|
||||
"pe": pe
|
||||
}
|
||||
|
||||
def test_device_filter_and_cross_entity(self):
|
||||
from games.filters import DeviceFilter, SessionFilter
|
||||
from games.models import Device
|
||||
|
||||
data = self._setup_entities()
|
||||
# Find devices that have sessions on "Super Mario World"
|
||||
df = DeviceFilter.from_json({
|
||||
"session_filter": {
|
||||
"game_filter": {
|
||||
"name": {"value": "Super Mario World", "modifier": "EQUALS"}
|
||||
}
|
||||
}
|
||||
})
|
||||
results = list(Device.objects.filter(df.to_q()))
|
||||
assert data["dev"] in results
|
||||
|
||||
def test_platform_filter_and_cross_entity(self):
|
||||
from games.filters import PlatformFilter, GameFilter
|
||||
from games.models import Platform
|
||||
|
||||
data = self._setup_entities()
|
||||
# Find platforms with games that are finished
|
||||
pf = PlatformFilter.from_json({
|
||||
"game_filter": {
|
||||
"status": {"value": ["f"], "modifier": "INCLUDES"}
|
||||
}
|
||||
})
|
||||
results = list(Platform.objects.filter(pf.to_q()))
|
||||
assert data["plat"] in results
|
||||
|
||||
def test_session_filter_duration_splits(self):
|
||||
from games.filters import SessionFilter
|
||||
from games.models import Session
|
||||
|
||||
data = self._setup_entities()
|
||||
|
||||
# Test duration_total_minutes equals 40
|
||||
sf_tot = SessionFilter.from_json({
|
||||
"duration_total_minutes": {"value": 40, "modifier": "EQUALS"}
|
||||
})
|
||||
assert Session.objects.filter(sf_tot.to_q()).count() == 1
|
||||
|
||||
# Test duration_manual_minutes equals 10
|
||||
sf_man = SessionFilter.from_json({
|
||||
"duration_manual_minutes": {"value": 10, "modifier": "EQUALS"}
|
||||
})
|
||||
assert Session.objects.filter(sf_man.to_q()).count() == 1
|
||||
|
||||
# Test duration_calculated_minutes equals 30
|
||||
sf_calc = SessionFilter.from_json({
|
||||
"duration_calculated_minutes": {"value": 30, "modifier": "EQUALS"}
|
||||
})
|
||||
assert Session.objects.filter(sf_calc.to_q()).count() == 1
|
||||
|
||||
def test_purchase_filter_new_fields(self):
|
||||
from games.filters import PurchaseFilter
|
||||
from games.models import Purchase
|
||||
|
||||
data = self._setup_entities()
|
||||
|
||||
pf = PurchaseFilter.from_json({
|
||||
"infinite": {"value": True, "modifier": "EQUALS"},
|
||||
"needs_price_update": {"value": False, "modifier": "EQUALS"},
|
||||
"converted_currency": {"value": "USD", "modifier": "EQUALS"}
|
||||
})
|
||||
assert Purchase.objects.filter(pf.to_q()).count() == 1
|
||||
|
||||
def test_game_filter_stats_and_existence(self):
|
||||
from games.filters import GameFilter
|
||||
from games.models import Game
|
||||
|
||||
data = self._setup_entities()
|
||||
|
||||
# has_purchases = True
|
||||
gf_pur = GameFilter.from_json({
|
||||
"has_purchases": {"value": True, "modifier": "EQUALS"}
|
||||
})
|
||||
assert data["game"] in list(Game.objects.filter(gf_pur.to_q()))
|
||||
assert data["game2"] not in list(Game.objects.filter(gf_pur.to_q()))
|
||||
|
||||
# session_count = 1
|
||||
gf_cnt = GameFilter.from_json({
|
||||
"session_count": {"value": 1, "modifier": "EQUALS"}
|
||||
})
|
||||
assert data["game"] in list(Game.objects.filter(gf_cnt.to_q()))
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run all unit tests to confirm success**
|
||||
|
||||
Run: `pytest tests/test_filters.py -v`
|
||||
Expected: ALL tests pass perfectly.
|
||||
@@ -0,0 +1,577 @@
|
||||
# Frontend Filters Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Implement a comprehensive frontend filter bar interface for all 6 list views (Games, Sessions, Purchases, Devices, Platforms, PlayEvents) with specific field controls, simple cross-entity toggles, and full JSON preset support.
|
||||
|
||||
**Architecture:** We will extend existing components in `common/components/filters.py` and implement new filter bars (`DeviceFilterBar`, `PlatformFilterBar`, `PlayEventFilterBar`). We will update the views in `games/views/` to parse standard filter JSON from `request.GET.get('filter')`, apply them to querysets, render the filter bars, and export them in `common/components/__init__.py`.
|
||||
|
||||
**Tech Stack:** Django, Python dataclasses, Pytest.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Update existing FilterBars in `common/components/filters.py`
|
||||
|
||||
**Files:**
|
||||
- Modify: `common/components/filters.py`
|
||||
|
||||
- [ ] **Step 1: Add new fields to GameFilterBar**
|
||||
Add checkboxes for `has_purchases`, `has_playevents` and RangeSliders for `session_count`, `session_average`.
|
||||
|
||||
```python
|
||||
# Inside common/components/filters.py: FilterBar()
|
||||
|
||||
# Parse new values
|
||||
has_purchases_value = _parse_bool(existing, "has_purchases")
|
||||
has_playevents_value = _parse_bool(existing, "has_playevents")
|
||||
session_count_min, session_count_max = _parse_range(existing, "session_count")
|
||||
session_avg_min, session_avg_max = _parse_range(existing, "session_average")
|
||||
|
||||
# Add components to fields:
|
||||
# 1. Under status and platform, add the checkboxes for purchases/playevents
|
||||
# 2. Add RangeSliders for session count and average
|
||||
```
|
||||
|
||||
Code change to apply in `FilterBar`:
|
||||
```python
|
||||
fields = [
|
||||
Component(
|
||||
tag_name="div",
|
||||
attributes=[("class", _FILTER_GRID_CLASS)],
|
||||
children=[
|
||||
_filter_field(
|
||||
"Status",
|
||||
_enum_filter(
|
||||
"status",
|
||||
status_options,
|
||||
status_choice,
|
||||
nullable=not Game._meta.get_field("status").has_default(),
|
||||
),
|
||||
),
|
||||
_filter_field(
|
||||
"Platform",
|
||||
_model_filter(
|
||||
"platform",
|
||||
platform_choice,
|
||||
search_url="/api/platforms/search",
|
||||
nullable=Game._meta.get_field("platform").null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
RangeSlider(
|
||||
label="Year",
|
||||
input_name_prefix="filter-year",
|
||||
min_value=year_min,
|
||||
max_value=year_max,
|
||||
range_min=year_range_min,
|
||||
range_max=year_range_max,
|
||||
min_placeholder="e.g. 2020",
|
||||
max_placeholder="e.g. 2024",
|
||||
),
|
||||
Component(
|
||||
tag_name="div",
|
||||
attributes=[("class", "flex items-end gap-4 mb-4")],
|
||||
children=[
|
||||
_filter_checkbox("filter-mastered", "Mastered", mastered_value),
|
||||
_filter_checkbox("filter-has-purchases", "Has Purchases", has_purchases_value),
|
||||
_filter_checkbox("filter-has-playevents", "Has Play Events", has_playevents_value),
|
||||
],
|
||||
),
|
||||
RangeSlider(
|
||||
label="Playtime",
|
||||
input_name_prefix="filter-playtime",
|
||||
min_value=playtime_min,
|
||||
max_value=playtime_max,
|
||||
range_min=0,
|
||||
range_max=playtime_range_max,
|
||||
step="1",
|
||||
min_placeholder="e.g. 1",
|
||||
max_placeholder="e.g. 100",
|
||||
),
|
||||
RangeSlider(
|
||||
label="Session Count",
|
||||
input_name_prefix="filter-session-count",
|
||||
min_value=session_count_min,
|
||||
max_value=session_count_max,
|
||||
range_min=0,
|
||||
range_max=100,
|
||||
step="1",
|
||||
min_placeholder="e.g. 1",
|
||||
max_placeholder="e.g. 50",
|
||||
),
|
||||
RangeSlider(
|
||||
label="Average Session Duration (mins)",
|
||||
input_name_prefix="filter-session-average",
|
||||
min_value=session_avg_min,
|
||||
max_value=session_avg_max,
|
||||
range_min=0,
|
||||
range_max=240,
|
||||
step="1",
|
||||
min_placeholder="e.g. 10",
|
||||
max_placeholder="e.g. 120",
|
||||
),
|
||||
]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update SessionFilterBar to support split duration fields**
|
||||
Replace old `duration_minutes` RangeSlider with split total, manual, and calculated duration RangeSliders.
|
||||
|
||||
```python
|
||||
# Inside common/components/filters.py: SessionFilterBar()
|
||||
|
||||
dur_tot_min, dur_tot_max = _parse_range(existing, "duration_total_minutes")
|
||||
dur_man_min, dur_man_max = _parse_range(existing, "duration_manual_minutes")
|
||||
dur_calc_min, dur_calc_max = _parse_range(existing, "duration_calculated_minutes")
|
||||
|
||||
# Inside fields array, replace RangeSlider "Duration" with:
|
||||
RangeSlider(
|
||||
label="Total Duration (mins)",
|
||||
input_name_prefix="filter-duration-total-minutes",
|
||||
min_value=dur_tot_min,
|
||||
max_value=dur_tot_max,
|
||||
range_min=0,
|
||||
range_max=duration_range_max * 60, # Range sliders use minutes now
|
||||
step="1",
|
||||
min_placeholder="e.g. 30",
|
||||
max_placeholder="e.g. 180",
|
||||
),
|
||||
RangeSlider(
|
||||
label="Manual Duration (mins)",
|
||||
input_name_prefix="filter-duration-manual-minutes",
|
||||
min_value=dur_man_min,
|
||||
max_value=dur_man_max,
|
||||
range_min=0,
|
||||
range_max=240,
|
||||
step="1",
|
||||
min_placeholder="e.g. 10",
|
||||
max_placeholder="e.g. 120",
|
||||
),
|
||||
RangeSlider(
|
||||
label="Calculated Duration (mins)",
|
||||
input_name_prefix="filter-duration-calculated-minutes",
|
||||
min_value=dur_calc_min,
|
||||
max_value=dur_calc_max,
|
||||
range_min=0,
|
||||
range_max=duration_range_max * 60,
|
||||
step="1",
|
||||
min_placeholder="e.g. 30",
|
||||
max_placeholder="e.g. 180",
|
||||
),
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update PurchaseFilterBar to support original and converted currencies and infinite flag**
|
||||
Add Checkboxes `infinite`, `needs_price_update` and currency StringCriterion text field / Choice options.
|
||||
|
||||
```python
|
||||
# Inside common/components/filters.py: PurchaseFilterBar()
|
||||
|
||||
infinite_value = _parse_bool(existing, "infinite")
|
||||
needs_price_update_value = _parse_bool(existing, "needs_price_update")
|
||||
price_currency_value = existing.get("price_currency", {}).get("value", "")
|
||||
converted_currency_value = existing.get("converted_currency", {}).get("value", "")
|
||||
|
||||
# Expand fields component array with:
|
||||
Component(
|
||||
tag_name="div",
|
||||
attributes=[("class", "flex gap-4 mb-4")],
|
||||
children=[
|
||||
_filter_checkbox("filter-refunded", "Refunded", is_refunded_value),
|
||||
_filter_checkbox("filter-infinite", "Infinite", infinite_value),
|
||||
_filter_checkbox("filter-needs-price-update", "Needs Price Update", needs_price_update_value),
|
||||
],
|
||||
),
|
||||
```
|
||||
|
||||
Add currency text filters (as primitive `Input` controls for string criteria):
|
||||
```python
|
||||
Component(
|
||||
tag_name="div",
|
||||
attributes=[("class", _FILTER_GRID_CLASS)],
|
||||
children=[
|
||||
_filter_field(
|
||||
"Original Currency",
|
||||
Component(
|
||||
tag_name="input",
|
||||
attributes=[
|
||||
("type", "text"),
|
||||
("name", "filter-price_currency"),
|
||||
("value", price_currency_value),
|
||||
("placeholder", "e.g. USD, EUR"),
|
||||
("class", "w-full rounded border-default-medium p-2 bg-neutral-secondary-medium text-body"),
|
||||
],
|
||||
),
|
||||
),
|
||||
_filter_field(
|
||||
"Converted Currency",
|
||||
Component(
|
||||
tag_name="input",
|
||||
attributes=[
|
||||
("type", "text"),
|
||||
("name", "filter-converted_currency"),
|
||||
("value", converted_currency_value),
|
||||
("placeholder", "e.g. USD, EUR"),
|
||||
("class", "w-full rounded border-default-medium p-2 bg-neutral-secondary-medium text-body"),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Create New FilterBars in `common/components/filters.py`
|
||||
|
||||
**Files:**
|
||||
- Modify: `common/components/filters.py`
|
||||
|
||||
- [ ] **Step 1: Implement DeviceFilterBar, PlatformFilterBar, and PlayEventFilterBar**
|
||||
|
||||
Append these three new filter bar components to `common/components/filters.py`:
|
||||
|
||||
```python
|
||||
def DeviceFilterBar(
|
||||
filter_json="", preset_list_url="", preset_save_url=""
|
||||
) -> SafeText:
|
||||
"""Collapsible filter bar for the Device list."""
|
||||
from games.models import Device
|
||||
|
||||
existing = _filter_parse(filter_json)
|
||||
type_options = Device.DEVICE_TYPES
|
||||
type_choice = _filter_get_choice(existing, "type")
|
||||
|
||||
fields = [
|
||||
Component(
|
||||
tag_name="div",
|
||||
attributes=[("class", _FILTER_GRID_CLASS)],
|
||||
children=[
|
||||
_filter_field(
|
||||
"Device Type",
|
||||
_enum_filter(
|
||||
"type",
|
||||
type_options,
|
||||
type_choice,
|
||||
nullable=True,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
return _filter_bar(fields, filter_json, preset_list_url, preset_save_url)
|
||||
|
||||
|
||||
def PlatformFilterBar(
|
||||
filter_json="", preset_list_url="", preset_save_url=""
|
||||
) -> SafeText:
|
||||
"""Collapsible filter bar for the Platform list."""
|
||||
existing = _filter_parse(filter_json)
|
||||
|
||||
name_value = existing.get("name", {}).get("value", "")
|
||||
group_value = existing.get("group", {}).get("value", "")
|
||||
|
||||
fields = [
|
||||
Component(
|
||||
tag_name="div",
|
||||
attributes=[("class", _FILTER_GRID_CLASS)],
|
||||
children=[
|
||||
_filter_field(
|
||||
"Platform Name",
|
||||
Component(
|
||||
tag_name="input",
|
||||
attributes=[
|
||||
("type", "text"),
|
||||
("name", "filter-name"),
|
||||
("value", name_value),
|
||||
("placeholder", "e.g. Nintendo Switch"),
|
||||
("class", "w-full rounded border-default-medium p-2 bg-neutral-secondary-medium text-body"),
|
||||
],
|
||||
),
|
||||
),
|
||||
_filter_field(
|
||||
"Platform Group",
|
||||
Component(
|
||||
tag_name="input",
|
||||
attributes=[
|
||||
("type", "text"),
|
||||
("name", "filter-group"),
|
||||
("value", group_value),
|
||||
("placeholder", "e.g. Nintendo"),
|
||||
("class", "w-full rounded border-default-medium p-2 bg-neutral-secondary-medium text-body"),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
return _filter_bar(fields, filter_json, preset_list_url, preset_save_url)
|
||||
|
||||
|
||||
def PlayEventFilterBar(
|
||||
filter_json="", preset_list_url="", preset_save_url=""
|
||||
) -> SafeText:
|
||||
"""Collapsible filter bar for the PlayEvent list."""
|
||||
from games.models import PlayEvent
|
||||
|
||||
existing = _filter_parse(filter_json)
|
||||
game_choice = _filter_get_choice(existing, "game")
|
||||
days_min, days_max = _parse_range(existing, "days_to_finish")
|
||||
|
||||
fields = [
|
||||
Component(
|
||||
tag_name="div",
|
||||
attributes=[("class", _FILTER_GRID_CLASS)],
|
||||
children=[
|
||||
_filter_field(
|
||||
"Game",
|
||||
_model_filter(
|
||||
"game",
|
||||
game_choice,
|
||||
search_url="/api/games/search",
|
||||
nullable=False,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
RangeSlider(
|
||||
label="Days to Finish",
|
||||
input_name_prefix="filter-days-to-finish",
|
||||
min_value=days_min,
|
||||
max_value=days_max,
|
||||
range_min=0,
|
||||
range_max=365,
|
||||
step="1",
|
||||
min_placeholder="e.g. 1",
|
||||
max_placeholder="e.g. 30",
|
||||
),
|
||||
]
|
||||
return _filter_bar(fields, filter_json, preset_list_url, preset_save_url)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Export new FilterBars in `common/components/__init__.py`**
|
||||
|
||||
Modify: `common/components/__init__.py` to import and expose `DeviceFilterBar`, `PlatformFilterBar`, and `PlayEventFilterBar`.
|
||||
|
||||
```python
|
||||
# Import section:
|
||||
from common.components.filters import (
|
||||
FilterBar,
|
||||
PurchaseFilterBar,
|
||||
SessionFilterBar,
|
||||
DeviceFilterBar,
|
||||
PlatformFilterBar,
|
||||
PlayEventFilterBar,
|
||||
)
|
||||
|
||||
# In __all__:
|
||||
"FilterBar",
|
||||
"PurchaseFilterBar",
|
||||
"SessionFilterBar",
|
||||
"DeviceFilterBar",
|
||||
"PlatformFilterBar",
|
||||
"PlayEventFilterBar",
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Integrate FilterBars into `Device`, `Platform`, and `PlayEvent` views
|
||||
|
||||
**Files:**
|
||||
- Modify: `games/views/device.py`
|
||||
- Modify: `games/views/platform.py`
|
||||
- Modify: `games/views/playevent.py`
|
||||
|
||||
- [ ] **Step 1: Integrate FilterBar in `list_devices` in `games/views/device.py`**
|
||||
|
||||
Import and parse the filter, apply to queryset, instantiate `DeviceFilterBar`, prepend it to the output page content.
|
||||
|
||||
```python
|
||||
# At top of games/views/device.py:
|
||||
from django.utils.safestring import mark_safe
|
||||
from common.components import DeviceFilterBar, ModuleScript
|
||||
from games.filters import parse_device_filter
|
||||
|
||||
# Inside list_devices(request):
|
||||
devices = Device.objects.order_by("-created_at")
|
||||
|
||||
filter_json = request.GET.get("filter", "")
|
||||
if filter_json:
|
||||
device_filter = parse_device_filter(filter_json)
|
||||
if device_filter is not None:
|
||||
devices = devices.filter(device_filter.to_q())
|
||||
|
||||
devices, page_obj, elided_page_range = paginate(request, devices)
|
||||
|
||||
# ... create data dict ...
|
||||
|
||||
# Prepend the filter bar above table:
|
||||
filter_bar = DeviceFilterBar(
|
||||
filter_json=filter_json,
|
||||
preset_list_url=reverse("games:list_presets") + "?mode=devices",
|
||||
preset_save_url=reverse("games:save_preset") + "?mode=devices",
|
||||
)
|
||||
content = mark_safe(str(filter_bar) + str(content))
|
||||
return render_page(
|
||||
request,
|
||||
content,
|
||||
title="Manage devices",
|
||||
scripts=ModuleScript("range_slider.js")
|
||||
+ ModuleScript("search_select.js")
|
||||
+ ModuleScript("filter_bar.js"),
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Integrate FilterBar in `list_platforms` in `games/views/platform.py`**
|
||||
|
||||
Import and parse the filter, apply to platform queryset, instantiate platform filter bar, prepend to page content.
|
||||
|
||||
```python
|
||||
# At top of games/views/platform.py:
|
||||
from django.utils.safestring import mark_safe
|
||||
from common.components import PlatformFilterBar, ModuleScript
|
||||
from games.filters import parse_platform_filter
|
||||
|
||||
# Inside list_platforms(request):
|
||||
platforms = Platform.objects.order_by("name")
|
||||
|
||||
filter_json = request.GET.get("filter", "")
|
||||
if filter_json:
|
||||
platform_filter = parse_platform_filter(filter_json)
|
||||
if platform_filter is not None:
|
||||
platforms = platforms.filter(platform_filter.to_q())
|
||||
|
||||
platforms, page_obj, elided_page_range = paginate(request, platforms)
|
||||
|
||||
# ... create data dict ...
|
||||
|
||||
filter_bar = PlatformFilterBar(
|
||||
filter_json=filter_json,
|
||||
preset_list_url=reverse("games:list_presets") + "?mode=platforms",
|
||||
preset_save_url=reverse("games:save_preset") + "?mode=platforms",
|
||||
)
|
||||
content = mark_safe(str(filter_bar) + str(content))
|
||||
return render_page(
|
||||
request,
|
||||
content,
|
||||
title="Manage platforms",
|
||||
scripts=ModuleScript("range_slider.js")
|
||||
+ ModuleScript("search_select.js")
|
||||
+ ModuleScript("filter_bar.js"),
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Integrate FilterBar in `list_playevents` in `games/views/playevent.py`**
|
||||
|
||||
Import and parse the filter, apply to playevent queryset, instantiate playevent filter bar, prepend to page content.
|
||||
|
||||
```python
|
||||
# At top of games/views/playevent.py:
|
||||
from django.utils.safestring import mark_safe
|
||||
from common.components import PlayEventFilterBar
|
||||
from games.filters import parse_playevent_filter
|
||||
|
||||
# Inside list_playevents(request):
|
||||
playevents = PlayEvent.objects.order_by("-created_at")
|
||||
|
||||
filter_json = request.GET.get("filter", "")
|
||||
if filter_json:
|
||||
playevent_filter = parse_playevent_filter(filter_json)
|
||||
if playevent_filter is not None:
|
||||
playevents = playevents.filter(playevent_filter.to_q())
|
||||
|
||||
playevents, page_obj, elided_page_range = paginate(request, playevents)
|
||||
|
||||
# ... create data ...
|
||||
|
||||
filter_bar = PlayEventFilterBar(
|
||||
filter_json=filter_json,
|
||||
preset_list_url=reverse("games:list_presets") + "?mode=playevents",
|
||||
preset_save_url=reverse("games:save_preset") + "?mode=playevents",
|
||||
)
|
||||
content = mark_safe(str(filter_bar) + str(content))
|
||||
return render_page(
|
||||
request,
|
||||
content,
|
||||
title="Manage play events",
|
||||
scripts=ModuleScript("range_slider.js")
|
||||
+ ModuleScript("search_select.js")
|
||||
+ ModuleScript("filter_bar.js"),
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Support new preset modes in Preset View/Model
|
||||
|
||||
Ensure FilterPreset allows `devices` and `platforms` modes.
|
||||
|
||||
**Files:**
|
||||
- Modify: `games/models.py`
|
||||
- Modify: `games/views/filter_presets.py`
|
||||
|
||||
- [ ] **Step 1: Expand FilterPreset mode choices**
|
||||
|
||||
Verify or expand `MODE_CHOICES` inside `FilterPreset` model in `games/models.py`.
|
||||
|
||||
```python
|
||||
# Inside FilterPreset class:
|
||||
MODE_CHOICES = [
|
||||
("games", "Games"),
|
||||
("sessions", "Sessions"),
|
||||
("purchases", "Purchases"),
|
||||
("playevents", "Play Events"),
|
||||
("devices", "Devices"),
|
||||
("platforms", "Platforms"),
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Add Render Tests for new FilterBars
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/test_filter_bars.py`
|
||||
|
||||
- [ ] **Step 1: Write tests to verify new FilterBars render correctly**
|
||||
|
||||
Add test cases in `tests/test_filter_bars.py`:
|
||||
|
||||
```python
|
||||
def test_device_filter_bar(self):
|
||||
from common.components import DeviceFilterBar
|
||||
html = str(
|
||||
DeviceFilterBar(
|
||||
filter_json="",
|
||||
preset_list_url="/presets/devices/list",
|
||||
preset_save_url="/presets/devices/save",
|
||||
)
|
||||
)
|
||||
self._assert_shell(html, "/presets/devices/list", "/presets/devices/save")
|
||||
|
||||
def test_platform_filter_bar(self):
|
||||
from common.components import PlatformFilterBar
|
||||
html = str(
|
||||
PlatformFilterBar(
|
||||
filter_json="",
|
||||
preset_list_url="/presets/platforms/list",
|
||||
preset_save_url="/presets/platforms/save",
|
||||
)
|
||||
)
|
||||
self._assert_shell(html, "/presets/platforms/list", "/presets/platforms/save")
|
||||
|
||||
def test_playevent_filter_bar(self):
|
||||
from common.components import PlayEventFilterBar
|
||||
html = str(
|
||||
PlayEventFilterBar(
|
||||
filter_json="",
|
||||
preset_list_url="/presets/playevents/list",
|
||||
preset_save_url="/presets/playevents/save",
|
||||
)
|
||||
)
|
||||
self._assert_shell(html, "/presets/playevents/list", "/presets/playevents/save")
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run all test suites to confirm complete success**
|
||||
|
||||
Run: `pytest tests/test_filter_bars.py -v`
|
||||
Expected: ALL filter bar render tests pass.
|
||||
@@ -0,0 +1,177 @@
|
||||
# Unify Form Checkboxes Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Unify all Django form checkboxes across the codebase by routing them through our new Python `Checkbox` primitive.
|
||||
**Architecture:**
|
||||
1. Modify `Checkbox` and `Radio` primitives in `common/components/primitives.py` to support headless (label-less) rendering when `label` is `None`, so they can be injected into Django's native `form.as_div()` rendering without duplicating labels.
|
||||
2. Create a `PrimitiveCheckboxWidget` in `games/forms.py` that extends `forms.CheckboxInput` but renders using our `Checkbox` Python component.
|
||||
3. Create a `PrimitiveWidgetsMixin` in `games/forms.py` that automatically applies the `PrimitiveCheckboxWidget` to all `forms.BooleanField` instances in a form. Add this mixin to all ModelForms.
|
||||
|
||||
**Tech Stack:** Python, Django Forms, HTML.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Update Primitives for Headless Rendering
|
||||
|
||||
**Files:**
|
||||
- Modify: `common/components/primitives.py`
|
||||
- Modify: `tests/test_components.py`
|
||||
|
||||
- [ ] **Step 1: Write a failing test for headless rendering**
|
||||
In `tests/test_components.py`, add a test to `ComponentPrimitivesTest`:
|
||||
```python
|
||||
def test_checkbox_headless(self):
|
||||
html = Checkbox(name="test-headless", label=None, checked=True)
|
||||
self.assertNotIn('<label', html)
|
||||
self.assertIn('<input', html)
|
||||
self.assertIn('type="checkbox"', html)
|
||||
self.assertIn('name="test-headless"', html)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
Run: `pytest tests/test_components.py -k test_checkbox_headless`
|
||||
Expected: Fail because `Checkbox` currently requires `label` as a `str` and always renders a `Label` wrapper.
|
||||
|
||||
- [ ] **Step 3: Update `Checkbox` and `Radio` in `common/components/primitives.py`**
|
||||
Update the function signatures to accept `label: str | None = None` and selectively return only the `Input` if `label` is missing.
|
||||
```python
|
||||
def Checkbox(
|
||||
name: str,
|
||||
label: str | None = None,
|
||||
checked: bool = False,
|
||||
value: str = "1",
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
) -> SafeText:
|
||||
"""A filter-agnostic Checkbox component."""
|
||||
attributes = attributes or []
|
||||
input_attrs = [
|
||||
("name", name),
|
||||
("value", value),
|
||||
("class", "rounded border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand"),
|
||||
] + attributes
|
||||
if checked:
|
||||
input_attrs.append(("checked", "true"))
|
||||
|
||||
input_el = Input(type="checkbox", attributes=input_attrs)
|
||||
if label is None:
|
||||
return input_el
|
||||
|
||||
return Label(
|
||||
attributes=[("class", "flex items-center gap-2 text-sm text-heading cursor-pointer")],
|
||||
children=[input_el, label],
|
||||
)
|
||||
|
||||
def Radio(
|
||||
name: str,
|
||||
label: str | None = None,
|
||||
checked: bool = False,
|
||||
value: str = "",
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
) -> SafeText:
|
||||
"""A filter-agnostic Radio component."""
|
||||
attributes = attributes or []
|
||||
input_attrs = [
|
||||
("name", name),
|
||||
("value", value),
|
||||
("class", "rounded-full border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand"),
|
||||
] + attributes
|
||||
if checked:
|
||||
input_attrs.append(("checked", "true"))
|
||||
|
||||
input_el = Input(type="radio", attributes=input_attrs)
|
||||
if label is None:
|
||||
return input_el
|
||||
|
||||
return Label(
|
||||
attributes=[("class", "flex items-center gap-1.5 text-sm text-heading cursor-pointer")],
|
||||
children=[input_el, label],
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
Run: `pytest tests/test_components.py -k ComponentPrimitivesTest`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
Run:
|
||||
```bash
|
||||
git add common/components/primitives.py tests/test_components.py
|
||||
git commit -m "refactor: allow Checkbox and Radio primitives to render headlessly without labels"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Create Django Widget Adapter and Mixin
|
||||
|
||||
**Files:**
|
||||
- Modify: `games/forms.py`
|
||||
|
||||
- [ ] **Step 1: Write the Widget and Mixin implementations**
|
||||
At the top of `games/forms.py`, import `Checkbox` and implement `PrimitiveCheckboxWidget` and `PrimitiveWidgetsMixin`.
|
||||
```python
|
||||
from common.components.primitives import Checkbox
|
||||
|
||||
class PrimitiveCheckboxWidget(forms.CheckboxInput):
|
||||
"""Adapts Django's CheckboxInput to use our Checkbox component."""
|
||||
def render(self, name, value, attrs=None, renderer=None):
|
||||
final_attrs = self.build_attrs(self.attrs, attrs)
|
||||
checked = self.check_test(value)
|
||||
attributes = [(k, str(v)) for k, v in final_attrs.items() if k not in ("type", "name", "value", "checked")]
|
||||
|
||||
# Django uses boolean values differently for checkboxes, we omit value if empty
|
||||
return str(Checkbox(
|
||||
name=name,
|
||||
label=None,
|
||||
checked=checked,
|
||||
value=str(value) if value else "1",
|
||||
attributes=attributes
|
||||
))
|
||||
|
||||
class PrimitiveWidgetsMixin:
|
||||
"""Automatically applies primitive custom widgets to native Django form fields."""
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
for field_name, field in self.fields.items():
|
||||
if isinstance(field, forms.BooleanField):
|
||||
field.widget = PrimitiveCheckboxWidget()
|
||||
# Maintain the field's explicit required status (usually False for booleans)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Apply the Mixin to all Forms**
|
||||
In `games/forms.py`, update all the ModelForm classes to inherit from `PrimitiveWidgetsMixin` as the **first** base class (before `forms.ModelForm`).
|
||||
Example:
|
||||
```python
|
||||
class SessionForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
||||
# ...
|
||||
|
||||
class PurchaseForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
||||
# ...
|
||||
|
||||
class GameForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
||||
# ...
|
||||
|
||||
class PlatformForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
||||
# ...
|
||||
|
||||
class DeviceForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
||||
# ...
|
||||
|
||||
class PlayEventForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
||||
# ...
|
||||
|
||||
class GameStatusChangeForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
||||
# ...
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Test Django Form Rendering**
|
||||
Run the full test suite to ensure forms still validate properly and render without error.
|
||||
Run: `pytest`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
Run:
|
||||
```bash
|
||||
git add games/forms.py
|
||||
git commit -m "feat: replace all form BooleanFields with PrimitiveCheckboxWidget via mixin"
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,197 @@
|
||||
# Design Spec: Boolean Filters Overhaul (Approach A with Reusable Primitives)
|
||||
|
||||
Expose a two-radio-button UI for all boolean filters to allow selecting "True" (Yes), "False" (No), or leaving the filter "Unset" (Not set).
|
||||
|
||||
## 1. Architectural Changes
|
||||
|
||||
### 1.1 Backend Primitives & Components
|
||||
|
||||
We will extract the `_filter_checkbox` rendering logic from `common/components/filters.py` and generalize it into a reusable, filter-agnostic `Checkbox` component in `common/components/primitives.py`. We will also add a corresponding `Radio` component.
|
||||
|
||||
#### In `common/components/primitives.py`:
|
||||
```python
|
||||
def Checkbox(
|
||||
name: str,
|
||||
label: str,
|
||||
checked: bool = False,
|
||||
value: str = "1",
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
) -> SafeText:
|
||||
"""A filter-agnostic Checkbox component."""
|
||||
attributes = attributes or []
|
||||
input_attrs = [
|
||||
("name", name),
|
||||
("value", value),
|
||||
("class", "rounded border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand"),
|
||||
] + attributes
|
||||
if checked:
|
||||
input_attrs.append(("checked", "true"))
|
||||
|
||||
return Label(
|
||||
attributes=[("class", "flex items-center gap-2 text-sm text-heading cursor-pointer")],
|
||||
children=[
|
||||
Input(type="checkbox", attributes=input_attrs),
|
||||
label,
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def Radio(
|
||||
name: str,
|
||||
label: str,
|
||||
checked: bool = False,
|
||||
value: str = "",
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
) -> SafeText:
|
||||
"""A filter-agnostic Radio component."""
|
||||
attributes = attributes or []
|
||||
input_attrs = [
|
||||
("name", name),
|
||||
("value", value),
|
||||
("class", "rounded-full border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand"),
|
||||
] + attributes
|
||||
if checked:
|
||||
input_attrs.append(("checked", "true"))
|
||||
|
||||
return Label(
|
||||
attributes=[("class", "flex items-center gap-1.5 text-sm text-heading cursor-pointer")],
|
||||
children=[
|
||||
Input(type="radio", attributes=input_attrs),
|
||||
label,
|
||||
],
|
||||
)
|
||||
```
|
||||
|
||||
#### In `common/components/filters.py`:
|
||||
We will import `Checkbox` and `Radio` from `common.components.primitives`. We will redefine `_filter_checkbox` as a thin adapter pointing to our new generalized `Checkbox` component (preserving any backward compatibility), and we will create a new helper `_filter_boolean_radio` using `Radio`:
|
||||
|
||||
```python
|
||||
_FILTER_RADIO_CLASS = (
|
||||
"rounded-full border-default-medium bg-neutral-secondary-medium "
|
||||
"text-brand focus:ring-brand"
|
||||
)
|
||||
|
||||
def _filter_checkbox(name: str, label: str, checked: bool) -> SafeText:
|
||||
"""Thin adapter mapping legacy checkbox filters to the generalized Checkbox primitive."""
|
||||
return Checkbox(name=name, label=label, checked=checked)
|
||||
|
||||
|
||||
def _filter_boolean_radio(name: str, label: str, value: bool | None) -> SafeText:
|
||||
"""Renders a filter-specific boolean radio button group with 'True' and 'False' options."""
|
||||
return Div(
|
||||
attributes=[("class", "flex flex-col gap-1")],
|
||||
children=[
|
||||
Span(
|
||||
attributes=[("class", _FILTER_LABEL_CLASS)],
|
||||
children=[label],
|
||||
),
|
||||
Div(
|
||||
attributes=[("class", "flex items-center gap-4 h-9")],
|
||||
children=[
|
||||
Radio(name=name, label="True", checked=value is True, value="true"),
|
||||
Radio(name=name, label="False", checked=value is False, value="false"),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
```
|
||||
|
||||
### 1.2 Parsing Filter JSON (Backend)
|
||||
|
||||
We will introduce a robust parsing function in `common/components/filters.py` to distinguish `True`, `False`, and `None` (unset):
|
||||
|
||||
```python
|
||||
def _parse_bool_nullable(existing: dict, key: str) -> bool | None:
|
||||
"""Extract a nullable boolean value from a filter criterion."""
|
||||
if key not in existing:
|
||||
return None
|
||||
field = existing[key]
|
||||
if not isinstance(field, dict):
|
||||
return None
|
||||
val = field.get("value")
|
||||
if val is None:
|
||||
return None
|
||||
if isinstance(val, str):
|
||||
if val.lower() in ("true", "1", "yes"):
|
||||
return True
|
||||
if val.lower() in ("false", "0", "no"):
|
||||
return False
|
||||
return bool(val)
|
||||
```
|
||||
|
||||
### 1.3 UI Overhauls in Filter Bars
|
||||
|
||||
We will update the following filter bars to use `_parse_bool_nullable` and `_filter_boolean_radio`:
|
||||
1. **GameFilterBar:** `mastered`, `purchase_refunded`, `purchase_infinite`, `session_emulated`.
|
||||
2. **SessionFilterBar:** `emulated`, `is_active`.
|
||||
3. **PurchaseFilterBar:** `is_refunded`, `infinite`, `needs_price_update`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Frontend JS Changes (`games/static/js/filter_bar.js`)
|
||||
|
||||
### 2.1 Deselectable Radios Behavior
|
||||
To support resetting filters back to "Unset" without resetting the whole form, we add click behavior that unchecks an already checked radio button when clicked.
|
||||
|
||||
```javascript
|
||||
function setupDeselectableRadios() {
|
||||
document.querySelectorAll('input[type="radio"]').forEach(function (radio) {
|
||||
radio.addEventListener('click', function (e) {
|
||||
if (this.wasChecked) {
|
||||
this.checked = false;
|
||||
this.wasChecked = false;
|
||||
this.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
} else {
|
||||
var name = this.getAttribute('name');
|
||||
if (name) {
|
||||
document.querySelectorAll('input[type="radio"][name="' + name + '"]').forEach(function (r) {
|
||||
r.wasChecked = false;
|
||||
});
|
||||
}
|
||||
this.wasChecked = true;
|
||||
}
|
||||
});
|
||||
if (radio.checked) {
|
||||
radio.wasChecked = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
We will call `setupDeselectableRadios()` during `DOMContentLoaded`.
|
||||
|
||||
### 2.2 Serializing Radio States
|
||||
Update `buildFilterJSON(form)` to collect checked radios from boolean field groups:
|
||||
|
||||
```javascript
|
||||
// 2. Boolean Fields (Radio Button Groups)
|
||||
var booleanFields = [
|
||||
{ name: "filter-mastered", key: "mastered" },
|
||||
{ name: "filter-emulated", key: "emulated" },
|
||||
{ name: "filter-active", key: "is_active" },
|
||||
{ name: "filter-refunded", key: "is_refunded" },
|
||||
{ name: "filter-infinite", key: "infinite" },
|
||||
{ name: "filter-needs-price-update", key: "needs_price_update" },
|
||||
{ name: "filter-purchase-refunded", key: "purchase_refunded" },
|
||||
{ name: "filter-purchase-infinite", key: "purchase_infinite" },
|
||||
{ name: "filter-session-emulated", key: "session_emulated" }
|
||||
];
|
||||
booleanFields.forEach(function (bf) {
|
||||
var el = form.querySelector('[name="' + bf.name + '"]:checked');
|
||||
if (el) {
|
||||
var val = el.value === "true";
|
||||
filter[bf.key] = criterion(val, null, "EQUALS");
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Testing Strategy
|
||||
|
||||
1. **Unit Tests (`tests/test_filter_helpers.py`):**
|
||||
- Add test coverage for `_parse_bool_nullable` covering `None`, `True`, `False`, strings, missing keys, etc.
|
||||
2. **Component Tests (`tests/test_filter_bars.py`):**
|
||||
- Update tests where the filters render checkbox elements to assert that radio groups are rendered instead (with "True" and "False" radio buttons).
|
||||
3. **Integration and End-to-End Tests:**
|
||||
- Execute the test suite using `pytest` to ensure that all 355 tests continue to pass and reflect the updated UI structure perfectly.
|
||||
@@ -0,0 +1,157 @@
|
||||
# HTML + JS component authoring — design
|
||||
|
||||
**Date:** 2026-06-13
|
||||
**Status:** Approved (design); pending implementation plan
|
||||
**Branch context:** follows the lazy node-tree component system (`Element`/`Safe`/`Fragment`/`Media`) and the `Children`/`Attributes` typing work.
|
||||
|
||||
## Problem
|
||||
|
||||
Trusted HTML and JavaScript are authored as Python f-strings in several places. Two distinct pains:
|
||||
|
||||
- **HTML-as-string** — `Navbar`, `_TOAST_CONTAINER`, the played-row markup skeleton, and the generally verbose `Element("div", attributes=[...], children=[...])` call shape.
|
||||
- **JS-in-string** — the genuinely ugly ones: `GameStatusSelector` (~70 lines) and `SessionDeviceSelector` (~50 lines) inline an Alpine `x-data="{...}"` blob with `fetchWithHtmxTriggers`, server-value interpolation (`{game.status}`), **and** `{{ }}` brace-doubling throughout; `_PLAYED_ROW_TEMPLATE` dodges the brace collision entirely by switching to `@@TOKEN@@` placeholders + a `.replace()` loop.
|
||||
|
||||
You cannot node-tree JavaScript, so the JS pain needs a different answer than the HTML pain. The newer widgets (`search_select`, `range_slider`, `filter_bar`) already moved behavior into real `.js` files wired by `onSwap` + `data-*` attributes; the Alpine selectors are the holdouts that still inline their JS.
|
||||
|
||||
## Goal
|
||||
|
||||
Establish the *right* way to author interactive, server-rendered components in this codebase, and convert a few exemplars to prove it. North-star principle:
|
||||
|
||||
> The server never writes a line of JavaScript. The server↔client boundary is a typed, declarative contract. Behavior lives in real, tooled TypeScript files.
|
||||
|
||||
## Decisions (locked during brainstorming)
|
||||
|
||||
| Decision | Choice |
|
||||
| --- | --- |
|
||||
| HTML authoring | **htpy-*style* sugar on the existing `Element`** (not the htpy library) — keeps `Media`/`collect_media`, no build step |
|
||||
| JS runtime model | **Custom Elements** (Web Components), light DOM |
|
||||
| Server↔client contract | **Typed contract + codegen** (one Python `Props` type → generated TS interface + reader) |
|
||||
| JS language | **TypeScript** (real `.ts`, compiled) |
|
||||
| Build tool | **`tsc` per-module** (no bundler) — preserves per-component `Media` loading |
|
||||
| Alpine, for converted components | **Retired** — behavior rewritten as vanilla TS in the element class |
|
||||
| Exemplars | **`GameStatusSelector` + `SessionDeviceSelector` + played-row** |
|
||||
| Compiled output | **Build-only, gitignored** (produced by `make` + Docker) |
|
||||
| Existing hand-written `.js` | **Left as-is**, migrated to TS later |
|
||||
|
||||
## Architecture
|
||||
|
||||
Three independent layers composing through one typed seam:
|
||||
|
||||
```
|
||||
Python (server) TypeScript (client)
|
||||
───────────────── ───────────────────
|
||||
htpy-style Element ──renders──► <game-status-selector ──connectedCallback──► game-status-selector.ts
|
||||
+ Media (kept) game-id="3" status="f"> (vanilla DOM behavior)
|
||||
│ ▲
|
||||
└── GameStatusSelectorProps ─codegen─┘ generated props.ts (interface + typed reader)
|
||||
(one Python type = the whole server↔client contract)
|
||||
```
|
||||
|
||||
- **Layer 1 — htpy-style HTML** removes HTML-string / verbose-`Element` ugliness, pure Python, no build, `Media` untouched.
|
||||
- **Layer 2 — Custom Elements (TS)** removes JS-string ugliness; behavior in real typed modules with a native lifecycle.
|
||||
- **Layer 3 — Typed contract codegen** makes the seam type-safe in both languages from a single Python source.
|
||||
|
||||
### Layer 1 — htpy-style sugar on `Element`
|
||||
|
||||
Additive only. Existing `Element("div", attributes=[...], children=[...])` and `Div([("class","x")], "hi")` keep working.
|
||||
|
||||
- **Attributes as kwargs:** `Div(class_="card", hx_get="/x", disabled=True)`. Translation: trailing `_` stripped (`class_`→`class`); inner `_`→`-` (`hx_get`→`hx-get`, `data_id`→`data-id`); `True`→bare attribute, `False`/`None`→omitted.
|
||||
- **Children via `[]`:** `Div(class_="card")[H1["Title"], body]`. `Element.__getitem__` normalizes through the existing `as_children` and returns an `Element` carrying the same attributes and media.
|
||||
|
||||
The result is still a walkable `Element` tree, so `collect_media` / `Media` are unaffected. This is the "htpy feel on our own node so the asset system survives" decision.
|
||||
|
||||
Example:
|
||||
|
||||
```python
|
||||
Div(class_="flex gap-2 items-center")[
|
||||
Icon("play"),
|
||||
Span(class_="label")[name],
|
||||
]
|
||||
```
|
||||
|
||||
### Layer 2 — Custom Elements (TypeScript, light DOM)
|
||||
|
||||
- Python builder emits a **semantic tag**: `Element("game-status-selector", attrs).with_media(Media(js=("dist/elements/game-status-selector.js",)))`.
|
||||
- **Light DOM** (no shadow root — Tailwind's global classes must apply). The server renders the inner markup (htpy-style); the element enhances it.
|
||||
- **Native lifecycle replaces `onSwap`:** `connectedCallback()` fires when the browser parses or htmx-swaps the element in; `disconnectedCallback()` provides free teardown. No init registry, no guard flags.
|
||||
- Behavior is **vanilla TS** — the element class owns its state (dropdown open/closed, PATCH-on-select via `fetchWithHtmxTriggers`). Alpine retired for these three.
|
||||
- Source `ts/elements/<tag>.ts` → compiled `games/static/js/dist/elements/<tag>.js`, loaded only on pages that use it (via `Media`).
|
||||
|
||||
### Layer 3 — Typed contract (one Python type → the whole seam)
|
||||
|
||||
Each element declares its props once, in Python:
|
||||
|
||||
```python
|
||||
class GameStatusSelectorProps(TypedDict):
|
||||
game_id: int
|
||||
status: str
|
||||
csrf: str
|
||||
```
|
||||
|
||||
- The **Python builder** takes these typed args and serializes them to kebab-case attributes (`game-id="3"`).
|
||||
- **Codegen** reads the registered Props types and emits, per component, into `ts/generated/props.ts`:
|
||||
- an **interface** — `GameStatusSelectorProps { gameId: number; status: string; csrf: string }`, and
|
||||
- a **typed reader** — `readGameStatusSelectorProps(el): GameStatusSelectorProps` that pulls and parses attributes (`Number(el.getAttribute("game-id"))`, etc.).
|
||||
- The element imports the generated reader. The entire server↔client boundary is generated from one Python type: rename `game_id` in Python, regenerate, and `tsc` fails until the element updates. Drift is caught at build time; no hand-written `getAttribute` soup, no silent attr-name drift.
|
||||
|
||||
Type map: `int`/`float` → `number`, `str` → `string`, `bool` → `boolean`. Field `game_id` → attr `game-id` → TS prop `gameId`. Reader parsing follows the type (number → `Number(...)`, bool → presence / `=== "true"`, string → `getAttribute(...) ?? ""`).
|
||||
|
||||
## Toolchain (`tsc` per-module, build-only)
|
||||
|
||||
Layout:
|
||||
|
||||
```
|
||||
ts/
|
||||
elements/game-status-selector.ts # hand-written element classes
|
||||
generated/props.ts # codegen output (gitignored)
|
||||
globals.d.ts # ambient: window.fetchWithHtmxTriggers, htmx
|
||||
tsconfig.json # strict, ES2022, lib [ES2022, DOM, DOM.Iterable]
|
||||
# rootDir: ts/ → outDir: games/static/js/dist/
|
||||
```
|
||||
|
||||
- **`games/static/js/dist/` is the only compiled output**, trivially gitignored, never colliding with hand-written `.js`. `Media` references `dist/elements/...`.
|
||||
- **package.json**: add `typescript` devDep; scripts `build:ts` (`tsc -p tsconfig.json`), `watch:ts` (`tsc -p tsconfig.json --watch`).
|
||||
- **Makefile**: `make ts` = codegen → `tsc`; `make dev` also runs `tsc --watch` (beside Django runserver + Tailwind watch); `make check` gains `tsc --noEmit` as a drift gate.
|
||||
- **.gitignore**: `games/static/js/dist/`, `ts/generated/`.
|
||||
- **Docker**: add a `make ts` step in the image build (npm already present for Tailwind); compiled JS baked into the image. Runtime stays offline.
|
||||
- **TS lint/format**: deferred — `tsc --strict` is the only gate for now.
|
||||
|
||||
### Codegen mechanics
|
||||
|
||||
- A registry maps `tag → Props type` (e.g. a decorator `@element("game-status-selector", GameStatusSelectorProps)` on the Python builder, collected into a module-level registry).
|
||||
- A Django management command (or script) imports the registry and writes `ts/generated/props.ts` (interface + reader per component).
|
||||
- **Ordering:** codegen runs before `tsc` (the generated file is a `tsc` input). CI runs codegen then `tsc --noEmit`, so Python/TS drift fails the build. No committed generated artifact to diff against — `tsc` failing on drift is the gate.
|
||||
|
||||
## Exemplar conversions
|
||||
|
||||
1. **`GameStatusSelector` → `<game-status-selector game-id status csrf>`** — Python builds the light-DOM htpy-style; `game-status-selector.ts` wires the dropdown toggle + click→PATCH `/api/games/{id}/status` via `fetchWithHtmxTriggers` with CSRF, and updates the displayed status. Deletes the ~70-line f-string + brace-doubling.
|
||||
2. **`SessionDeviceSelector` → `<session-device-selector>`** — same shape; PATCH `/api/session/{id}/device`.
|
||||
3. **played-row → `<play-event-row>`** (non-Alpine) — deletes `_PLAYED_ROW_TEMPLATE` and the `@@TOKEN@@` / `.replace()` hack; Python builds markup htpy-style; `play-event-row.ts` owns the dropdown + add-playthrough POST. URLs are server-reversed and passed as attributes. Proves the pattern is not Alpine-only.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Python**: builders render the correct tag + attributes (extend `test_components` / `test_rendered_pages`); assert no f-string remnants remain.
|
||||
- **Type-check**: `tsc --noEmit` in `make check` — type errors, including contract drift, fail CI.
|
||||
- **e2e (Playwright)**: real Chromium upgrades the custom elements natively; port/extend the existing widget-e2e pattern for all three (open dropdown → select → PATCH → DOM updates).
|
||||
|
||||
## Risks and mitigations
|
||||
|
||||
1. **Element module must be loaded before its tag appears.** Full-page render loads the module via `Media`; htmx row-swaps reuse the already-defined element. Constraint to document: a fragment response that introduces a brand-new element type must include that element's `Media`. (Same limitation class as today's "`onSwap` needs the script present.")
|
||||
2. **A build step is now required** for `make dev` and Docker. One-time wiring, mitigated by Make/Docker integration.
|
||||
3. **First TypeScript in the repo** — adds `typescript`, `tsconfig.json`, a Docker build step. Scoped to `ts/`; existing `.js` untouched.
|
||||
4. **CSRF/PATCH parity** — the vanilla TS must replicate the Alpine version's fetch/CSRF/`HX-Trigger` behavior; it reuses the existing `fetchWithHtmxTriggers`; e2e guards it.
|
||||
5. **Codegen ↔ build ordering** — codegen must precede `tsc`; encoded in `make ts`.
|
||||
|
||||
## Out of scope (YAGNI)
|
||||
|
||||
- Migrating the existing hand-written `.js` to TypeScript (later, incrementally).
|
||||
- Bundling / minification of app JS.
|
||||
- Shadow DOM / scoped styles.
|
||||
- A general island / props-blob hydration runtime (custom elements cover these three).
|
||||
- TS lint/format tooling (prettier/eslint).
|
||||
|
||||
## Future on-ramps (not now)
|
||||
|
||||
- **More custom elements**: migrate the remaining `onSwap` widgets to custom elements once the pattern is proven.
|
||||
- **Existing `.js` → TS**: incremental, file by file (`tsc` checks mixed projects).
|
||||
- The typed contract already positions the boundary for full type-safety as more client code becomes TS.
|
||||
@@ -0,0 +1 @@
|
||||
# e2e tests package
|
||||
@@ -0,0 +1,22 @@
|
||||
import os
|
||||
import shutil
|
||||
import pytest
|
||||
|
||||
# Playwright runs an async event loop in the background, which triggers
|
||||
# Django's async safety checks when running synchronous tests. This allows
|
||||
# synchronous operations inside the async context safely.
|
||||
os.environ.setdefault("DJANGO_ALLOW_ASYNC_UNSAFE", "true")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def browser_type_launch_args(browser_type_launch_args):
|
||||
# Try to find a system-installed Google Chrome or Chromium to bypass Nix/NixOS shared library issues
|
||||
for browser_name in ["google-chrome-stable", "google-chrome", "chromium", "chrome"]:
|
||||
path = shutil.which(browser_name)
|
||||
if path:
|
||||
return {
|
||||
**browser_type_launch_args,
|
||||
"executable_path": path,
|
||||
}
|
||||
# Fallback to default Playwright behavior
|
||||
return browser_type_launch_args
|
||||
@@ -0,0 +1,112 @@
|
||||
"""End-to-end Playwright test for boolean radio filter serialization and deselect behavior.
|
||||
|
||||
Covers:
|
||||
1. Selecting True/False serializes the boolean field as True/False.
|
||||
2. Unsetting/unchecking a radio button by clicking on it again, which deselects it, omitting the field from JSON.
|
||||
"""
|
||||
|
||||
import json
|
||||
import urllib.parse
|
||||
|
||||
import pytest
|
||||
from django.http import HttpResponse
|
||||
from django.test import override_settings
|
||||
from django.urls import path
|
||||
|
||||
from common.components import FilterBar
|
||||
|
||||
|
||||
def _bar_page(filter_json: str = "") -> str:
|
||||
return f"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Boolean filter E2E</title>
|
||||
<script src="/static/js/htmx.min.js"></script>
|
||||
<script src="/static/js/range_slider.js" type="module"></script>
|
||||
<script src="/static/js/search_select.js" type="module"></script>
|
||||
<script src="/static/js/filter_bar.js" type="module"></script>
|
||||
</head>
|
||||
<body>
|
||||
{FilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
def empty_bar_view(request):
|
||||
return HttpResponse(_bar_page())
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("test-boolean-filter/", empty_bar_view),
|
||||
]
|
||||
|
||||
|
||||
def _filter_from_url(url: str) -> dict:
|
||||
"""Extract and parse the ?filter=... query param from a URL."""
|
||||
query = urllib.parse.urlparse(url).query
|
||||
params = urllib.parse.parse_qs(query)
|
||||
raw = params.get("filter", [""])[0]
|
||||
return json.loads(raw) if raw else {}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_boolean_filter_e2e")
|
||||
def test_no_selection_omits_boolean_filters(live_server, page):
|
||||
page.goto(live_server.url + "/test-boolean-filter/")
|
||||
with page.expect_navigation():
|
||||
page.evaluate(
|
||||
"document.getElementById('filter-bar-form')"
|
||||
".dispatchEvent(new Event('submit', {cancelable: true}))"
|
||||
)
|
||||
parsed = _filter_from_url(page.url)
|
||||
assert "mastered" not in parsed
|
||||
assert "purchase_refunded" not in parsed
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_boolean_filter_e2e")
|
||||
def test_select_true_and_false_serializes_correctly(live_server, page):
|
||||
page.goto(live_server.url + "/test-boolean-filter/")
|
||||
|
||||
# Select "True" for Mastered
|
||||
# Under PurchaseFilterBar: "filter-mastered" is the mastered radio name.
|
||||
# The true radio has value="true", false radio has value="false"
|
||||
true_radio = page.locator('input[name="filter-mastered"][value="true"]')
|
||||
true_radio.click()
|
||||
|
||||
# Select "False" for Refunded (filter-purchase-refunded)
|
||||
false_radio = page.locator('input[name="filter-purchase-refunded"][value="false"]')
|
||||
false_radio.click()
|
||||
|
||||
with page.expect_navigation():
|
||||
page.evaluate(
|
||||
"document.getElementById('filter-bar-form')"
|
||||
".dispatchEvent(new Event('submit', {cancelable: true}))"
|
||||
)
|
||||
parsed = _filter_from_url(page.url)
|
||||
assert parsed.get("mastered") == {"value": True, "modifier": "EQUALS"}
|
||||
assert parsed.get("purchase_refunded") == {"value": False, "modifier": "EQUALS"}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_boolean_filter_e2e")
|
||||
def test_click_to_deselect_radio_works(live_server, page):
|
||||
page.goto(live_server.url + "/test-boolean-filter/")
|
||||
|
||||
true_radio = page.locator('input[name="filter-mastered"][value="true"]')
|
||||
|
||||
# First click checks it
|
||||
true_radio.click()
|
||||
assert true_radio.is_checked()
|
||||
|
||||
# Second click deselects it
|
||||
true_radio.click()
|
||||
assert not true_radio.is_checked()
|
||||
|
||||
with page.expect_navigation():
|
||||
page.evaluate(
|
||||
"document.getElementById('filter-bar-form')"
|
||||
".dispatchEvent(new Event('submit', {cancelable: true}))"
|
||||
)
|
||||
parsed = _filter_from_url(page.url)
|
||||
assert "mastered" not in parsed
|
||||
@@ -0,0 +1,84 @@
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
from playwright.sync_api import Page, expect
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def authenticated_page(live_server, page: Page, django_user_model) -> Page:
|
||||
django_user_model.objects.create_user(username="tester", password="secret123")
|
||||
page.goto(f"{live_server.url}{reverse('login')}")
|
||||
page.fill('input[name="username"]', "tester")
|
||||
page.fill('input[name="password"]', "secret123")
|
||||
page.click('input[type="submit"]')
|
||||
page.wait_for_url(f"{live_server.url}/tracker**")
|
||||
return page
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_game_status_selector_opens_and_patches(authenticated_page: Page, live_server):
|
||||
from games.models import Game, Platform
|
||||
|
||||
platform = Platform.objects.create(name="PC", icon="pc")
|
||||
game = Game.objects.create(name="Test Game", platform=platform, status="u")
|
||||
|
||||
page = authenticated_page
|
||||
page.goto(f"{live_server.url}{reverse('games:list_games')}")
|
||||
|
||||
host = page.locator("game-status-selector").first
|
||||
expect(host).to_be_attached()
|
||||
host.locator("[data-toggle]").click()
|
||||
expect(host.locator("[data-menu]")).to_be_visible()
|
||||
with page.expect_response(
|
||||
lambda r: "/status" in r.url and r.request.method == "PATCH"
|
||||
):
|
||||
host.locator('[data-option][data-value="f"]').click()
|
||||
expect(host.locator("[data-menu]")).to_be_hidden()
|
||||
game.refresh_from_db()
|
||||
assert game.status == "f"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_session_device_selector_patches(authenticated_page: Page, live_server):
|
||||
from games.models import Device, Game, Platform, Session
|
||||
|
||||
platform = Platform.objects.create(name="PC", icon="pc")
|
||||
game = Game.objects.create(name="Test Game", platform=platform)
|
||||
desktop = Device.objects.create(name="Desktop")
|
||||
deck = Device.objects.create(name="Deck")
|
||||
session = Session.objects.create(
|
||||
game=game, device=desktop, timestamp_start="2025-01-01 00:00:00+00:00"
|
||||
)
|
||||
|
||||
page = authenticated_page
|
||||
page.goto(f"{live_server.url}{reverse('games:list_sessions')}")
|
||||
|
||||
host = page.locator("session-device-selector").first
|
||||
expect(host).to_be_attached()
|
||||
host.locator("[data-toggle]").click()
|
||||
with page.expect_response(
|
||||
lambda r: "/device" in r.url and r.request.method == "PATCH"
|
||||
):
|
||||
host.locator(f'[data-option][data-value="{deck.id}"]').click()
|
||||
session.refresh_from_db()
|
||||
assert session.device_id == deck.id
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_play_event_row_increments(authenticated_page: Page, live_server):
|
||||
from games.models import Game, Platform
|
||||
|
||||
platform = Platform.objects.create(name="PC", icon="pc")
|
||||
game = Game.objects.create(name="Test Game", platform=platform)
|
||||
|
||||
page = authenticated_page
|
||||
page.goto(f"{live_server.url}{reverse('games:view_game', args=[game.id])}")
|
||||
|
||||
host = page.locator("play-event-row").first
|
||||
expect(host).to_be_attached()
|
||||
host.locator("[data-toggle]").click()
|
||||
with page.expect_response(
|
||||
lambda r: "playevent" in r.url.lower() and r.request.method == "POST"
|
||||
):
|
||||
host.locator("[data-add-play]").click()
|
||||
expect(host.locator("[data-count]")).to_have_text("1")
|
||||
assert game.playevents.count() == 1
|
||||
@@ -0,0 +1,167 @@
|
||||
"""End-to-end Playwright test for the date-range filter widget's JS submit path.
|
||||
|
||||
Covers the one layer the Django-Client tests in ``test_rendered_pages.py``
|
||||
cannot reach: ``filter_bar.js`` reading the two ``<input type="date">``
|
||||
elements, building a ``DateCriterion`` JSON object, and navigating the
|
||||
browser to ``?filter=<encoded>``.
|
||||
|
||||
The native ``<input type="date">`` path is exercised through the Refunded
|
||||
field — the Purchased field now uses the DateRangePicker component, covered
|
||||
by ``test_date_range_picker_e2e.py``.
|
||||
|
||||
Renders the bar at its own custom URL so the test doesn't need to auth
|
||||
against the real app — the bar's JS doesn't care what route serves it.
|
||||
"""
|
||||
|
||||
import json
|
||||
import urllib.parse
|
||||
|
||||
import pytest
|
||||
from django.http import HttpResponse
|
||||
from django.test import override_settings
|
||||
from django.urls import path
|
||||
|
||||
from common.components import PurchaseFilterBar
|
||||
|
||||
|
||||
def _bar_page(filter_json: str = "") -> str:
|
||||
return f"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Date filter E2E</title>
|
||||
<script src="/static/js/htmx.min.js"></script>
|
||||
<script src="/static/js/range_slider.js" type="module"></script>
|
||||
<script src="/static/js/search_select.js" type="module"></script>
|
||||
<script src="/static/js/filter_bar.js" type="module"></script>
|
||||
</head>
|
||||
<body>
|
||||
{PurchaseFilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
def empty_bar_view(request):
|
||||
return HttpResponse(_bar_page())
|
||||
|
||||
|
||||
def prefilled_bar_view(request):
|
||||
filter_json = json.dumps(
|
||||
{
|
||||
"date_refunded": {
|
||||
"value": "2024-03-15",
|
||||
"value2": "2024-09-20",
|
||||
"modifier": "BETWEEN",
|
||||
}
|
||||
}
|
||||
)
|
||||
return HttpResponse(_bar_page(filter_json))
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("test-date-filter/", empty_bar_view),
|
||||
path("test-date-filter-prefilled/", prefilled_bar_view),
|
||||
]
|
||||
|
||||
|
||||
def _filter_from_url(url: str) -> dict:
|
||||
"""Extract and parse the ?filter=... query param from a URL."""
|
||||
query = urllib.parse.urlparse(url).query
|
||||
params = urllib.parse.parse_qs(query)
|
||||
raw = params.get("filter", [""])[0]
|
||||
return json.loads(raw) if raw else {}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_date_filter_e2e")
|
||||
def test_both_dates_serializes_as_between(live_server, page):
|
||||
page.goto(live_server.url + "/test-date-filter/")
|
||||
page.locator('input[name="filter-date-refunded-min"]').fill("2024-01-01")
|
||||
page.locator('input[name="filter-date-refunded-max"]').fill("2024-12-31")
|
||||
with page.expect_navigation():
|
||||
page.evaluate(
|
||||
"document.getElementById('filter-bar-form')"
|
||||
".dispatchEvent(new Event('submit', {cancelable: true}))"
|
||||
)
|
||||
parsed = _filter_from_url(page.url)
|
||||
assert parsed == {
|
||||
"date_refunded": {
|
||||
"value": "2024-01-01",
|
||||
"value2": "2024-12-31",
|
||||
"modifier": "BETWEEN",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_date_filter_e2e")
|
||||
def test_min_only_serializes_as_greater_than(live_server, page):
|
||||
page.goto(live_server.url + "/test-date-filter/")
|
||||
page.locator('input[name="filter-date-refunded-min"]').fill("2024-06-15")
|
||||
with page.expect_navigation():
|
||||
page.evaluate(
|
||||
"document.getElementById('filter-bar-form')"
|
||||
".dispatchEvent(new Event('submit', {cancelable: true}))"
|
||||
)
|
||||
parsed = _filter_from_url(page.url)
|
||||
assert parsed == {
|
||||
"date_refunded": {"value": "2024-06-15", "modifier": "GREATER_THAN"}
|
||||
}
|
||||
# value2 must not be present when there's no upper bound.
|
||||
assert "value2" not in parsed["date_refunded"]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_date_filter_e2e")
|
||||
def test_max_only_serializes_as_less_than(live_server, page):
|
||||
page.goto(live_server.url + "/test-date-filter/")
|
||||
page.locator('input[name="filter-date-refunded-max"]').fill("2025-06-30")
|
||||
with page.expect_navigation():
|
||||
page.evaluate(
|
||||
"document.getElementById('filter-bar-form')"
|
||||
".dispatchEvent(new Event('submit', {cancelable: true}))"
|
||||
)
|
||||
parsed = _filter_from_url(page.url)
|
||||
assert parsed == {"date_refunded": {"value": "2025-06-30", "modifier": "LESS_THAN"}}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_date_filter_e2e")
|
||||
def test_empty_inputs_omit_date_criterion(live_server, page):
|
||||
"""No date typed → the filter JSON simply has no date_purchased /
|
||||
date_refunded keys (vs. an empty-string crash)."""
|
||||
page.goto(live_server.url + "/test-date-filter/")
|
||||
with page.expect_navigation():
|
||||
page.evaluate(
|
||||
"document.getElementById('filter-bar-form')"
|
||||
".dispatchEvent(new Event('submit', {cancelable: true}))"
|
||||
)
|
||||
parsed = _filter_from_url(page.url)
|
||||
assert "date_purchased" not in parsed
|
||||
assert "date_refunded" not in parsed
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_date_filter_e2e")
|
||||
def test_prefilled_bar_reflects_existing_filter_in_inputs(live_server, page):
|
||||
"""A bar rendered with a BETWEEN filter_json pre-fills the inputs and
|
||||
re-submits the same bounds unchanged."""
|
||||
page.goto(live_server.url + "/test-date-filter-prefilled/")
|
||||
assert (
|
||||
page.locator('input[name="filter-date-refunded-min"]').input_value()
|
||||
== "2024-03-15"
|
||||
)
|
||||
assert (
|
||||
page.locator('input[name="filter-date-refunded-max"]').input_value()
|
||||
== "2024-09-20"
|
||||
)
|
||||
with page.expect_navigation():
|
||||
page.evaluate(
|
||||
"document.getElementById('filter-bar-form')"
|
||||
".dispatchEvent(new Event('submit', {cancelable: true}))"
|
||||
)
|
||||
parsed = _filter_from_url(page.url)
|
||||
assert parsed["date_refunded"] == {
|
||||
"value": "2024-03-15",
|
||||
"value2": "2024-09-20",
|
||||
"modifier": "BETWEEN",
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
"""End-to-end Playwright tests for the DateRangePicker component.
|
||||
|
||||
Exercises the behaviour layers the rendering tests cannot reach
|
||||
(``date_range_picker.js``): segmented digit entry with right-to-left
|
||||
placeholder fill and auto-advance, Backspace reverting a part, the calendar
|
||||
popup's anchor-style range picking, presets, the Cancel / Clear / Select
|
||||
footer, and the ``filter_bar.js`` serialization of the hidden ISO inputs
|
||||
into a ``DateCriterion``.
|
||||
|
||||
Like the other filter-bar e2e modules, the bar is served from its own
|
||||
minimal URLconf (no auth, no CSS) — the JS only cares about the DOM.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import urllib.parse
|
||||
|
||||
import pytest
|
||||
from django.http import HttpResponse
|
||||
from django.test import override_settings
|
||||
|
||||
from common.components import PurchaseFilterBar
|
||||
from django.urls import path
|
||||
|
||||
|
||||
def _bar_page(filter_json: str = "") -> str:
|
||||
return f"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Date range picker E2E</title>
|
||||
<script src="/static/js/htmx.min.js"></script>
|
||||
<script src="/static/js/range_slider.js" type="module"></script>
|
||||
<script src="/static/js/search_select.js" type="module"></script>
|
||||
<script src="/static/js/date_range_picker.js" defer></script>
|
||||
<script src="/static/js/filter_bar.js" type="module"></script>
|
||||
</head>
|
||||
<body>
|
||||
{PurchaseFilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
def empty_bar_view(request):
|
||||
return HttpResponse(_bar_page())
|
||||
|
||||
|
||||
def prefilled_bar_view(request):
|
||||
filter_json = json.dumps(
|
||||
{
|
||||
"date_purchased": {
|
||||
"value": "2024-03-15",
|
||||
"value2": "2024-09-20",
|
||||
"modifier": "BETWEEN",
|
||||
}
|
||||
}
|
||||
)
|
||||
return HttpResponse(_bar_page(filter_json))
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("test-date-range-picker/", empty_bar_view),
|
||||
path("test-date-range-picker-prefilled/", prefilled_bar_view),
|
||||
]
|
||||
|
||||
|
||||
PICKER = '[data-date-range-picker][data-input-name-prefix="filter-date-purchased"]'
|
||||
POPUP = PICKER + " [data-date-range-calendar]"
|
||||
HIDDEN_MIN = 'input[name="filter-date-purchased-min"]'
|
||||
HIDDEN_MAX = 'input[name="filter-date-purchased-max"]'
|
||||
|
||||
|
||||
def _segment(page, side: str, part: str):
|
||||
return page.locator(
|
||||
f'{PICKER} input[data-date-side="{side}"][data-date-part="{part}"]'
|
||||
)
|
||||
|
||||
|
||||
def _day_cell(page, iso_date: str):
|
||||
return page.locator(
|
||||
f'{PICKER} [data-date-range-grid] button[data-date="{iso_date}"]'
|
||||
)
|
||||
|
||||
|
||||
def _popup_is_open(page) -> bool:
|
||||
return "hidden" not in (page.locator(POPUP).get_attribute("class") or "")
|
||||
|
||||
|
||||
def _submit_filter_bar(page):
|
||||
with page.expect_navigation():
|
||||
page.evaluate(
|
||||
"document.getElementById('filter-bar-form')"
|
||||
".dispatchEvent(new Event('submit', {cancelable: true}))"
|
||||
)
|
||||
|
||||
|
||||
def _filter_from_url(url: str) -> dict:
|
||||
query = urllib.parse.urlparse(url).query
|
||||
params = urllib.parse.parse_qs(query)
|
||||
raw = params.get("filter", [""])[0]
|
||||
return json.loads(raw) if raw else {}
|
||||
|
||||
|
||||
# ── Segmented manual entry ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
|
||||
def test_typing_fills_parts_and_serializes_between(live_server, page):
|
||||
"""Digits flow through the parts (DD → MM → YYYY → DD …) with
|
||||
auto-advance, ending in a BETWEEN criterion on submit."""
|
||||
page.goto(live_server.url + "/test-date-range-picker/")
|
||||
_segment(page, "min", "day").click()
|
||||
page.keyboard.type("1503202420092024")
|
||||
assert page.locator(HIDDEN_MIN).input_value() == "2024-03-15"
|
||||
assert page.locator(HIDDEN_MAX).input_value() == "2024-09-20"
|
||||
_submit_filter_bar(page)
|
||||
parsed = _filter_from_url(page.url)
|
||||
assert parsed == {
|
||||
"date_purchased": {
|
||||
"value": "2024-03-15",
|
||||
"value2": "2024-09-20",
|
||||
"modifier": "BETWEEN",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
|
||||
def test_placeholder_fills_from_the_right(live_server, page):
|
||||
"""Typing 19 into the YYYY part shows YYY1 then YY19."""
|
||||
page.goto(live_server.url + "/test-date-range-picker/")
|
||||
year_segment = _segment(page, "min", "year")
|
||||
year_segment.click()
|
||||
page.keyboard.press("1")
|
||||
assert year_segment.input_value() == "YYY1"
|
||||
page.keyboard.press("9")
|
||||
assert year_segment.input_value() == "YY19"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
|
||||
def test_min_side_only_serializes_greater_than(live_server, page):
|
||||
page.goto(live_server.url + "/test-date-range-picker/")
|
||||
_segment(page, "min", "day").click()
|
||||
page.keyboard.type("15062024")
|
||||
_submit_filter_bar(page)
|
||||
parsed = _filter_from_url(page.url)
|
||||
assert parsed == {
|
||||
"date_purchased": {"value": "2024-06-15", "modifier": "GREATER_THAN"}
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
|
||||
def test_backspace_reverts_part_to_placeholder(live_server, page):
|
||||
page.goto(live_server.url + "/test-date-range-picker/")
|
||||
_segment(page, "min", "day").click()
|
||||
page.keyboard.type("15032024")
|
||||
assert page.locator(HIDDEN_MIN).input_value() == "2024-03-15"
|
||||
month_segment = _segment(page, "min", "month")
|
||||
month_segment.click()
|
||||
page.keyboard.press("Backspace")
|
||||
assert month_segment.input_value() == ""
|
||||
# An incomplete date no longer commits to the hidden input.
|
||||
assert page.locator(HIDDEN_MIN).input_value() == ""
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
|
||||
def test_only_numbers_can_be_typed(live_server, page):
|
||||
page.goto(live_server.url + "/test-date-range-picker/")
|
||||
day_segment = _segment(page, "min", "day")
|
||||
day_segment.click()
|
||||
page.keyboard.type("ab-/")
|
||||
assert day_segment.input_value() == ""
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
|
||||
def test_invalid_calendar_date_does_not_commit(live_server, page):
|
||||
"""31-02-2024 fills all parts but is not a real date — no hidden value."""
|
||||
page.goto(live_server.url + "/test-date-range-picker/")
|
||||
_segment(page, "min", "day").click()
|
||||
page.keyboard.type("31022024")
|
||||
assert page.locator(HIDDEN_MIN).input_value() == ""
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
|
||||
def test_clicking_container_activates_first_part(live_server, page):
|
||||
page.goto(live_server.url + "/test-date-range-picker/")
|
||||
page.locator(PICKER + " [data-date-range-field]").click(position={"x": 5, "y": 5})
|
||||
focused = page.evaluate(
|
||||
"document.activeElement.getAttribute('data-date-part') + ':' +"
|
||||
"document.activeElement.getAttribute('data-date-side')"
|
||||
)
|
||||
assert focused == "day:min"
|
||||
|
||||
|
||||
# ── Calendar popup ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _open_calendar(page):
|
||||
page.locator(PICKER + " [data-date-range-calendar-toggle]").click()
|
||||
|
||||
|
||||
def _current_month_iso(day_of_month: int) -> str:
|
||||
today = datetime.date.today()
|
||||
return today.replace(day=day_of_month).isoformat()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
|
||||
def test_calendar_pick_range_then_select(live_server, page):
|
||||
page.goto(live_server.url + "/test-date-range-picker/")
|
||||
_open_calendar(page)
|
||||
assert _popup_is_open(page)
|
||||
first_pick = _current_month_iso(10)
|
||||
second_pick = _current_month_iso(20)
|
||||
_day_cell(page, first_pick).click()
|
||||
assert page.locator(HIDDEN_MIN).input_value() == first_pick
|
||||
assert page.locator(HIDDEN_MAX).input_value() == ""
|
||||
_day_cell(page, second_pick).click()
|
||||
assert page.locator(HIDDEN_MAX).input_value() == second_pick
|
||||
page.locator(PICKER + " [data-date-range-select]").click()
|
||||
assert not _popup_is_open(page)
|
||||
_submit_filter_bar(page)
|
||||
parsed = _filter_from_url(page.url)
|
||||
assert parsed == {
|
||||
"date_purchased": {
|
||||
"value": first_pick,
|
||||
"value2": second_pick,
|
||||
"modifier": "BETWEEN",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
|
||||
def test_picking_before_start_restarts_the_range(live_server, page):
|
||||
"""With the StartDate anchored, picking an earlier date clears the range
|
||||
and the clicked date becomes the new StartDate."""
|
||||
page.goto(live_server.url + "/test-date-range-picker/")
|
||||
_open_calendar(page)
|
||||
_day_cell(page, _current_month_iso(20)).click()
|
||||
_day_cell(page, _current_month_iso(10)).click()
|
||||
assert page.locator(HIDDEN_MIN).input_value() == _current_month_iso(10)
|
||||
assert page.locator(HIDDEN_MAX).input_value() == ""
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
|
||||
def test_completed_range_anchor_moves_to_end(live_server, page):
|
||||
"""After both dates are picked the EndDate becomes the anchor, so a
|
||||
further pick inside the range moves the StartDate."""
|
||||
page.goto(live_server.url + "/test-date-range-picker/")
|
||||
_open_calendar(page)
|
||||
_day_cell(page, _current_month_iso(10)).click()
|
||||
_day_cell(page, _current_month_iso(20)).click()
|
||||
_day_cell(page, _current_month_iso(15)).click()
|
||||
assert page.locator(HIDDEN_MIN).input_value() == _current_month_iso(15)
|
||||
assert page.locator(HIDDEN_MAX).input_value() == _current_month_iso(20)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
|
||||
def test_preset_fills_both_dates(live_server, page):
|
||||
page.goto(live_server.url + "/test-date-range-picker/")
|
||||
_open_calendar(page)
|
||||
page.locator(PICKER + ' [data-date-range-preset="last_7_days"]').click()
|
||||
today = datetime.date.today()
|
||||
assert (
|
||||
page.locator(HIDDEN_MIN).input_value()
|
||||
== (today - datetime.timedelta(days=6)).isoformat()
|
||||
)
|
||||
assert page.locator(HIDDEN_MAX).input_value() == today.isoformat()
|
||||
# Presets keep the popup open; Select commits and closes.
|
||||
assert _popup_is_open(page)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
|
||||
def test_clear_clears_dates_but_keeps_popup_open(live_server, page):
|
||||
page.goto(live_server.url + "/test-date-range-picker/")
|
||||
_open_calendar(page)
|
||||
_day_cell(page, _current_month_iso(10)).click()
|
||||
_day_cell(page, _current_month_iso(20)).click()
|
||||
page.locator(PICKER + " [data-date-range-clear]").click()
|
||||
assert page.locator(HIDDEN_MIN).input_value() == ""
|
||||
assert page.locator(HIDDEN_MAX).input_value() == ""
|
||||
assert _popup_is_open(page)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
|
||||
def test_cancel_clears_dates_and_closes_popup(live_server, page):
|
||||
page.goto(live_server.url + "/test-date-range-picker/")
|
||||
_open_calendar(page)
|
||||
_day_cell(page, _current_month_iso(10)).click()
|
||||
_day_cell(page, _current_month_iso(20)).click()
|
||||
page.locator(PICKER + " [data-date-range-cancel]").click()
|
||||
assert page.locator(HIDDEN_MIN).input_value() == ""
|
||||
assert page.locator(HIDDEN_MAX).input_value() == ""
|
||||
assert not _popup_is_open(page)
|
||||
|
||||
|
||||
# ── Prefill round-trip ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
|
||||
def test_prefilled_picker_round_trips_unchanged(live_server, page):
|
||||
page.goto(live_server.url + "/test-date-range-picker-prefilled/")
|
||||
assert _segment(page, "min", "day").input_value() == "15"
|
||||
assert _segment(page, "min", "month").input_value() == "03"
|
||||
assert _segment(page, "min", "year").input_value() == "2024"
|
||||
assert _segment(page, "max", "day").input_value() == "20"
|
||||
assert page.locator(HIDDEN_MIN).input_value() == "2024-03-15"
|
||||
assert page.locator(HIDDEN_MAX).input_value() == "2024-09-20"
|
||||
_submit_filter_bar(page)
|
||||
parsed = _filter_from_url(page.url)
|
||||
assert parsed["date_purchased"] == {
|
||||
"value": "2024-03-15",
|
||||
"value2": "2024-09-20",
|
||||
"modifier": "BETWEEN",
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
"""End-to-end Playwright test for the RangeSlider JS synchronization, cross-over, and clamping behavior."""
|
||||
|
||||
import pytest
|
||||
from django.http import HttpResponse
|
||||
from django.test import override_settings
|
||||
from django.urls import path
|
||||
|
||||
from common.components import FilterBar
|
||||
|
||||
|
||||
def _bar_page(filter_json: str = "") -> str:
|
||||
return f"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Range Slider E2E</title>
|
||||
<script src="/static/js/htmx.min.js"></script>
|
||||
<script src="/static/js/range_slider.js" type="module"></script>
|
||||
<script src="/static/js/search_select.js" type="module"></script>
|
||||
<script src="/static/js/filter_bar.js" type="module"></script>
|
||||
</head>
|
||||
<body>
|
||||
{FilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
def empty_bar_view(request):
|
||||
return HttpResponse(_bar_page())
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("test-range-slider/", empty_bar_view),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_range_slider_e2e")
|
||||
def test_range_slider_crossover_min_higher_than_max(live_server, page):
|
||||
page.goto(live_server.url + "/test-range-slider/")
|
||||
|
||||
# 1. Start with known state: Min is empty, Max is empty
|
||||
min_input = page.locator('input[name="filter-session-count-min"]')
|
||||
max_input = page.locator('input[name="filter-session-count-max"]')
|
||||
|
||||
# 2. Type "20" into max input
|
||||
max_input.fill("20")
|
||||
|
||||
# 3. Type "50" into min input (which is higher than 20)
|
||||
min_input.fill("50")
|
||||
|
||||
# 4. Max input should have automatically synchronized/snapped to 50
|
||||
assert max_input.input_value() == "50"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_range_slider_e2e")
|
||||
def test_range_slider_crossover_max_less_than_min(live_server, page):
|
||||
page.goto(live_server.url + "/test-range-slider/")
|
||||
|
||||
min_input = page.locator('input[name="filter-session-count-min"]')
|
||||
max_input = page.locator('input[name="filter-session-count-max"]')
|
||||
|
||||
# 1. Type "50" into min input
|
||||
min_input.fill("50")
|
||||
|
||||
# 2. Type "30" into max input (which is less than 50)
|
||||
max_input.fill("30")
|
||||
|
||||
# 3. Min input should have automatically synchronized/snapped to 30
|
||||
assert min_input.input_value() == "30"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_range_slider_e2e")
|
||||
def test_range_slider_strict_bounds_clamping_on_blur(live_server, page):
|
||||
page.goto(live_server.url + "/test-range-slider/")
|
||||
|
||||
min_input = page.locator('input[name="filter-session-count-min"]')
|
||||
max_input = page.locator('input[name="filter-session-count-max"]')
|
||||
|
||||
# 1. Type value higher than dataMax (100 is max, type "150")
|
||||
max_input.fill("150")
|
||||
max_input.blur() # triggers "change" event
|
||||
|
||||
assert max_input.input_value() == "100"
|
||||
|
||||
# 2. Type value lower than dataMin (0 is min, type "-20")
|
||||
min_input.fill("-20")
|
||||
min_input.blur() # triggers "change" event
|
||||
|
||||
assert min_input.input_value() == "0"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_range_slider_e2e")
|
||||
def test_range_slider_empty_max_thumb_does_not_jump_to_beginning(live_server, page):
|
||||
page.goto(live_server.url + "/test-range-slider/")
|
||||
|
||||
# Locate handles
|
||||
max_handle = page.locator(
|
||||
'.range-handle-max[data-target="filter-session-count-max"]'
|
||||
)
|
||||
|
||||
# Initially, max_input is empty, so handle should sit at 100% (far right)
|
||||
style = max_handle.get_attribute("style")
|
||||
assert "left:100%" in style or "left: 100%" in style
|
||||
|
||||
# Set min to 50
|
||||
min_input = page.locator('input[name="filter-session-count-min"]')
|
||||
min_input.fill("50")
|
||||
|
||||
# Max handle should STILL stay at 100% since max input is still empty (defaults to max_value)
|
||||
style = max_handle.get_attribute("style")
|
||||
assert "left:100%" in style or "left: 100%" in style
|
||||
@@ -0,0 +1,109 @@
|
||||
import pytest
|
||||
from django.urls import path
|
||||
from django.http import HttpResponse
|
||||
from django.test import override_settings
|
||||
from common.components import SearchSelect
|
||||
|
||||
|
||||
def e2e_test_view(request):
|
||||
html = f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>SearchSelect E2E Test</title>
|
||||
<!-- search_select.js is an ES module and initializes via onSwap(),
|
||||
which rides on htmx.onLoad — so htmx must be present. -->
|
||||
<script src="/static/js/htmx.min.js"></script>
|
||||
<script type="module" src="/static/js/search_select.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div style="padding: 50px;">
|
||||
{
|
||||
SearchSelect(
|
||||
name="games",
|
||||
selected=[{"value": "7", "label": "Game A", "data": {}}],
|
||||
options=[
|
||||
{"value": "7", "label": "Game A", "data": {}},
|
||||
{"value": "8", "label": "Game B", "data": {}},
|
||||
],
|
||||
multi_select=False,
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
return HttpResponse(html)
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("test-search-select/", e2e_test_view),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_search_select_e2e")
|
||||
def test_search_select_backspace_clears_single_select(live_server, page):
|
||||
# Enable console log forwarding
|
||||
page.on("console", lambda msg: print(f"BROWSER CONSOLE: {msg.text}"))
|
||||
|
||||
page.goto(live_server.url + "/test-search-select/")
|
||||
|
||||
# Inject our event logger
|
||||
page.evaluate("""() => {
|
||||
const s = document.querySelector('input[data-search-select-search]');
|
||||
const c = document.querySelector('[data-search-select]');
|
||||
s.addEventListener('focus', () => console.log('JS-EVENT: focus, dirty=' + c._searchSelectDirty + ', value="' + s.value + '"'));
|
||||
s.addEventListener('blur', () => console.log('JS-EVENT: blur, dirty=' + c._searchSelectDirty + ', value="' + s.value + '"'));
|
||||
s.addEventListener('input', () => console.log('JS-EVENT: input, dirty=' + c._searchSelectDirty + ', value="' + s.value + '"'));
|
||||
s.addEventListener('keydown', (e) => console.log('JS-EVENT: keydown ' + e.key + ', dirty=' + c._searchSelectDirty + ', value="' + s.value + '"'));
|
||||
}""")
|
||||
|
||||
search_input = page.locator("input[data-search-select-search]")
|
||||
|
||||
assert search_input.input_value() == "Game A"
|
||||
|
||||
hidden_input = page.locator('input[name="games"]')
|
||||
assert hidden_input.first.get_attribute("value") == "7"
|
||||
|
||||
# Focus the input
|
||||
print("\n--- FOCUSING INPUT ---")
|
||||
search_input.focus()
|
||||
assert search_input.input_value() == ""
|
||||
|
||||
# Press Backspace using the raw keyboard API to avoid any high-level Playwright input simulation
|
||||
print("\n--- PRESSING BACKSPACE ---")
|
||||
page.keyboard.press("Backspace")
|
||||
|
||||
# Explicitly blur the input
|
||||
print("\n--- BLURRING INPUT ---")
|
||||
search_input.blur()
|
||||
|
||||
# Wait for blur microtasks/setTimeout to settle (120ms timeout in JS)
|
||||
page.wait_for_timeout(200)
|
||||
|
||||
# After Backspace and blur, the input should remain empty (the selection is cleared)
|
||||
assert search_input.input_value() == ""
|
||||
assert hidden_input.count() == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_search_select_e2e")
|
||||
def test_search_select_typing_replaces_single_select(live_server, page):
|
||||
page.goto(live_server.url + "/test-search-select/")
|
||||
|
||||
search_input = page.locator("input[data-search-select-search]")
|
||||
|
||||
search_input.focus()
|
||||
assert search_input.input_value() == ""
|
||||
|
||||
search_input.type("X")
|
||||
assert search_input.input_value() == "X"
|
||||
|
||||
search_input.blur()
|
||||
page.wait_for_timeout(200)
|
||||
|
||||
assert search_input.input_value() == "Game A"
|
||||
|
||||
hidden_input = page.locator('input[name="games"]')
|
||||
assert hidden_input.first.get_attribute("value") == "7"
|
||||
@@ -0,0 +1,150 @@
|
||||
"""End-to-end Playwright test for String multi-mode filter serialization, null-state toggling, and prefill behaviors."""
|
||||
|
||||
import json
|
||||
import urllib.parse
|
||||
|
||||
import pytest
|
||||
from django.http import HttpResponse
|
||||
from django.test import override_settings
|
||||
from django.urls import path
|
||||
|
||||
from common.components import PlatformFilterBar
|
||||
|
||||
|
||||
def _bar_page(filter_json: str = "") -> str:
|
||||
return f"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>String filter E2E</title>
|
||||
<script src="/static/js/htmx.min.js"></script>
|
||||
<script src="/static/js/range_slider.js" type="module"></script>
|
||||
<script src="/static/js/search_select.js" type="module"></script>
|
||||
<script src="/static/js/filter_bar.js" type="module"></script>
|
||||
</head>
|
||||
<body>
|
||||
{PlatformFilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
def empty_bar_view(request):
|
||||
return HttpResponse(_bar_page())
|
||||
|
||||
|
||||
def prefilled_bar_view(request):
|
||||
filter_json = json.dumps(
|
||||
{
|
||||
"name": {
|
||||
"value": "Switch",
|
||||
"modifier": "INCLUDES",
|
||||
},
|
||||
"group": {"modifier": "IS_NULL"},
|
||||
}
|
||||
)
|
||||
return HttpResponse(_bar_page(filter_json=filter_json))
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("test-string-filter-empty/", empty_bar_view),
|
||||
path("test-string-filter-prefilled/", prefilled_bar_view),
|
||||
]
|
||||
|
||||
|
||||
def _filter_from_url(url: str) -> dict:
|
||||
query = urllib.parse.urlparse(url).query
|
||||
params = urllib.parse.parse_qs(query)
|
||||
raw = params.get("filter", [""])[0]
|
||||
return json.loads(raw) if raw else {}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_string_filter_e2e")
|
||||
def test_string_filter_defaults_and_toggles(live_server, page):
|
||||
page.goto(live_server.url + "/test-string-filter-empty/")
|
||||
|
||||
# 1. Verify text inputs are active by default and modifier "is" (EQUALS) is checked
|
||||
name_input = page.locator('input[name="filter-name"]')
|
||||
assert name_input.is_enabled()
|
||||
|
||||
is_radio = page.locator('input[name="filter-name-modifier"][value="EQUALS"]')
|
||||
assert is_radio.is_checked()
|
||||
|
||||
# 2. Enter values, click "includes" (INCLUDES), and submit
|
||||
name_input.fill("PlayStation")
|
||||
includes_radio = page.locator(
|
||||
'input[name="filter-name-modifier"][value="INCLUDES"]'
|
||||
)
|
||||
includes_radio.click()
|
||||
|
||||
with page.expect_navigation():
|
||||
page.evaluate(
|
||||
"document.getElementById('filter-bar-form')"
|
||||
".dispatchEvent(new Event('submit', {cancelable: true}))"
|
||||
)
|
||||
parsed = _filter_from_url(page.url)
|
||||
assert parsed["name"] == {"value": "PlayStation", "modifier": "INCLUDES"}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_string_filter_e2e")
|
||||
def test_string_filter_null_states(live_server, page):
|
||||
page.goto(live_server.url + "/test-string-filter-empty/")
|
||||
|
||||
name_input = page.locator('input[name="filter-name"]')
|
||||
name_input.fill("Xbox")
|
||||
|
||||
# Click "is null"
|
||||
is_null_radio = page.locator('input[name="filter-name-modifier"][value="IS_NULL"]')
|
||||
is_null_radio.click()
|
||||
|
||||
# Verification of interactive disabling
|
||||
assert not name_input.is_enabled()
|
||||
assert name_input.input_value() == ""
|
||||
|
||||
with page.expect_navigation():
|
||||
page.evaluate(
|
||||
"document.getElementById('filter-bar-form')"
|
||||
".dispatchEvent(new Event('submit', {cancelable: true}))"
|
||||
)
|
||||
parsed = _filter_from_url(page.url)
|
||||
assert parsed["name"] == {"modifier": "IS_NULL"}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_string_filter_e2e")
|
||||
def test_string_filter_prefilled_states(live_server, page):
|
||||
page.goto(live_server.url + "/test-string-filter-prefilled/")
|
||||
|
||||
name_input = page.locator('input[name="filter-name"]')
|
||||
group_input = page.locator('input[name="filter-group"]')
|
||||
|
||||
# Verifies name matches "Switch" and "includes" is checked
|
||||
assert name_input.input_value() == "Switch"
|
||||
assert name_input.is_enabled()
|
||||
assert page.locator(
|
||||
'input[name="filter-name-modifier"][value="INCLUDES"]'
|
||||
).is_checked()
|
||||
|
||||
# Verifies group is empty, disabled, and "is null" is checked
|
||||
assert group_input.input_value() == ""
|
||||
assert not group_input.is_enabled()
|
||||
assert page.locator(
|
||||
'input[name="filter-group-modifier"][value="IS_NULL"]'
|
||||
).is_checked()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_string_filter_e2e")
|
||||
def test_string_filter_deselect_re_enables(live_server, page):
|
||||
page.goto(live_server.url + "/test-string-filter-empty/")
|
||||
|
||||
name_input = page.locator('input[name="filter-name"]')
|
||||
is_null_radio = page.locator('input[name="filter-name-modifier"][value="IS_NULL"]')
|
||||
|
||||
# 1. Click "is null" -> disables input
|
||||
is_null_radio.click()
|
||||
assert not name_input.is_enabled()
|
||||
|
||||
# 2. Click "is null" again to deselect/uncheck -> should re-enable the text input
|
||||
is_null_radio.click()
|
||||
assert name_input.is_enabled()
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Browser tests for widget JavaScript (search_select.js, range_slider.js,
|
||||
add_purchase.js) and their onSwap() initialization lifecycle.
|
||||
|
||||
These run a real Chromium via pytest-playwright against pytest-django's
|
||||
``live_server``. All JavaScript under test is served locally from
|
||||
``games/static/js/`` (htmx, Alpine, Flowbite and the widget files are
|
||||
vendored), so no network access is needed beyond the live server itself.
|
||||
|
||||
Browser binaries must be installed once: ``uv run playwright install chromium``.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
from playwright.sync_api import Page, expect
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def authenticated_page(live_server, page: Page, django_user_model) -> Page:
|
||||
django_user_model.objects.create_user(username="tester", password="secret123")
|
||||
page.goto(f"{live_server.url}{reverse('login')}")
|
||||
page.fill('input[name="username"]', "tester")
|
||||
page.fill('input[name="password"]', "secret123")
|
||||
page.click('input[type="submit"]')
|
||||
page.wait_for_url(f"{live_server.url}/tracker**")
|
||||
return page
|
||||
|
||||
|
||||
def open_filter_bar(page: Page) -> None:
|
||||
page.click("#filter-bar button:has-text('Filters')")
|
||||
expect(page.locator("#filter-bar-body")).to_be_visible()
|
||||
|
||||
|
||||
def status_filter_widget(page: Page):
|
||||
return page.locator('[data-search-select][data-name="status"]')
|
||||
|
||||
|
||||
def test_search_select_initializes_on_page_load(authenticated_page: Page, live_server):
|
||||
"""Clicking into a FilterSelect search box opens its options panel —
|
||||
proof that onSwap ran the widget initializer on the initial page load."""
|
||||
page = authenticated_page
|
||||
page.goto(f"{live_server.url}{reverse('games:list_games')}")
|
||||
open_filter_bar(page)
|
||||
|
||||
widget = status_filter_widget(page)
|
||||
widget.locator("[data-search-select-search]").click()
|
||||
|
||||
options_panel = widget.locator("[data-search-select-options]")
|
||||
expect(options_panel).to_be_visible()
|
||||
# The pinned "(Any)" modifier pseudo-option is rendered server-side and
|
||||
# only becomes interactable through the initialized panel.
|
||||
expect(
|
||||
options_panel.locator("[data-search-select-modifier-option]").first
|
||||
).to_have_text("(Any)")
|
||||
|
||||
|
||||
def test_search_select_adds_include_pill(authenticated_page: Page, live_server):
|
||||
"""Clicking an enum option row adds an include pill (full widget wiring)."""
|
||||
page = authenticated_page
|
||||
page.goto(f"{live_server.url}{reverse('games:list_games')}")
|
||||
open_filter_bar(page)
|
||||
|
||||
widget = status_filter_widget(page)
|
||||
widget.locator("[data-search-select-search]").click()
|
||||
widget.locator('[data-search-select-option][data-label="Finished"]').click()
|
||||
|
||||
pill = widget.locator("[data-search-select-pills] [data-pill]")
|
||||
expect(pill).to_have_count(1)
|
||||
expect(pill).to_contain_text("Finished")
|
||||
|
||||
|
||||
def test_range_slider_mode_toggle_fires_exactly_once(
|
||||
authenticated_page: Page, live_server
|
||||
):
|
||||
"""One click on the mode toggle flips the slider from range to point mode
|
||||
exactly once. Double-bound listeners (the old force-re-init bug) would
|
||||
flip it twice, leaving data-mode unchanged."""
|
||||
page = authenticated_page
|
||||
page.goto(f"{live_server.url}{reverse('games:list_games')}")
|
||||
open_filter_bar(page)
|
||||
|
||||
block = page.locator(".range-slider-block").first
|
||||
slider = block.locator(".range-slider")
|
||||
expect(slider).to_have_attribute("data-mode", "range")
|
||||
|
||||
block.locator(".range-mode-toggle").click()
|
||||
expect(slider).to_have_attribute("data-mode", "point")
|
||||
|
||||
|
||||
def test_widgets_initialize_inside_htmx_swapped_content(
|
||||
authenticated_page: Page, live_server
|
||||
):
|
||||
"""Widgets arriving via an htmx swap initialize without a page load.
|
||||
|
||||
The filter bar is re-fetched and swapped in with htmx.ajax — fresh,
|
||||
uninitialized DOM. The swapped-in FilterSelect must open its panel and the
|
||||
swapped-in slider must toggle exactly once, proving the htmx:load half of
|
||||
onSwap and the once-per-element guard."""
|
||||
page = authenticated_page
|
||||
page.goto(f"{live_server.url}{reverse('games:list_games')}")
|
||||
|
||||
page.evaluate(
|
||||
"htmx.ajax('GET', window.location.pathname, "
|
||||
"{target: '#filter-bar', select: '#filter-bar', swap: 'outerHTML'})"
|
||||
)
|
||||
# The swapped-in bar arrives collapsed again; opening it proves the swap
|
||||
# happened and the fresh DOM is in place.
|
||||
open_filter_bar(page)
|
||||
|
||||
widget = status_filter_widget(page)
|
||||
widget.locator("[data-search-select-search]").click()
|
||||
expect(widget.locator("[data-search-select-options]")).to_be_visible()
|
||||
|
||||
block = page.locator(".range-slider-block").first
|
||||
slider = block.locator(".range-slider")
|
||||
expect(slider).to_have_attribute("data-mode", "range")
|
||||
block.locator(".range-mode-toggle").click()
|
||||
expect(slider).to_have_attribute("data-mode", "point")
|
||||
|
||||
|
||||
def test_add_purchase_type_toggles_disabled_fields(
|
||||
authenticated_page: Page, live_server
|
||||
):
|
||||
"""add_purchase.js disables name/related-purchase while type is "game"
|
||||
and re-enables them for other types."""
|
||||
page = authenticated_page
|
||||
page.goto(f"{live_server.url}{reverse('games:add_purchase')}")
|
||||
|
||||
name_input = page.locator("#id_name")
|
||||
expect(name_input).to_be_disabled()
|
||||
|
||||
page.select_option("#id_type", "dlc")
|
||||
expect(name_input).to_be_enabled()
|
||||
|
||||
page.select_option("#id_type", "game")
|
||||
expect(name_input).to_be_disabled()
|
||||
@@ -20,4 +20,35 @@ chown "$PUID:$PGID" /var/log/supervisor
|
||||
python manage.py migrate
|
||||
python manage.py collectstatic --clear --no-input
|
||||
|
||||
# Staging seeded from a production snapshot: remove copied sessions and the
|
||||
# inherited django-q schedule/queue so staging neither shares prod's session
|
||||
# cookies nor independently runs scheduled tasks (see issue #20).
|
||||
if [ "${STAGING:-false}" = "true" ]; then
|
||||
python manage.py scrub_staging
|
||||
fi
|
||||
|
||||
# Public staging with a fresh database (e.g. Fly.io): load demo data instead
|
||||
# of any production snapshot. Runs once while the games table is empty.
|
||||
if [ "${LOAD_SAMPLE_DATA:-false}" = "true" ]; then
|
||||
python manage.py shell -c "
|
||||
from games.models import Game
|
||||
from django.core.management import call_command
|
||||
if not Game.objects.exists():
|
||||
call_command('loaddata', 'sample.yaml')
|
||||
print('Loaded sample data.')
|
||||
"
|
||||
fi
|
||||
|
||||
if [ "${CREATE_DEFAULT_SUPERUSER:-false}" = "true" ]; then
|
||||
python manage.py shell -c "
|
||||
from django.contrib.auth import get_user_model
|
||||
User = get_user_model()
|
||||
if not User.objects.filter(username='admin').exists():
|
||||
User.objects.create_superuser('admin', '', 'admin')
|
||||
print('Created default superuser: admin / admin')
|
||||
"
|
||||
fi
|
||||
|
||||
chown -R "$PUID:$PGID" /home/timetracker/app/data
|
||||
|
||||
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisor.conf
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# Shared Fly.io configuration for ephemeral, per-branch GitHub staging deploys.
|
||||
#
|
||||
# The app name is NOT set here on purpose; each branch supplies its own via
|
||||
# `flyctl deploy --app timetracker-staging-<slug>`. These instances run with a
|
||||
# fresh database seeded from sample fixtures (never production data) and their
|
||||
# own SECRET_KEY, so they are safe to expose on a public *.fly.dev hostname.
|
||||
|
||||
primary_region = "ams"
|
||||
|
||||
[build]
|
||||
dockerfile = "Dockerfile"
|
||||
|
||||
[env]
|
||||
PROD = "1"
|
||||
TZ = "Europe/Prague"
|
||||
DATA_DIR = "/home/timetracker/app/data"
|
||||
LOAD_SAMPLE_DATA = "true"
|
||||
CREATE_DEFAULT_SUPERUSER = "true"
|
||||
|
||||
[http_service]
|
||||
internal_port = 8000
|
||||
force_https = true
|
||||
auto_stop_machines = "stop"
|
||||
auto_start_machines = true
|
||||
min_machines_running = 0
|
||||
|
||||
[[vm]]
|
||||
size = "shared-cpu-1x"
|
||||
memory = "512mb"
|
||||
@@ -59,6 +59,12 @@ class GameOption(Schema): # mirrors SearchSelectOption
|
||||
data: dict
|
||||
|
||||
|
||||
class StringOption(Schema): # SearchSelectOption with a string value (e.g. group names)
|
||||
value: str
|
||||
label: str
|
||||
data: dict
|
||||
|
||||
|
||||
@game_router.get("/search", response=list[GameOption])
|
||||
def search_games(request, q: str = "", limit: int = 10):
|
||||
qs = Game.objects.select_related("platform").order_by("sort_name")
|
||||
@@ -133,6 +139,15 @@ def search_platforms(request, q: str = "", limit: int = 10):
|
||||
return [{"value": p.id, "label": p.name, "data": {}} for p in qs[:limit]]
|
||||
|
||||
|
||||
@platform_router.get("/groups", response=list[StringOption])
|
||||
def search_platform_groups(request, q: str = "", limit: int = 10):
|
||||
qs = Platform.objects.exclude(group="")
|
||||
if q:
|
||||
qs = qs.filter(group__icontains=q)
|
||||
groups = qs.values_list("group", flat=True).distinct().order_by("group")
|
||||
return [{"value": group, "label": group, "data": {}} for group in groups[:limit]]
|
||||
|
||||
|
||||
api.add_router("/playevent", playevent_router)
|
||||
api.add_router("/games", game_router)
|
||||
api.add_router("/devices", device_router)
|
||||
|
||||
+576
-54
@@ -18,6 +18,7 @@ from django.db.models import Q
|
||||
from common.criteria import (
|
||||
BoolCriterion,
|
||||
ChoiceCriterion,
|
||||
DateCriterion,
|
||||
FloatCriterion,
|
||||
IntCriterion,
|
||||
Modifier,
|
||||
@@ -58,15 +59,46 @@ class GameFilter(OperatorFilter):
|
||||
original_year_released: IntCriterion | None = None
|
||||
wikidata: StringCriterion | None = None
|
||||
platform: ChoiceCriterion | None = None # selectable filter widget
|
||||
platform_group: MultiCriterion | None = None # platform__group__in
|
||||
status: ChoiceCriterion | None = None # selectable filter widget
|
||||
mastered: BoolCriterion | None = None
|
||||
playtime_minutes: IntCriterion | None = None # converted to timedelta on to_q()
|
||||
playtime_hours: IntCriterion | None = None # converted to timedelta on to_q()
|
||||
created_at: StringCriterion | None = None # date string
|
||||
updated_at: StringCriterion | None = None # date string
|
||||
|
||||
session_count: IntCriterion | None = None
|
||||
session_average: IntCriterion | None = None # average in hours
|
||||
purchase_count: IntCriterion | None = None # distinct purchases per game
|
||||
playevent_count: IntCriterion | None = None # playevents per game
|
||||
|
||||
# Aggregate session durations (hours), summed across the game's sessions
|
||||
manual_playtime_hours: IntCriterion | None = None
|
||||
calculated_playtime_hours: IntCriterion | None = None
|
||||
|
||||
# Cross-entity: any session played on these devices / matching these flags
|
||||
device: MultiCriterion | None = None # game has session on any of these devices
|
||||
session_emulated: BoolCriterion | None = None # game has emulated session
|
||||
|
||||
# Cross-entity: matches against the game's purchases
|
||||
purchase_refunded: BoolCriterion | None = None # game has refunded purchase
|
||||
purchase_infinite: BoolCriterion | None = None # game has infinite purchase
|
||||
purchase_price_total: FloatCriterion | None = None # sum of converted prices
|
||||
purchase_price_any: FloatCriterion | None = None # any single purchase in range
|
||||
purchase_type: ChoiceCriterion | None = None # game has purchase of type
|
||||
purchase_ownership_type: ChoiceCriterion | None = None # by ownership
|
||||
|
||||
# Cross-entity: substring match against the game's playevent notes
|
||||
playevent_note: StringCriterion | None = None
|
||||
|
||||
# Free-text search (combines name + sort_name + platform name)
|
||||
search: StringCriterion | None = None
|
||||
|
||||
# Cross-entity filters
|
||||
session_filter: SessionFilter | None = None
|
||||
purchase_filter: PurchaseFilter | None = None
|
||||
playevent_filter: PlayEventFilter | None = None
|
||||
platform_filter: PlatformFilter | None = None
|
||||
|
||||
def to_q(self) -> Q:
|
||||
q = Q()
|
||||
|
||||
@@ -87,13 +119,183 @@ class GameFilter(OperatorFilter):
|
||||
q &= self.status.to_q("status")
|
||||
if self.mastered is not None:
|
||||
q &= self.mastered.to_q("mastered")
|
||||
if self.playtime_minutes is not None:
|
||||
q &= self._playtime_to_q(self.playtime_minutes)
|
||||
if self.playtime_hours is not None:
|
||||
q &= self._playtime_to_q(self.playtime_hours)
|
||||
if self.created_at is not None:
|
||||
q &= self.created_at.to_q("created_at")
|
||||
if self.updated_at is not None:
|
||||
q &= self.updated_at.to_q("updated_at")
|
||||
|
||||
if self.platform_group is not None:
|
||||
q &= self.platform_group.to_q("platform__group")
|
||||
|
||||
if self.session_count is not None:
|
||||
from django.db.models import Count
|
||||
|
||||
from games.models import Game
|
||||
|
||||
matching_ids = (
|
||||
Game.objects.annotate(s_count=Count("sessions", distinct=True))
|
||||
.filter(self.session_count.to_q("s_count"))
|
||||
.values_list("id", flat=True)
|
||||
)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
if self.session_average is not None:
|
||||
from django.db.models import Avg
|
||||
|
||||
from games.models import Game
|
||||
|
||||
matching_ids = (
|
||||
Game.objects.annotate(s_avg=Avg("sessions__duration_total"))
|
||||
.filter(self._playtime_to_q_for_field(self.session_average, "s_avg"))
|
||||
.values_list("id", flat=True)
|
||||
)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
if self.purchase_count is not None:
|
||||
from django.db.models import Count
|
||||
|
||||
from games.models import Game
|
||||
|
||||
matching_ids = (
|
||||
Game.objects.annotate(p_count=Count("purchases", distinct=True))
|
||||
.filter(self.purchase_count.to_q("p_count"))
|
||||
.values_list("id", flat=True)
|
||||
)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
if self.playevent_count is not None:
|
||||
from django.db.models import Count
|
||||
|
||||
from games.models import Game
|
||||
|
||||
matching_ids = (
|
||||
Game.objects.annotate(pe_count=Count("playevents", distinct=True))
|
||||
.filter(self.playevent_count.to_q("pe_count"))
|
||||
.values_list("id", flat=True)
|
||||
)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
if self.manual_playtime_hours is not None:
|
||||
from django.db.models import Sum
|
||||
|
||||
from games.models import Game
|
||||
|
||||
matching_ids = (
|
||||
Game.objects.annotate(s_manual=Sum("sessions__duration_manual"))
|
||||
.filter(
|
||||
self._playtime_to_q_for_field(
|
||||
self.manual_playtime_hours, "s_manual"
|
||||
)
|
||||
)
|
||||
.values_list("id", flat=True)
|
||||
)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
if self.calculated_playtime_hours is not None:
|
||||
from django.db.models import Sum
|
||||
|
||||
from games.models import Game
|
||||
|
||||
matching_ids = (
|
||||
Game.objects.annotate(s_calc=Sum("sessions__duration_calculated"))
|
||||
.filter(
|
||||
self._playtime_to_q_for_field(
|
||||
self.calculated_playtime_hours, "s_calc"
|
||||
)
|
||||
)
|
||||
.values_list("id", flat=True)
|
||||
)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
if self.device is not None:
|
||||
from games.models import Session
|
||||
|
||||
session_q = self.device.to_q("device_id")
|
||||
matching_ids = Session.objects.filter(session_q).values_list(
|
||||
"game_id", flat=True
|
||||
)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
if self.session_emulated is not None:
|
||||
from games.models import Session
|
||||
|
||||
emulated_ids = Session.objects.filter(
|
||||
emulated=self.session_emulated.value
|
||||
).values_list("game_id", flat=True)
|
||||
if self.session_emulated.value:
|
||||
q &= Q(id__in=emulated_ids)
|
||||
else:
|
||||
emulated_true_ids = Session.objects.filter(emulated=True).values_list(
|
||||
"game_id", flat=True
|
||||
)
|
||||
q &= ~Q(id__in=emulated_true_ids)
|
||||
|
||||
if self.purchase_refunded is not None:
|
||||
from games.models import Purchase
|
||||
|
||||
refunded_ids = Purchase.objects.filter(
|
||||
date_refunded__isnull=False
|
||||
).values_list("games__id", flat=True)
|
||||
if self.purchase_refunded.value:
|
||||
q &= Q(id__in=refunded_ids)
|
||||
else:
|
||||
q &= ~Q(id__in=refunded_ids)
|
||||
|
||||
if self.purchase_infinite is not None:
|
||||
from games.models import Purchase
|
||||
|
||||
infinite_ids = Purchase.objects.filter(infinite=True).values_list(
|
||||
"games__id", flat=True
|
||||
)
|
||||
if self.purchase_infinite.value:
|
||||
q &= Q(id__in=infinite_ids)
|
||||
else:
|
||||
q &= ~Q(id__in=infinite_ids)
|
||||
|
||||
if self.purchase_price_total is not None:
|
||||
from django.db.models import Sum
|
||||
|
||||
from games.models import Game
|
||||
|
||||
matching_ids = (
|
||||
Game.objects.annotate(p_total=Sum("purchases__converted_price"))
|
||||
.filter(self.purchase_price_total.to_q("p_total"))
|
||||
.values_list("id", flat=True)
|
||||
)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
if self.purchase_price_any is not None:
|
||||
from games.models import Purchase
|
||||
|
||||
price_q = self.purchase_price_any.to_q("converted_price")
|
||||
matching_ids = Purchase.objects.filter(price_q).values_list(
|
||||
"games__id", flat=True
|
||||
)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
if self.purchase_type is not None:
|
||||
from games.models import Purchase
|
||||
|
||||
type_q = self.purchase_type.to_q("type")
|
||||
matching_ids = Purchase.objects.filter(type_q).values_list(
|
||||
"games__id", flat=True
|
||||
)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
if self.purchase_ownership_type is not None:
|
||||
from games.models import Purchase
|
||||
|
||||
ownership_q = self.purchase_ownership_type.to_q("ownership_type")
|
||||
matching_ids = Purchase.objects.filter(ownership_q).values_list(
|
||||
"games__id", flat=True
|
||||
)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
if self.playevent_note is not None:
|
||||
q &= self._playevent_note_to_q(self.playevent_note)
|
||||
|
||||
# ── free-text search (OR across multiple fields) ──
|
||||
if self.search is not None and self.search.value:
|
||||
search_q = (
|
||||
@@ -105,6 +307,43 @@ class GameFilter(OperatorFilter):
|
||||
search_q = ~search_q
|
||||
q &= search_q
|
||||
|
||||
# Cross-entity filters
|
||||
if self.session_filter is not None:
|
||||
from games.models import Session
|
||||
|
||||
session_q = self.session_filter.to_q()
|
||||
matching_ids = Session.objects.filter(session_q).values_list(
|
||||
"game_id", flat=True
|
||||
)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
if self.purchase_filter is not None:
|
||||
from games.models import Purchase
|
||||
|
||||
purchase_q = self.purchase_filter.to_q()
|
||||
matching_ids = Purchase.objects.filter(purchase_q).values_list(
|
||||
"games__id", flat=True
|
||||
)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
if self.playevent_filter is not None:
|
||||
from games.models import PlayEvent
|
||||
|
||||
playevent_q = self.playevent_filter.to_q()
|
||||
matching_ids = PlayEvent.objects.filter(playevent_q).values_list(
|
||||
"game_id", flat=True
|
||||
)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
if self.platform_filter is not None:
|
||||
from games.models import Platform
|
||||
|
||||
platform_q = self.platform_filter.to_q()
|
||||
matching_ids = Platform.objects.filter(platform_q).values_list(
|
||||
"id", flat=True
|
||||
)
|
||||
q &= Q(platform_id__in=matching_ids)
|
||||
|
||||
# ── AND / OR / NOT sub-filters ──
|
||||
sub = self.sub_filter()
|
||||
if sub is not None:
|
||||
@@ -119,31 +358,34 @@ class GameFilter(OperatorFilter):
|
||||
|
||||
@staticmethod
|
||||
def _playtime_to_q(c: IntCriterion) -> Q:
|
||||
"""Convert minutes-based criterion to a DurationField Q object.
|
||||
return GameFilter._playtime_to_q_for_field(c, "playtime")
|
||||
|
||||
@staticmethod
|
||||
def _playtime_to_q_for_field(c: IntCriterion, field: str) -> Q:
|
||||
"""Convert hours-based criterion to a DurationField Q object.
|
||||
|
||||
Django stores DurationField as microseconds in SQLite, so we convert
|
||||
minutes → timedelta(microseconds=X) and use the appropriate lookups.
|
||||
hours → timedelta(microseconds=X) and use the appropriate lookups.
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
from common.criteria import Modifier
|
||||
|
||||
m = c.modifier
|
||||
field = "playtime"
|
||||
td_val = timedelta(minutes=c.value)
|
||||
td_val = timedelta(hours=c.value)
|
||||
|
||||
if m == Modifier.EQUALS:
|
||||
return Q(
|
||||
**{
|
||||
f"{field}__gte": td_val,
|
||||
f"{field}__lt": timedelta(minutes=c.value + 1),
|
||||
f"{field}__lt": timedelta(hours=c.value + 1),
|
||||
}
|
||||
)
|
||||
if m == Modifier.NOT_EQUALS:
|
||||
return ~Q(
|
||||
**{
|
||||
f"{field}__gte": td_val,
|
||||
f"{field}__lt": timedelta(minutes=c.value + 1),
|
||||
f"{field}__lt": timedelta(hours=c.value + 1),
|
||||
}
|
||||
)
|
||||
if m == Modifier.GREATER_THAN:
|
||||
@@ -151,12 +393,12 @@ class GameFilter(OperatorFilter):
|
||||
if m == Modifier.LESS_THAN:
|
||||
return Q(**{f"{field}__lt": td_val})
|
||||
if m == Modifier.BETWEEN and c.value2 is not None:
|
||||
lo = timedelta(minutes=min(c.value, c.value2))
|
||||
hi = timedelta(minutes=max(c.value, c.value2))
|
||||
lo = timedelta(hours=min(c.value, c.value2))
|
||||
hi = timedelta(hours=max(c.value, c.value2))
|
||||
return Q(**{f"{field}__gte": lo, f"{field}__lte": hi})
|
||||
if m == Modifier.NOT_BETWEEN and c.value2 is not None:
|
||||
lo = timedelta(minutes=min(c.value, c.value2))
|
||||
hi = timedelta(minutes=max(c.value, c.value2))
|
||||
lo = timedelta(hours=min(c.value, c.value2))
|
||||
hi = timedelta(hours=max(c.value, c.value2))
|
||||
return Q(**{f"{field}__lt": lo}) | Q(**{f"{field}__gt": hi})
|
||||
if m == Modifier.IS_NULL:
|
||||
return Q(**{f"{field}": timedelta(0)})
|
||||
@@ -164,6 +406,17 @@ class GameFilter(OperatorFilter):
|
||||
return ~Q(**{f"{field}": timedelta(0)})
|
||||
return Q()
|
||||
|
||||
@staticmethod
|
||||
def _playevent_note_to_q(criterion: StringCriterion) -> Q:
|
||||
"""Match games by substring / regex / null against their playevents' notes."""
|
||||
from games.models import PlayEvent
|
||||
|
||||
event_q = criterion.to_q("note")
|
||||
matching_ids = PlayEvent.objects.filter(event_q).values_list(
|
||||
"game_id", flat=True
|
||||
)
|
||||
return Q(id__in=matching_ids)
|
||||
|
||||
|
||||
# ── SessionFilter ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -180,7 +433,10 @@ class SessionFilter(OperatorFilter):
|
||||
device: MultiCriterion | None = None # filters on device_id
|
||||
emulated: BoolCriterion | None = None
|
||||
note: StringCriterion | None = None
|
||||
duration_minutes: IntCriterion | None = None # on duration_total
|
||||
duration_hours: IntCriterion | None = None # on duration_total (legacy alias)
|
||||
duration_total_hours: IntCriterion | None = None
|
||||
duration_manual_hours: IntCriterion | None = None
|
||||
duration_calculated_hours: IntCriterion | None = None
|
||||
is_active: BoolCriterion | None = None # timestamp_end IS NULL
|
||||
timestamp_start: StringCriterion | None = None # date string
|
||||
timestamp_end: StringCriterion | None = None # date string
|
||||
@@ -193,6 +449,47 @@ class SessionFilter(OperatorFilter):
|
||||
# Cross-entity: sessions for games matching these criteria
|
||||
game_filter: GameFilter | None = None
|
||||
|
||||
# Cross-entity: sessions for devices matching these criteria
|
||||
device_filter: DeviceFilter | None = None
|
||||
|
||||
def _duration_to_q(self, c: IntCriterion, field: str) -> Q:
|
||||
from datetime import timedelta
|
||||
|
||||
q = Q()
|
||||
td_val = timedelta(hours=c.value)
|
||||
m = c.modifier
|
||||
if m == Modifier.EQUALS:
|
||||
q &= Q(
|
||||
**{
|
||||
f"{field}__gte": td_val,
|
||||
f"{field}__lt": timedelta(hours=c.value + 1),
|
||||
}
|
||||
)
|
||||
elif m == Modifier.NOT_EQUALS:
|
||||
q &= ~Q(
|
||||
**{
|
||||
f"{field}__gte": td_val,
|
||||
f"{field}__lt": timedelta(hours=c.value + 1),
|
||||
}
|
||||
)
|
||||
elif m == Modifier.GREATER_THAN:
|
||||
q &= Q(**{f"{field}__gt": td_val})
|
||||
elif m == Modifier.LESS_THAN:
|
||||
q &= Q(**{f"{field}__lt": td_val})
|
||||
elif m == Modifier.BETWEEN and c.value2 is not None:
|
||||
lo = timedelta(hours=min(c.value, c.value2))
|
||||
hi = timedelta(hours=max(c.value, c.value2))
|
||||
q &= Q(**{f"{field}__gte": lo, f"{field}__lte": hi})
|
||||
elif m == Modifier.NOT_BETWEEN and c.value2 is not None:
|
||||
lo = timedelta(hours=min(c.value, c.value2))
|
||||
hi = timedelta(hours=max(c.value, c.value2))
|
||||
q &= Q(**{f"{field}__lt": lo}) | Q(**{f"{field}__gt": hi})
|
||||
elif m == Modifier.IS_NULL:
|
||||
q &= Q(**{f"{field}": timedelta(0)})
|
||||
elif m == Modifier.NOT_NULL:
|
||||
q &= ~Q(**{f"{field}": timedelta(0)})
|
||||
return q
|
||||
|
||||
def to_q(self) -> Q:
|
||||
from datetime import timedelta
|
||||
|
||||
@@ -206,41 +503,16 @@ class SessionFilter(OperatorFilter):
|
||||
q &= self.emulated.to_q("emulated")
|
||||
if self.note is not None:
|
||||
q &= self.note.to_q("note")
|
||||
if self.duration_minutes is not None:
|
||||
c = self.duration_minutes
|
||||
td_val = timedelta(minutes=c.value)
|
||||
field = "duration_total"
|
||||
m = c.modifier
|
||||
if m == Modifier.EQUALS:
|
||||
q &= Q(
|
||||
**{
|
||||
f"{field}__gte": td_val,
|
||||
f"{field}__lt": timedelta(minutes=c.value + 1),
|
||||
}
|
||||
)
|
||||
elif m == Modifier.NOT_EQUALS:
|
||||
q &= ~Q(
|
||||
**{
|
||||
f"{field}__gte": td_val,
|
||||
f"{field}__lt": timedelta(minutes=c.value + 1),
|
||||
}
|
||||
)
|
||||
elif m == Modifier.GREATER_THAN:
|
||||
q &= Q(**{f"{field}__gt": td_val})
|
||||
elif m == Modifier.LESS_THAN:
|
||||
q &= Q(**{f"{field}__lt": td_val})
|
||||
elif m == Modifier.BETWEEN and c.value2 is not None:
|
||||
lo = timedelta(minutes=min(c.value, c.value2))
|
||||
hi = timedelta(minutes=max(c.value, c.value2))
|
||||
q &= Q(**{f"{field}__gte": lo, f"{field}__lte": hi})
|
||||
elif m == Modifier.NOT_BETWEEN and c.value2 is not None:
|
||||
lo = timedelta(minutes=min(c.value, c.value2))
|
||||
hi = timedelta(minutes=max(c.value, c.value2))
|
||||
q &= Q(**{f"{field}__lt": lo}) | Q(**{f"{field}__gt": hi})
|
||||
elif m == Modifier.IS_NULL:
|
||||
q &= Q(**{f"{field}": timedelta(0)})
|
||||
elif m == Modifier.NOT_NULL:
|
||||
q &= ~Q(**{f"{field}": timedelta(0)})
|
||||
if self.duration_hours is not None:
|
||||
q &= self._duration_to_q(self.duration_hours, "duration_total")
|
||||
if self.duration_total_hours is not None:
|
||||
q &= self._duration_to_q(self.duration_total_hours, "duration_total")
|
||||
if self.duration_manual_hours is not None:
|
||||
q &= self._duration_to_q(self.duration_manual_hours, "duration_manual")
|
||||
if self.duration_calculated_hours is not None:
|
||||
q &= self._duration_to_q(
|
||||
self.duration_calculated_hours, "duration_calculated"
|
||||
)
|
||||
if self.is_active is not None:
|
||||
if self.is_active.value:
|
||||
q &= Q(timestamp_end__isnull=True)
|
||||
@@ -278,6 +550,14 @@ class SessionFilter(OperatorFilter):
|
||||
matching_ids = Game.objects.filter(game_q).values_list("id", flat=True)
|
||||
q &= Q(game_id__in=matching_ids)
|
||||
|
||||
# Cross-entity filter: sessions for devices matching DeviceFilter
|
||||
if self.device_filter is not None:
|
||||
from games.models import Device
|
||||
|
||||
device_q = self.device_filter.to_q()
|
||||
matching_ids = Device.objects.filter(device_q).values_list("id", flat=True)
|
||||
q &= Q(device_id__in=matching_ids)
|
||||
|
||||
# AND / OR / NOT
|
||||
sub = self.sub_filter()
|
||||
if sub is not None:
|
||||
@@ -305,8 +585,8 @@ class PurchaseFilter(OperatorFilter):
|
||||
name: StringCriterion | None = None
|
||||
platform: ChoiceCriterion | None = None # platform_id
|
||||
games: ChoiceCriterion | None = None # games (M2M IDs)
|
||||
date_purchased: StringCriterion | None = None # date string
|
||||
date_refunded: StringCriterion | None = None # date string
|
||||
date_purchased: DateCriterion | None = None
|
||||
date_refunded: DateCriterion | None = None
|
||||
is_refunded: BoolCriterion | None = None # date_refunded IS NOT NULL
|
||||
price: FloatCriterion | None = None # on price field
|
||||
converted_price: FloatCriterion | None = None
|
||||
@@ -317,12 +597,19 @@ class PurchaseFilter(OperatorFilter):
|
||||
created_at: StringCriterion | None = None
|
||||
updated_at: StringCriterion | None = None
|
||||
|
||||
infinite: BoolCriterion | None = None
|
||||
needs_price_update: BoolCriterion | None = None
|
||||
converted_currency: StringCriterion | None = None
|
||||
|
||||
# Free-text search
|
||||
search: StringCriterion | None = None
|
||||
|
||||
# Cross-entity: purchases for games matching these criteria
|
||||
game_filter: GameFilter | None = None
|
||||
|
||||
# Cross-entity: purchases for platforms matching these criteria
|
||||
platform_filter: PlatformFilter | None = None
|
||||
|
||||
def to_q(self) -> Q:
|
||||
q = Q()
|
||||
|
||||
@@ -354,6 +641,12 @@ class PurchaseFilter(OperatorFilter):
|
||||
q &= self.created_at.to_q("created_at")
|
||||
if self.updated_at is not None:
|
||||
q &= self.updated_at.to_q("updated_at")
|
||||
if self.infinite is not None:
|
||||
q &= self.infinite.to_q("infinite")
|
||||
if self.needs_price_update is not None:
|
||||
q &= self.needs_price_update.to_q("needs_price_update")
|
||||
if self.converted_currency is not None:
|
||||
q &= self.converted_currency.to_q("converted_currency")
|
||||
|
||||
# Free-text search
|
||||
if self.search is not None and self.search.value:
|
||||
@@ -374,6 +667,16 @@ class PurchaseFilter(OperatorFilter):
|
||||
matching_ids = Game.objects.filter(game_q).values_list("id", flat=True)
|
||||
q &= Q(games__id__in=matching_ids)
|
||||
|
||||
# Cross-entity platform filter
|
||||
if self.platform_filter is not None:
|
||||
from games.models import Platform
|
||||
|
||||
platform_q = self.platform_filter.to_q()
|
||||
matching_ids = Platform.objects.filter(platform_q).values_list(
|
||||
"id", flat=True
|
||||
)
|
||||
q &= Q(platform_id__in=matching_ids)
|
||||
|
||||
sub = self.sub_filter()
|
||||
if sub is not None:
|
||||
if self.AND is not None:
|
||||
@@ -420,9 +723,9 @@ class PurchaseFilter(OperatorFilter):
|
||||
subquery = subquery.filter(games=game_id)
|
||||
|
||||
if criterion.modifier == Modifier.INCLUDES_ONLY:
|
||||
extra_ids = Game.objects.exclude(
|
||||
id__in=criterion.value
|
||||
).values_list("id", flat=True)
|
||||
extra_ids = Game.objects.exclude(id__in=criterion.value).values_list(
|
||||
"id", flat=True
|
||||
)
|
||||
if extra_ids:
|
||||
subquery = subquery.exclude(games__in=extra_ids)
|
||||
|
||||
@@ -442,6 +745,213 @@ class PurchaseFilter(OperatorFilter):
|
||||
return criterion.to_q("games")
|
||||
|
||||
|
||||
# ── DeviceFilter ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeviceFilter(OperatorFilter):
|
||||
"""Filter for the Device model."""
|
||||
|
||||
AND: DeviceFilter | None = None
|
||||
OR: DeviceFilter | None = None
|
||||
NOT: DeviceFilter | None = None
|
||||
|
||||
name: StringCriterion | None = None
|
||||
type: ChoiceCriterion | None = None
|
||||
created_at: StringCriterion | None = None
|
||||
|
||||
# Free-text search
|
||||
search: StringCriterion | None = None
|
||||
|
||||
# Cross-entity: Devices that have sessions matching these criteria
|
||||
session_filter: SessionFilter | None = None
|
||||
|
||||
def to_q(self) -> Q:
|
||||
q = Q()
|
||||
|
||||
if self.name is not None:
|
||||
q &= self.name.to_q("name")
|
||||
if self.type is not None:
|
||||
q &= self.type.to_q("type")
|
||||
if self.created_at is not None:
|
||||
q &= self.created_at.to_q("created_at")
|
||||
|
||||
# Free-text search
|
||||
if self.search is not None and self.search.value:
|
||||
search_q = Q(name__icontains=self.search.value) | Q(
|
||||
type__icontains=self.search.value
|
||||
)
|
||||
if self.search.modifier == Modifier.EXCLUDES:
|
||||
search_q = ~search_q
|
||||
q &= search_q
|
||||
|
||||
# Cross-entity filter: session_filter
|
||||
if self.session_filter is not None:
|
||||
from games.models import Session
|
||||
|
||||
session_q = self.session_filter.to_q()
|
||||
matching_ids = Session.objects.filter(session_q).values_list(
|
||||
"device_id", flat=True
|
||||
)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
sub = self.sub_filter()
|
||||
if sub is not None:
|
||||
if self.AND is not None:
|
||||
q &= sub.to_q()
|
||||
elif self.OR is not None:
|
||||
q |= sub.to_q()
|
||||
elif self.NOT is not None:
|
||||
q &= ~sub.to_q()
|
||||
|
||||
return q
|
||||
|
||||
|
||||
# ── PlatformFilter ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlatformFilter(OperatorFilter):
|
||||
"""Filter for the Platform model."""
|
||||
|
||||
AND: PlatformFilter | None = None
|
||||
OR: PlatformFilter | None = None
|
||||
NOT: PlatformFilter | None = None
|
||||
|
||||
name: StringCriterion | None = None
|
||||
group: StringCriterion | None = None
|
||||
icon: StringCriterion | None = None
|
||||
created_at: StringCriterion | None = None
|
||||
|
||||
# Free-text search
|
||||
search: StringCriterion | None = None
|
||||
|
||||
# Cross-entity
|
||||
game_filter: GameFilter | None = None
|
||||
purchase_filter: PurchaseFilter | None = None
|
||||
|
||||
def to_q(self) -> Q:
|
||||
q = Q()
|
||||
|
||||
if self.name is not None:
|
||||
q &= self.name.to_q("name")
|
||||
if self.group is not None:
|
||||
q &= self.group.to_q("group")
|
||||
if self.icon is not None:
|
||||
q &= self.icon.to_q("icon")
|
||||
if self.created_at is not None:
|
||||
q &= self.created_at.to_q("created_at")
|
||||
|
||||
# Free-text search
|
||||
if self.search is not None and self.search.value:
|
||||
search_q = Q(name__icontains=self.search.value) | Q(
|
||||
group__icontains=self.search.value
|
||||
)
|
||||
if self.search.modifier == Modifier.EXCLUDES:
|
||||
search_q = ~search_q
|
||||
q &= search_q
|
||||
|
||||
# Cross-entity filter: game_filter
|
||||
if self.game_filter is not None:
|
||||
from games.models import Game
|
||||
|
||||
game_q = self.game_filter.to_q()
|
||||
matching_ids = Game.objects.filter(game_q).values_list(
|
||||
"platform_id", flat=True
|
||||
)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
# Cross-entity filter: purchase_filter
|
||||
if self.purchase_filter is not None:
|
||||
from games.models import Purchase
|
||||
|
||||
purchase_q = self.purchase_filter.to_q()
|
||||
matching_ids = Purchase.objects.filter(purchase_q).values_list(
|
||||
"platform_id", flat=True
|
||||
)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
sub = self.sub_filter()
|
||||
if sub is not None:
|
||||
if self.AND is not None:
|
||||
q &= sub.to_q()
|
||||
elif self.OR is not None:
|
||||
q |= sub.to_q()
|
||||
elif self.NOT is not None:
|
||||
q &= ~sub.to_q()
|
||||
|
||||
return q
|
||||
|
||||
|
||||
# ── PlayEventFilter ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlayEventFilter(OperatorFilter):
|
||||
"""Filter for the PlayEvent model."""
|
||||
|
||||
AND: PlayEventFilter | None = None
|
||||
OR: PlayEventFilter | None = None
|
||||
NOT: PlayEventFilter | None = None
|
||||
|
||||
game: MultiCriterion | None = None # filters on game_id
|
||||
started: StringCriterion | None = None # date string
|
||||
ended: StringCriterion | None = None # date string
|
||||
days_to_finish: IntCriterion | None = None
|
||||
note: StringCriterion | None = None
|
||||
created_at: StringCriterion | None = None
|
||||
|
||||
# Free-text search
|
||||
search: StringCriterion | None = None
|
||||
|
||||
# Cross-entity: PlayEvents for games matching these criteria
|
||||
game_filter: GameFilter | None = None
|
||||
|
||||
def to_q(self) -> Q:
|
||||
q = Q()
|
||||
|
||||
if self.game is not None:
|
||||
q &= self.game.to_q("game_id")
|
||||
if self.started is not None:
|
||||
q &= self.started.to_q("started")
|
||||
if self.ended is not None:
|
||||
q &= self.ended.to_q("ended")
|
||||
if self.days_to_finish is not None:
|
||||
q &= self.days_to_finish.to_q("days_to_finish")
|
||||
if self.note is not None:
|
||||
q &= self.note.to_q("note")
|
||||
if self.created_at is not None:
|
||||
q &= self.created_at.to_q("created_at")
|
||||
|
||||
# Free-text search
|
||||
if self.search is not None and self.search.value:
|
||||
search_q = Q(game__name__icontains=self.search.value) | Q(
|
||||
note__icontains=self.search.value
|
||||
)
|
||||
if self.search.modifier == Modifier.EXCLUDES:
|
||||
search_q = ~search_q
|
||||
q &= search_q
|
||||
|
||||
# Cross-entity filter: game_filter
|
||||
if self.game_filter is not None:
|
||||
from games.models import Game
|
||||
|
||||
game_q = self.game_filter.to_q()
|
||||
matching_ids = Game.objects.filter(game_q).values_list("id", flat=True)
|
||||
q &= Q(game_id__in=matching_ids)
|
||||
|
||||
sub = self.sub_filter()
|
||||
if sub is not None:
|
||||
if self.AND is not None:
|
||||
q &= sub.to_q()
|
||||
elif self.OR is not None:
|
||||
q |= sub.to_q()
|
||||
elif self.NOT is not None:
|
||||
q &= ~sub.to_q()
|
||||
|
||||
return q
|
||||
|
||||
|
||||
# ── Convenience helpers ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -455,3 +965,15 @@ def parse_session_filter(json_str: str) -> SessionFilter | None:
|
||||
|
||||
def parse_purchase_filter(json_str: str) -> PurchaseFilter | None:
|
||||
return filter_from_json(PurchaseFilter, json_str)
|
||||
|
||||
|
||||
def parse_device_filter(json_str: str) -> DeviceFilter | None:
|
||||
return filter_from_json(DeviceFilter, json_str)
|
||||
|
||||
|
||||
def parse_platform_filter(json_str: str) -> PlatformFilter | None:
|
||||
return filter_from_json(PlatformFilter, json_str)
|
||||
|
||||
|
||||
def parse_playevent_filter(json_str: str) -> PlayEventFilter | None:
|
||||
return filter_from_json(PlayEventFilter, json_str)
|
||||
|
||||
+55
-36
@@ -1,71 +1,90 @@
|
||||
- model: games.game
|
||||
pk: 1
|
||||
fields:
|
||||
name: Nioh 2
|
||||
wikidata: Q67482292
|
||||
- model: games.game
|
||||
pk: 2
|
||||
fields:
|
||||
name: Elden Ring
|
||||
wikidata: Q64826862
|
||||
- model: games.game
|
||||
pk: 3
|
||||
fields:
|
||||
name: Cyberpunk 2077
|
||||
wikidata: Q3182559
|
||||
- model: games.purchase
|
||||
pk: 1
|
||||
fields:
|
||||
game: 1
|
||||
platform: 1
|
||||
date_purchased: 2021-02-13
|
||||
date_refunded: null
|
||||
- model: games.purchase
|
||||
pk: 2
|
||||
fields:
|
||||
game: 2
|
||||
platform: 1
|
||||
date_purchased: 2022-02-24
|
||||
date_refunded: null
|
||||
- model: games.purchase
|
||||
pk: 3
|
||||
fields:
|
||||
game: 3
|
||||
platform: 1
|
||||
date_purchased: 2020-12-07
|
||||
date_refunded: null
|
||||
- model: games.platform
|
||||
pk: 1
|
||||
fields:
|
||||
name: Steam
|
||||
group: PC
|
||||
created_at: "2020-01-01T00:00:00Z"
|
||||
- model: games.platform
|
||||
pk: 3
|
||||
fields:
|
||||
name: Xbox Gamepass
|
||||
group: PC
|
||||
created_at: "2020-01-01T00:00:00Z"
|
||||
- model: games.platform
|
||||
pk: 4
|
||||
fields:
|
||||
name: Epic Games Store
|
||||
group: PC
|
||||
created_at: "2020-01-01T00:00:00Z"
|
||||
- model: games.platform
|
||||
pk: 5
|
||||
fields:
|
||||
name: Playstation 5
|
||||
group: Playstation
|
||||
created_at: "2020-01-01T00:00:00Z"
|
||||
- model: games.platform
|
||||
pk: 6
|
||||
fields:
|
||||
name: Playstation 4
|
||||
group: Playstation
|
||||
created_at: "2020-01-01T00:00:00Z"
|
||||
- model: games.platform
|
||||
pk: 7
|
||||
fields:
|
||||
name: Nintendo Switch
|
||||
group: Nintendo
|
||||
created_at: "2020-01-01T00:00:00Z"
|
||||
- model: games.platform
|
||||
pk: 8
|
||||
fields:
|
||||
name: Nintendo 3DS
|
||||
group: Nintendo
|
||||
created_at: "2020-01-01T00:00:00Z"
|
||||
- model: games.game
|
||||
pk: 1
|
||||
fields:
|
||||
name: Nioh 2
|
||||
wikidata: Q67482292
|
||||
created_at: "2021-02-13T00:00:00Z"
|
||||
updated_at: "2021-02-13T00:00:00Z"
|
||||
- model: games.game
|
||||
pk: 2
|
||||
fields:
|
||||
name: Elden Ring
|
||||
wikidata: Q64826862
|
||||
created_at: "2022-02-24T00:00:00Z"
|
||||
updated_at: "2022-02-24T00:00:00Z"
|
||||
- model: games.game
|
||||
pk: 3
|
||||
fields:
|
||||
name: Cyberpunk 2077
|
||||
wikidata: Q3182559
|
||||
created_at: "2020-12-07T00:00:00Z"
|
||||
updated_at: "2020-12-07T00:00:00Z"
|
||||
- model: games.purchase
|
||||
pk: 1
|
||||
fields:
|
||||
games: [1]
|
||||
platform: 1
|
||||
date_purchased: 2021-02-13
|
||||
date_refunded: null
|
||||
created_at: "2021-02-13T00:00:00Z"
|
||||
updated_at: "2021-02-13T00:00:00Z"
|
||||
- model: games.purchase
|
||||
pk: 2
|
||||
fields:
|
||||
games: [2]
|
||||
platform: 1
|
||||
date_purchased: 2022-02-24
|
||||
date_refunded: null
|
||||
created_at: "2022-02-24T00:00:00Z"
|
||||
updated_at: "2022-02-24T00:00:00Z"
|
||||
- model: games.purchase
|
||||
pk: 3
|
||||
fields:
|
||||
games: [3]
|
||||
platform: 1
|
||||
date_purchased: 2020-12-07
|
||||
date_refunded: null
|
||||
created_at: "2020-12-07T00:00:00Z"
|
||||
updated_at: "2020-12-07T00:00:00Z"
|
||||
|
||||
+64
-19
@@ -3,10 +3,13 @@ from django.db import transaction
|
||||
from django.db.models import OuterRef, Subquery
|
||||
|
||||
from common.components import (
|
||||
DEFAULT_PREFETCH,
|
||||
SearchSelect,
|
||||
SearchSelectOption,
|
||||
render,
|
||||
searchselect_selected,
|
||||
)
|
||||
from common.components.primitives import Checkbox
|
||||
from games.models import (
|
||||
Device,
|
||||
Game,
|
||||
@@ -24,6 +27,42 @@ custom_datetime_widget = forms.DateTimeInput(
|
||||
autofocus_input_widget = forms.TextInput(attrs={"autofocus": "autofocus"})
|
||||
|
||||
|
||||
class PrimitiveCheckboxWidget(forms.CheckboxInput):
|
||||
"""Adapts Django's CheckboxInput to use our Checkbox component."""
|
||||
|
||||
def render(self, name, value, attrs=None, renderer=None):
|
||||
final_attrs = self.build_attrs(self.attrs, attrs)
|
||||
checked = self.check_test(value)
|
||||
attributes = [
|
||||
(k, str(v))
|
||||
for k, v in final_attrs.items()
|
||||
if k not in ("type", "name", "value", "checked")
|
||||
]
|
||||
|
||||
# Django uses boolean values differently for checkboxes, we omit value if empty
|
||||
# render() returns a safe string (Django widgets must not be autoescaped).
|
||||
return render(
|
||||
Checkbox(
|
||||
name=name,
|
||||
label=None,
|
||||
checked=checked,
|
||||
value=str(value) if value else "1",
|
||||
attributes=attributes,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class PrimitiveWidgetsMixin:
|
||||
"""Automatically applies primitive custom widgets to native Django form fields."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
for field_name, field in self.fields.items():
|
||||
if isinstance(field, forms.BooleanField):
|
||||
field.widget = PrimitiveCheckboxWidget()
|
||||
# Maintain the field's explicit required status (usually False for booleans)
|
||||
|
||||
|
||||
class MultipleGameChoiceField(forms.ModelMultipleChoiceField):
|
||||
def label_from_instance(self, obj) -> str:
|
||||
return obj.search_label
|
||||
@@ -75,6 +114,7 @@ class SearchSelectWidget(forms.Widget):
|
||||
multi_select=False,
|
||||
items_visible=5,
|
||||
items_scroll=10,
|
||||
prefetch=DEFAULT_PREFETCH,
|
||||
always_visible=False,
|
||||
placeholder="Search…",
|
||||
attrs=None,
|
||||
@@ -85,6 +125,7 @@ class SearchSelectWidget(forms.Widget):
|
||||
self.multi_select = multi_select
|
||||
self.items_visible = items_visible
|
||||
self.items_scroll = items_scroll
|
||||
self.prefetch = prefetch
|
||||
self.always_visible = always_visible
|
||||
self.placeholder = placeholder
|
||||
|
||||
@@ -99,18 +140,22 @@ class SearchSelectWidget(forms.Widget):
|
||||
def render(self, name, value, attrs=None, renderer=None):
|
||||
selected = searchselect_selected(self._values(value), self.options_resolver)
|
||||
autofocus = bool((attrs or {}).get("autofocus"))
|
||||
return SearchSelect(
|
||||
name=name,
|
||||
selected=selected,
|
||||
options=None,
|
||||
search_url=self.search_url,
|
||||
multi_select=self.multi_select,
|
||||
items_visible=self.items_visible,
|
||||
items_scroll=self.items_scroll,
|
||||
always_visible=self.always_visible,
|
||||
placeholder=self.placeholder,
|
||||
id=(attrs or {}).get("id", ""),
|
||||
autofocus=autofocus,
|
||||
# Django widgets must return a safe string; the component is a node.
|
||||
return render(
|
||||
SearchSelect(
|
||||
name=name,
|
||||
selected=selected,
|
||||
options=None,
|
||||
search_url=self.search_url,
|
||||
multi_select=self.multi_select,
|
||||
items_visible=self.items_visible,
|
||||
items_scroll=self.items_scroll,
|
||||
prefetch=self.prefetch,
|
||||
always_visible=self.always_visible,
|
||||
placeholder=self.placeholder,
|
||||
id=(attrs or {}).get("id", ""),
|
||||
autofocus=autofocus,
|
||||
)
|
||||
)
|
||||
|
||||
def value_from_datadict(self, data, files, name):
|
||||
@@ -124,7 +169,7 @@ class SearchSelectMultiple(SearchSelectWidget):
|
||||
return data.get(name)
|
||||
|
||||
|
||||
class SessionForm(forms.ModelForm):
|
||||
class SessionForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
||||
game = SingleGameChoiceField(
|
||||
queryset=Game.objects.order_by("sort_name"),
|
||||
widget=SearchSelectWidget(
|
||||
@@ -208,7 +253,7 @@ class RelatedPurchaseChoiceField(forms.ModelChoiceField):
|
||||
return name or obj.standardized_name
|
||||
|
||||
|
||||
class PurchaseForm(forms.ModelForm):
|
||||
class PurchaseForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["platform"].queryset = Platform.objects.order_by("name")
|
||||
@@ -301,7 +346,7 @@ class GameModelChoiceField(forms.ModelChoiceField):
|
||||
return obj.sort_name
|
||||
|
||||
|
||||
class GameForm(forms.ModelForm):
|
||||
class GameForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
||||
platform = forms.ModelChoiceField(
|
||||
queryset=Platform.objects.order_by("name"),
|
||||
required=False,
|
||||
@@ -325,7 +370,7 @@ class GameForm(forms.ModelForm):
|
||||
widgets = {"name": autofocus_input_widget}
|
||||
|
||||
|
||||
class PlatformForm(forms.ModelForm):
|
||||
class PlatformForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
||||
class Meta:
|
||||
model = Platform
|
||||
fields = [
|
||||
@@ -336,14 +381,14 @@ class PlatformForm(forms.ModelForm):
|
||||
widgets = {"name": autofocus_input_widget}
|
||||
|
||||
|
||||
class DeviceForm(forms.ModelForm):
|
||||
class DeviceForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
||||
class Meta:
|
||||
model = Device
|
||||
fields = ["name", "type"]
|
||||
widgets = {"name": autofocus_input_widget}
|
||||
|
||||
|
||||
class PlayEventForm(forms.ModelForm):
|
||||
class PlayEventForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
||||
game = SingleGameChoiceField(
|
||||
queryset=Game.objects.order_by("sort_name"),
|
||||
widget=SearchSelectWidget(
|
||||
@@ -378,7 +423,7 @@ class PlayEventForm(forms.ModelForm):
|
||||
return session
|
||||
|
||||
|
||||
class GameStatusChangeForm(forms.ModelForm):
|
||||
class GameStatusChangeForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
||||
class Meta:
|
||||
model = GameStatusChange
|
||||
fields = [
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Write ts/generated/props.ts from the registered custom-element specs."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
# Importing the components package triggers element registration at import time.
|
||||
import common.components # noqa: F401
|
||||
from common.components.custom_elements import render_props_module
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Generate ts/generated/props.ts from registered custom elements."
|
||||
|
||||
def handle(self, *args, **options) -> None:
|
||||
output_dir = Path(settings.BASE_DIR) / "ts" / "generated"
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
target = output_dir / "props.ts"
|
||||
target.write_text(render_props_module(), encoding="utf-8")
|
||||
self.stdout.write(self.style.SUCCESS(f"Wrote {target}"))
|
||||
@@ -0,0 +1,28 @@
|
||||
from django.contrib.sessions.models import Session
|
||||
from django.core.management.base import BaseCommand
|
||||
from django_q.models import OrmQ, Schedule, Task
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = (
|
||||
"Remove copied production artifacts from a staging database seeded "
|
||||
"from a production snapshot: clears authenticated sessions and the "
|
||||
"django-q schedule/queue/results so staging does not share prod's "
|
||||
"session cookies or independently run scheduled tasks."
|
||||
)
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
sessions_deleted, _ = Session.objects.all().delete()
|
||||
schedules_deleted, _ = Schedule.objects.all().delete()
|
||||
tasks_deleted, _ = Task.objects.all().delete()
|
||||
queued_deleted, _ = OrmQ.objects.all().delete()
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
"Scrubbed staging database: "
|
||||
f"{sessions_deleted} session(s), "
|
||||
f"{schedules_deleted} schedule(s), "
|
||||
f"{tasks_deleted} task result(s), "
|
||||
f"{queued_deleted} queued task(s) removed."
|
||||
)
|
||||
)
|
||||
@@ -4,15 +4,14 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('games', '0017_add_filter_preset'),
|
||||
("games", "0017_add_filter_preset"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='session',
|
||||
name='timestamp_start',
|
||||
field=models.DateTimeField(db_index=True, verbose_name='Start'),
|
||||
model_name="session",
|
||||
name="timestamp_start",
|
||||
field=models.DateTimeField(db_index=True, verbose_name="Start"),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 6.0.5 on 2026-06-13 18:48
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("games", "0018_alter_session_timestamp_start"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="filterpreset",
|
||||
name="mode",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("games", "Games"),
|
||||
("sessions", "Sessions"),
|
||||
("purchases", "Purchases"),
|
||||
("playevents", "Play Events"),
|
||||
("devices", "Devices"),
|
||||
("platforms", "Platforms"),
|
||||
],
|
||||
default="games",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -501,6 +501,8 @@ class FilterPreset(models.Model):
|
||||
("sessions", "Sessions"),
|
||||
("purchases", "Purchases"),
|
||||
("playevents", "Play Events"),
|
||||
("devices", "Devices"),
|
||||
("platforms", "Platforms"),
|
||||
]
|
||||
|
||||
name = models.CharField(max_length=255)
|
||||
|
||||
+240
-42
@@ -306,7 +306,6 @@
|
||||
--color-neutral-tertiary: var(--color-gray-100);
|
||||
--color-neutral-tertiary-medium: var(--color-gray-100);
|
||||
--color-neutral-quaternary: var(--color-gray-200);
|
||||
--color-brand-soft: var(--color-blue-100);
|
||||
--color-brand: var(--color-blue-700);
|
||||
--color-brand-medium: var(--color-blue-200);
|
||||
--color-brand-strong: var(--color-blue-800);
|
||||
@@ -467,6 +466,9 @@
|
||||
}
|
||||
}
|
||||
@layer utilities {
|
||||
.\@container {
|
||||
container-type: inline-size;
|
||||
}
|
||||
.pointer-events-auto {
|
||||
pointer-events: auto;
|
||||
}
|
||||
@@ -916,6 +918,9 @@
|
||||
.ms-2\.5 {
|
||||
margin-inline-start: calc(var(--spacing) * 2.5);
|
||||
}
|
||||
.ms-auto {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
.me-2 {
|
||||
margin-inline-end: calc(var(--spacing) * 2);
|
||||
}
|
||||
@@ -1476,6 +1481,9 @@
|
||||
.h-8 {
|
||||
height: calc(var(--spacing) * 8);
|
||||
}
|
||||
.h-9 {
|
||||
height: calc(var(--spacing) * 9);
|
||||
}
|
||||
.h-10 {
|
||||
height: calc(var(--spacing) * 10);
|
||||
}
|
||||
@@ -1574,6 +1582,12 @@
|
||||
.w-5 {
|
||||
width: calc(var(--spacing) * 5);
|
||||
}
|
||||
.w-5\/6 {
|
||||
width: calc(5 / 6 * 100%);
|
||||
}
|
||||
.w-8 {
|
||||
width: calc(var(--spacing) * 8);
|
||||
}
|
||||
.w-10 {
|
||||
width: calc(var(--spacing) * 10);
|
||||
}
|
||||
@@ -1589,6 +1603,12 @@
|
||||
.w-72 {
|
||||
width: calc(var(--spacing) * 72);
|
||||
}
|
||||
.w-\[2\.5ch\] {
|
||||
width: 2.5ch;
|
||||
}
|
||||
.w-\[4\.5ch\] {
|
||||
width: 4.5ch;
|
||||
}
|
||||
.w-\[300px\] {
|
||||
width: 300px;
|
||||
}
|
||||
@@ -1728,6 +1748,9 @@
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
.cursor-text {
|
||||
cursor: text;
|
||||
}
|
||||
.resize {
|
||||
resize: both;
|
||||
}
|
||||
@@ -1740,6 +1763,9 @@
|
||||
.grid-cols-1 {
|
||||
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||
}
|
||||
.grid-cols-2 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.grid-cols-4 {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
@@ -1764,6 +1790,9 @@
|
||||
.items-start {
|
||||
align-items: flex-start;
|
||||
}
|
||||
.items-stretch {
|
||||
align-items: stretch;
|
||||
}
|
||||
.justify-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
@@ -1776,9 +1805,15 @@
|
||||
.justify-start {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.gap-0\.5 {
|
||||
gap: calc(var(--spacing) * 0.5);
|
||||
}
|
||||
.gap-1 {
|
||||
gap: calc(var(--spacing) * 1);
|
||||
}
|
||||
.gap-1\.5 {
|
||||
gap: calc(var(--spacing) * 1.5);
|
||||
}
|
||||
.gap-2 {
|
||||
gap: calc(var(--spacing) * 2);
|
||||
}
|
||||
@@ -1791,6 +1826,9 @@
|
||||
.gap-5 {
|
||||
gap: calc(var(--spacing) * 5);
|
||||
}
|
||||
.gap-6 {
|
||||
gap: calc(var(--spacing) * 6);
|
||||
}
|
||||
.space-y-6 {
|
||||
:where(& > :not(:last-child)) {
|
||||
--tw-space-y-reverse: 0;
|
||||
@@ -1819,6 +1857,9 @@
|
||||
margin-inline-end: calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-x-reverse)));
|
||||
}
|
||||
}
|
||||
.gap-y-0\.5 {
|
||||
row-gap: calc(var(--spacing) * 0.5);
|
||||
}
|
||||
.gap-y-4 {
|
||||
row-gap: calc(var(--spacing) * 4);
|
||||
}
|
||||
@@ -1887,6 +1928,9 @@
|
||||
.rounded-xl {
|
||||
border-radius: var(--radius-xl);
|
||||
}
|
||||
.rounded-xs {
|
||||
border-radius: var(--radius-xs);
|
||||
}
|
||||
.rounded-s-base {
|
||||
border-start-start-radius: var(--radius-base);
|
||||
border-end-start-radius: var(--radius-base);
|
||||
@@ -1911,20 +1955,21 @@
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
}
|
||||
.rounded-l-lg {
|
||||
border-top-left-radius: var(--radius-lg);
|
||||
border-bottom-left-radius: var(--radius-lg);
|
||||
}
|
||||
.rounded-tl-none {
|
||||
border-top-left-radius: 0;
|
||||
}
|
||||
.rounded-tr-md {
|
||||
border-top-right-radius: var(--radius-md);
|
||||
.rounded-r-lg {
|
||||
border-top-right-radius: var(--radius-lg);
|
||||
border-bottom-right-radius: var(--radius-lg);
|
||||
}
|
||||
.rounded-b {
|
||||
border-bottom-right-radius: var(--radius);
|
||||
border-bottom-left-radius: var(--radius);
|
||||
}
|
||||
.rounded-b-md {
|
||||
border-bottom-right-radius: var(--radius-md);
|
||||
border-bottom-left-radius: var(--radius-md);
|
||||
}
|
||||
.border {
|
||||
border-style: var(--tw-border-style);
|
||||
border-width: 1px;
|
||||
@@ -1933,14 +1978,14 @@
|
||||
border-style: var(--tw-border-style);
|
||||
border-width: 0px;
|
||||
}
|
||||
.border-0\! {
|
||||
border-style: var(--tw-border-style) !important;
|
||||
border-width: 0px !important;
|
||||
}
|
||||
.border-2 {
|
||||
border-style: var(--tw-border-style);
|
||||
border-width: 2px;
|
||||
}
|
||||
.border-y {
|
||||
border-block-style: var(--tw-border-style);
|
||||
border-block-width: 1px;
|
||||
}
|
||||
.border-e {
|
||||
border-inline-end-style: var(--tw-border-style);
|
||||
border-inline-end-width: 1px;
|
||||
@@ -2010,9 +2055,21 @@
|
||||
.border-blue-200 {
|
||||
border-color: var(--color-blue-200);
|
||||
}
|
||||
.border-blue-600 {
|
||||
border-color: var(--color-blue-600);
|
||||
}
|
||||
.border-blue-700 {
|
||||
border-color: var(--color-blue-700);
|
||||
}
|
||||
.border-brand {
|
||||
border-color: var(--color-brand);
|
||||
}
|
||||
.border-brand\/70 {
|
||||
border-color: color-mix(in srgb, oklch(48.8% 0.243 264.376) 70%, transparent);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
border-color: color-mix(in oklab, var(--color-brand) 70%, transparent);
|
||||
}
|
||||
}
|
||||
.border-default {
|
||||
border-color: var(--color-default);
|
||||
}
|
||||
@@ -2104,12 +2161,24 @@
|
||||
.bg-brand {
|
||||
background-color: var(--color-brand);
|
||||
}
|
||||
.bg-brand\/10 {
|
||||
background-color: color-mix(in srgb, oklch(48.8% 0.243 264.376) 10%, transparent);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
background-color: color-mix(in oklab, var(--color-brand) 10%, transparent);
|
||||
}
|
||||
}
|
||||
.bg-brand\/15 {
|
||||
background-color: color-mix(in srgb, oklch(48.8% 0.243 264.376) 15%, transparent);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
background-color: color-mix(in oklab, var(--color-brand) 15%, transparent);
|
||||
}
|
||||
}
|
||||
.bg-brand\/30 {
|
||||
background-color: color-mix(in srgb, oklch(48.8% 0.243 264.376) 30%, transparent);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
background-color: color-mix(in oklab, var(--color-brand) 30%, transparent);
|
||||
}
|
||||
}
|
||||
.bg-dark-backdrop\/70 {
|
||||
background-color: color-mix(in srgb, oklch(13% 0.028 261.692) 70%, transparent);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
@@ -2122,6 +2191,9 @@
|
||||
.bg-gray-100 {
|
||||
background-color: var(--color-gray-100);
|
||||
}
|
||||
.bg-gray-200 {
|
||||
background-color: var(--color-gray-200);
|
||||
}
|
||||
.bg-gray-400 {
|
||||
background-color: var(--color-gray-400);
|
||||
}
|
||||
@@ -2279,6 +2351,9 @@
|
||||
padding: 0 !important;
|
||||
}
|
||||
}
|
||||
.p-0 {
|
||||
padding: calc(var(--spacing) * 0);
|
||||
}
|
||||
.p-1 {
|
||||
padding: calc(var(--spacing) * 1);
|
||||
}
|
||||
@@ -2303,6 +2378,9 @@
|
||||
.p-6 {
|
||||
padding: calc(var(--spacing) * 6);
|
||||
}
|
||||
.px-0\.5 {
|
||||
padding-inline: calc(var(--spacing) * 0.5);
|
||||
}
|
||||
.px-2 {
|
||||
padding-inline: calc(var(--spacing) * 2);
|
||||
}
|
||||
@@ -2402,6 +2480,9 @@
|
||||
.text-right {
|
||||
text-align: right;
|
||||
}
|
||||
.text-start {
|
||||
text-align: start;
|
||||
}
|
||||
.align-middle {
|
||||
vertical-align: middle;
|
||||
}
|
||||
@@ -2595,6 +2676,9 @@
|
||||
.text-blue-500 {
|
||||
color: var(--color-blue-500);
|
||||
}
|
||||
.text-blue-600 {
|
||||
color: var(--color-blue-600);
|
||||
}
|
||||
.text-blue-800 {
|
||||
color: var(--color-blue-800);
|
||||
}
|
||||
@@ -2682,9 +2766,6 @@
|
||||
.line-through {
|
||||
text-decoration-line: line-through;
|
||||
}
|
||||
.no-underline\! {
|
||||
text-decoration-line: none !important;
|
||||
}
|
||||
.underline {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
@@ -2697,9 +2778,18 @@
|
||||
.decoration-dotted {
|
||||
text-decoration-style: dotted;
|
||||
}
|
||||
.caret-transparent {
|
||||
caret-color: transparent;
|
||||
}
|
||||
.opacity-0 {
|
||||
opacity: 0%;
|
||||
}
|
||||
.opacity-40 {
|
||||
opacity: 40%;
|
||||
}
|
||||
.opacity-50 {
|
||||
opacity: 50%;
|
||||
}
|
||||
.opacity-100 {
|
||||
opacity: 100%;
|
||||
}
|
||||
@@ -2732,6 +2822,13 @@
|
||||
--tw-shadow: 0 1px 2px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.05));
|
||||
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
|
||||
}
|
||||
.ring-2 {
|
||||
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
|
||||
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
|
||||
}
|
||||
.ring-brand-strong {
|
||||
--tw-ring-color: var(--color-brand-strong);
|
||||
}
|
||||
.outline {
|
||||
outline-style: var(--tw-outline-style);
|
||||
outline-width: 1px;
|
||||
@@ -2753,6 +2850,11 @@
|
||||
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
|
||||
transition-duration: var(--tw-duration, var(--default-transition-duration));
|
||||
}
|
||||
.transition-all {
|
||||
transition-property: all;
|
||||
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
|
||||
transition-duration: var(--tw-duration, var(--default-transition-duration));
|
||||
}
|
||||
.transition-opacity {
|
||||
transition-property: opacity;
|
||||
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
|
||||
@@ -2792,6 +2894,9 @@
|
||||
.\[program\:qcluster\] {
|
||||
program: qcluster;
|
||||
}
|
||||
.ring-inset {
|
||||
--tw-ring-inset: inset;
|
||||
}
|
||||
.group-hover\:absolute {
|
||||
&:is(:where(.group):hover *) {
|
||||
@media (hover: hover) {
|
||||
@@ -2892,6 +2997,16 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.group-data-\[search-select-highlighted\]\:border-white {
|
||||
&:is(:where(.group)[data-search-select-highlighted] *) {
|
||||
border-color: var(--color-white);
|
||||
}
|
||||
}
|
||||
.group-data-\[search-select-highlighted\]\:text-white {
|
||||
&:is(:where(.group)[data-search-select-highlighted] *) {
|
||||
color: var(--color-white);
|
||||
}
|
||||
}
|
||||
.placeholder\:text-body {
|
||||
&::placeholder {
|
||||
color: var(--color-body);
|
||||
@@ -2923,6 +3038,22 @@
|
||||
padding-top: calc(var(--spacing) * 0);
|
||||
}
|
||||
}
|
||||
.focus-within\:border-brand {
|
||||
&:focus-within {
|
||||
border-color: var(--color-brand);
|
||||
}
|
||||
}
|
||||
.focus-within\:ring-1 {
|
||||
&:focus-within {
|
||||
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
|
||||
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
|
||||
}
|
||||
}
|
||||
.focus-within\:ring-brand {
|
||||
&:focus-within {
|
||||
--tw-ring-color: var(--color-brand);
|
||||
}
|
||||
}
|
||||
.hover\:scale-110 {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
@@ -2954,6 +3085,13 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.hover\:border-gray-300 {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
border-color: var(--color-gray-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
.hover\:border-green-600 {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
@@ -3013,6 +3151,13 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.hover\:bg-gray-700 {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
background-color: var(--color-gray-700);
|
||||
}
|
||||
}
|
||||
}
|
||||
.hover\:bg-green-500 {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
@@ -3160,6 +3305,24 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.group-data-\[search-select-highlighted\]\:hover\:border-white {
|
||||
&:is(:where(.group)[data-search-select-highlighted] *) {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
border-color: var(--color-white);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.group-data-\[search-select-highlighted\]\:hover\:bg-brand-strong {
|
||||
&:is(:where(.group)[data-search-select-highlighted] *) {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
background-color: var(--color-brand-strong);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.focus\:z-10 {
|
||||
&:focus {
|
||||
z-index: 10;
|
||||
@@ -3170,6 +3333,14 @@
|
||||
border-color: var(--color-brand);
|
||||
}
|
||||
}
|
||||
.focus\:bg-brand\/30 {
|
||||
&:focus {
|
||||
background-color: color-mix(in srgb, oklch(48.8% 0.243 264.376) 30%, transparent);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
background-color: color-mix(in oklab, var(--color-brand) 30%, transparent);
|
||||
}
|
||||
}
|
||||
}
|
||||
.focus\:text-blue-700 {
|
||||
&:focus {
|
||||
color: var(--color-blue-700);
|
||||
@@ -3259,6 +3430,36 @@
|
||||
outline-style: none;
|
||||
}
|
||||
}
|
||||
.data-\[search-select-highlighted\]\:bg-brand {
|
||||
&[data-search-select-highlighted] {
|
||||
background-color: var(--color-brand);
|
||||
}
|
||||
}
|
||||
.data-\[search-select-highlighted\]\:bg-brand\/15 {
|
||||
&[data-search-select-highlighted] {
|
||||
background-color: color-mix(in srgb, oklch(48.8% 0.243 264.376) 15%, transparent);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
background-color: color-mix(in oklab, var(--color-brand) 15%, transparent);
|
||||
}
|
||||
}
|
||||
}
|
||||
.data-\[search-select-highlighted\]\:outline {
|
||||
&[data-search-select-highlighted] {
|
||||
outline-style: var(--tw-outline-style);
|
||||
outline-width: 1px;
|
||||
}
|
||||
}
|
||||
.data-\[search-select-highlighted\]\:outline-1 {
|
||||
&[data-search-select-highlighted] {
|
||||
outline-style: var(--tw-outline-style);
|
||||
outline-width: 1px;
|
||||
}
|
||||
}
|
||||
.data-\[search-select-highlighted\]\:outline-brand-strong {
|
||||
&[data-search-select-highlighted] {
|
||||
outline-color: var(--color-brand-strong);
|
||||
}
|
||||
}
|
||||
.sm\:table-cell {
|
||||
@media (width >= 40rem) {
|
||||
display: table-cell;
|
||||
@@ -3444,6 +3645,11 @@
|
||||
max-width: var(--breakpoint-2xl);
|
||||
}
|
||||
}
|
||||
.\@md\:grid-cols-4 {
|
||||
@container (width >= 28rem) {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
.rtl\:rotate-180 {
|
||||
&:where(:dir(rtl), [dir="rtl"], [dir="rtl"] *) {
|
||||
rotate: 180deg;
|
||||
@@ -3489,6 +3695,11 @@
|
||||
border-color: var(--color-amber-700);
|
||||
}
|
||||
}
|
||||
.dark\:border-blue-500 {
|
||||
&:is(.dark *) {
|
||||
border-color: var(--color-blue-500);
|
||||
}
|
||||
}
|
||||
.dark\:border-blue-700 {
|
||||
&:is(.dark *) {
|
||||
border-color: var(--color-blue-700);
|
||||
@@ -3524,6 +3735,11 @@
|
||||
border-color: var(--color-red-700);
|
||||
}
|
||||
}
|
||||
.dark\:border-transparent {
|
||||
&:is(.dark *) {
|
||||
border-color: transparent;
|
||||
}
|
||||
}
|
||||
.dark\:bg-amber-900 {
|
||||
&:is(.dark *) {
|
||||
background-color: var(--color-amber-900);
|
||||
@@ -3821,6 +4037,15 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.dark\:hover\:text-blue-500 {
|
||||
&:is(.dark *) {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
color: var(--color-blue-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.dark\:hover\:text-gray-300 {
|
||||
&:is(.dark *) {
|
||||
&:hover {
|
||||
@@ -3971,17 +4196,6 @@
|
||||
text-underline-offset: 4px;
|
||||
}
|
||||
}
|
||||
.\[\&_li\:first-of-type_a\]\:rounded-none {
|
||||
& li:first-of-type a {
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
.\[\&_li\:last-of-type_a\]\:rounded-t-none {
|
||||
& li:last-of-type a {
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
}
|
||||
}
|
||||
.\[\&_td\:last-child\]\:text-right {
|
||||
& td:last-child {
|
||||
text-align: right;
|
||||
@@ -4320,7 +4534,7 @@ form input:disabled, select:disabled, textarea:disabled {
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-heading);
|
||||
}
|
||||
input:not([type="checkbox"]) {
|
||||
input:not([type="checkbox"]):not([data-search-select-search]) {
|
||||
margin-bottom: calc(var(--spacing) * 3);
|
||||
display: block;
|
||||
width: 100%;
|
||||
@@ -4346,22 +4560,6 @@ form input:disabled, select:disabled, textarea:disabled {
|
||||
--tw-ring-color: var(--color-brand);
|
||||
}
|
||||
}
|
||||
input[type="checkbox"] {
|
||||
height: calc(var(--spacing) * 4);
|
||||
width: calc(var(--spacing) * 4);
|
||||
border-radius: var(--radius-xs);
|
||||
border-style: var(--tw-border-style);
|
||||
border-width: 1px;
|
||||
border-color: var(--color-default-medium);
|
||||
background-color: var(--color-neutral-secondary-medium);
|
||||
&:focus {
|
||||
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
|
||||
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
|
||||
}
|
||||
&:focus {
|
||||
--tw-ring-color: var(--color-brand-soft);
|
||||
}
|
||||
}
|
||||
select {
|
||||
width: 100%;
|
||||
border-radius: var(--radius-base);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getEl, disableElementsWhenTrue } from "./utils.js";
|
||||
import { getEl, disableElementsWhenTrue, onSwap } from "./utils.js";
|
||||
|
||||
const RELATED_PURCHASE_URL = "/tracker/purchase/related-purchase-by-game";
|
||||
|
||||
@@ -38,8 +38,9 @@ function setupElementHandlers() {
|
||||
]);
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", setupElementHandlers);
|
||||
document.addEventListener("htmx:afterSwap", setupElementHandlers);
|
||||
getEl("#id_type").addEventListener("change", () => {
|
||||
onSwap("#id_type", (typeSelect) => {
|
||||
setupElementHandlers();
|
||||
typeSelect.addEventListener("change", () => {
|
||||
setupElementHandlers();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { toISOUTCString } from "./utils.js";
|
||||
|
||||
for (let button of document.querySelectorAll("[data-target]")) {
|
||||
let target = button.getAttribute("data-target");
|
||||
let type = button.getAttribute("data-type");
|
||||
let targetElement = document.querySelector(`#id_${target}`);
|
||||
button.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
if (type == "now") {
|
||||
targetElement.value = toISOUTCString(new Date());
|
||||
} else if (type == "copy") {
|
||||
const oppositeName =
|
||||
targetElement.name == "timestamp_start"
|
||||
? "timestamp_end"
|
||||
: "timestamp_start";
|
||||
document.querySelector(`[name='${oppositeName}']`).value =
|
||||
targetElement.value;
|
||||
} else if (type == "toggle") {
|
||||
if (targetElement.type == "datetime-local") targetElement.type = "text";
|
||||
else targetElement.type = "datetime-local";
|
||||
}
|
||||
});
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
(()=>{function x(n){n.directive("mask",(e,{value:l,expression:r},{effect:s,evaluateLater:i,cleanup:u})=>{let p=()=>r,f="";queueMicrotask(()=>{if(["function","dynamic"].includes(l)){let o=i(r);s(()=>{p=t=>{let c;return n.dontAutoEvaluateFunctions(()=>{o(d=>{c=typeof d=="function"?d(t):d},{scope:{$input:t,$money:M.bind({el:e})}})}),c},a(e,!1)})}else a(e,!1);if(e._x_model){e._x_model.get()!==e.value&&(e._x_model.get()===null&&e.value===""||e._x_model.set(e.value));let o=e._x_forceModelUpdate;e._x_forceModelUpdate=t=>{t=String(t);let c=p(t);c&&c!=="false"&&(t=m(c,t)),f=t,o(t),e._x_model.set(t)}}});let g=new AbortController;u(()=>{g.abort()}),e.addEventListener("input",()=>a(e),{signal:g.signal,capture:!0}),e.addEventListener("blur",()=>a(e,!1),{signal:g.signal});function a(o,t=!0){let c=o.value,d=p(c);if(!d||d==="false")return!1;if(f.length-o.value.length===1)return f=o.value;let h=()=>{f=o.value=m(d,c)};t?v(o,d,()=>{h()}):h()}}).before("model")}function v(n,e,l){let r=n.selectionStart,s=n.value;l();let i=s.slice(0,r),u=m(e,i).length;n.setSelectionRange(u,u)}var _={9:/[0-9]/,a:/[a-zA-Z]/,"*":/[a-zA-Z0-9]/};function m(n,e){let l=0,r=0,s="";for(;l<n.length&&r<e.length;){let i=n[l],u=e[r];i in _?(_[i].test(u)&&(s+=u,l++),r++):(s+=i,l++,i===e[r]&&r++)}return s}function M(n,e=".",l,r=2){if(n==="-")return"-";if(/^\D+$/.test(n))return"9";l==null&&(l=e===","?".":",");let s=(f,g)=>{let a="",o=0;for(let t=f.length-1;t>=0;t--)f[t]!==g&&(o===3?(a=f[t]+g+a,o=0):a=f[t]+a,o++);return a},i=n.startsWith("-")?"-":"",u=n.replaceAll(new RegExp(`[^0-9\\${e}]`,"g"),""),p=Array.from({length:u.split(e)[0].length}).fill("9").join("");return p=`${i}${s(p,l)}`,r>0&&n.includes(e)&&(p+=`${e}`+"9".repeat(r)),queueMicrotask(()=>{this.el.value.endsWith(e)||this.el.value[this.el.selectionStart-1]===e&&this.el.setSelectionRange(this.el.selectionStart-1,this.el.selectionStart-1)}),p}document.addEventListener("alpine:init",()=>{window.Alpine.plugin(x)});})();
|
||||
Vendored
+5
File diff suppressed because one or more lines are too long
@@ -0,0 +1,530 @@
|
||||
/**
|
||||
* DateRangePicker — vanilla JavaScript implementation.
|
||||
*
|
||||
* Drives the DateRangePicker component (common/components/date_range_picker.py):
|
||||
*
|
||||
* - DateRangeField: segmented manual entry. Each date part (DD/MM/YYYY) is its
|
||||
* own input; digits fill the placeholder from the right (YYYY → YYY1 → YY19
|
||||
* → Y198 → 1987), full parts auto-advance to the next one, and
|
||||
* Backspace/Delete reverts the active part to its placeholder.
|
||||
* - DateRangeCalendar: popup month grid with a preset column and a
|
||||
* Cancel / Clear / Select footer. Picking works anchor-style: the first
|
||||
* pick becomes the StartDate anchor, the second pick sets the EndDate and
|
||||
* moves the anchor there so further picks adjust the StartDate. Picking on
|
||||
* the wrong side of the anchor clears the range and restarts from the
|
||||
* clicked date.
|
||||
*
|
||||
* The committed value lives in the two hidden ISO inputs ({prefix}-min /
|
||||
* {prefix}-max) that filter_bar.js serializes into a DateCriterion.
|
||||
*
|
||||
* NB: class strings below are emitted verbatim so the Tailwind scanner picks
|
||||
* them up — keep them as plain literals.
|
||||
*/
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var WEEKDAY_LABELS = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"];
|
||||
|
||||
var WEEKDAY_CLASS =
|
||||
"w-8 h-6 flex items-center justify-center text-xs text-body select-none";
|
||||
var DAY_BASE_CLASS =
|
||||
"date-range-day w-8 h-8 flex items-center justify-center text-sm " +
|
||||
"text-heading cursor-pointer hover:bg-neutral-tertiary-medium";
|
||||
var DAY_ROUNDED_CLASS = "rounded-base";
|
||||
var DAY_OUTSIDE_MONTH_CLASS = "opacity-40";
|
||||
var DAY_SELECTED_CLASS = "bg-brand text-white hover:bg-brand-strong";
|
||||
var DAY_ANCHOR_CLASS =
|
||||
"bg-brand text-white ring-2 ring-inset ring-brand-strong hover:bg-brand-strong";
|
||||
// The three visual states of the date range track (the days between the
|
||||
// two endpoints): outlined while picking the second date, filled once both
|
||||
// are picked, muted when showing an already-committed range read-only.
|
||||
var TRACK_OUTLINED_CLASS = "border-y border-brand/70 bg-brand/10";
|
||||
var TRACK_FILLED_CLASS = "bg-brand/30";
|
||||
var TRACK_MUTED_CLASS = "bg-brand/15";
|
||||
|
||||
// ── Date helpers (all local-time; values are ISO YYYY-MM-DD strings) ──
|
||||
|
||||
function padNumber(value, width) {
|
||||
var text = String(value);
|
||||
while (text.length < width) text = "0" + text;
|
||||
return text;
|
||||
}
|
||||
|
||||
function isoFromDate(dateObject) {
|
||||
return (
|
||||
padNumber(dateObject.getFullYear(), 4) +
|
||||
"-" +
|
||||
padNumber(dateObject.getMonth() + 1, 2) +
|
||||
"-" +
|
||||
padNumber(dateObject.getDate(), 2)
|
||||
);
|
||||
}
|
||||
|
||||
function dateFromIso(isoString) {
|
||||
var pieces = isoString.split("-");
|
||||
return new Date(
|
||||
parseInt(pieces[0], 10),
|
||||
parseInt(pieces[1], 10) - 1,
|
||||
parseInt(pieces[2], 10)
|
||||
);
|
||||
}
|
||||
|
||||
function addDays(dateObject, dayCount) {
|
||||
var copy = new Date(dateObject.getTime());
|
||||
copy.setDate(copy.getDate() + dayCount);
|
||||
return copy;
|
||||
}
|
||||
|
||||
/** Validate a (year, month, day) triple as a real calendar date. */
|
||||
function isoFromParts(year, month, day) {
|
||||
var candidate = new Date(year, month - 1, day);
|
||||
if (
|
||||
candidate.getFullYear() !== year ||
|
||||
candidate.getMonth() !== month - 1 ||
|
||||
candidate.getDate() !== day
|
||||
) {
|
||||
return "";
|
||||
}
|
||||
return isoFromDate(candidate);
|
||||
}
|
||||
|
||||
function presetRange(presetName) {
|
||||
var today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
var yesterday = addDays(today, -1);
|
||||
var year = today.getFullYear();
|
||||
var month = today.getMonth();
|
||||
switch (presetName) {
|
||||
case "today":
|
||||
return [today, today];
|
||||
case "yesterday":
|
||||
return [yesterday, yesterday];
|
||||
case "last_7_days":
|
||||
return [addDays(today, -6), today];
|
||||
case "last_30_days":
|
||||
return [addDays(today, -29), today];
|
||||
case "this_month":
|
||||
return [new Date(year, month, 1), new Date(year, month + 1, 0)];
|
||||
case "last_month":
|
||||
return [new Date(year, month - 1, 1), new Date(year, month, 0)];
|
||||
case "this_year":
|
||||
return [new Date(year, 0, 1), new Date(year, 11, 31)];
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── DateRangeField: segmented manual entry ──────────────────────────────
|
||||
|
||||
function segmentBuffer(segment) {
|
||||
return segment.dataset.typedDigits || "";
|
||||
}
|
||||
|
||||
function setSegmentBuffer(segment, buffer) {
|
||||
segment.dataset.typedDigits = buffer;
|
||||
if (buffer === "") {
|
||||
segment.value = "";
|
||||
return;
|
||||
}
|
||||
var placeholder = segment.getAttribute("placeholder");
|
||||
// Fill the placeholder from the right: typing 19 into YYYY shows YY19.
|
||||
segment.value = placeholder.slice(0, placeholder.length - buffer.length) + buffer;
|
||||
}
|
||||
|
||||
function segmentsForSide(picker, side) {
|
||||
return Array.prototype.slice.call(
|
||||
picker.querySelectorAll('input[data-date-side="' + side + '"]')
|
||||
);
|
||||
}
|
||||
|
||||
/** Recompute one hidden ISO input from its side's segment buffers. */
|
||||
function syncHiddenFromSegments(picker, side) {
|
||||
var hidden = picker.querySelector(
|
||||
'input[data-date-range-hidden="' + side + '"]'
|
||||
);
|
||||
var partValues = {};
|
||||
var complete = true;
|
||||
segmentsForSide(picker, side).forEach(function (segment) {
|
||||
var buffer = segmentBuffer(segment);
|
||||
if (buffer.length !== parseInt(segment.getAttribute("maxlength"), 10)) {
|
||||
complete = false;
|
||||
}
|
||||
partValues[segment.dataset.datePart] = buffer;
|
||||
});
|
||||
var previousValue = hidden.value;
|
||||
if (complete) {
|
||||
hidden.value = isoFromParts(
|
||||
parseInt(partValues.year, 10),
|
||||
parseInt(partValues.month, 10),
|
||||
parseInt(partValues.day, 10)
|
||||
);
|
||||
} else {
|
||||
hidden.value = "";
|
||||
}
|
||||
return hidden.value !== previousValue;
|
||||
}
|
||||
|
||||
/** Push an ISO value (or "") into a side's segments and hidden input. */
|
||||
function setSideValue(picker, side, isoString) {
|
||||
var hidden = picker.querySelector(
|
||||
'input[data-date-range-hidden="' + side + '"]'
|
||||
);
|
||||
hidden.value = isoString;
|
||||
var partValues = { year: "", month: "", day: "" };
|
||||
if (isoString) {
|
||||
var pieces = isoString.split("-");
|
||||
partValues = { year: pieces[0], month: pieces[1], day: pieces[2] };
|
||||
}
|
||||
segmentsForSide(picker, side).forEach(function (segment) {
|
||||
setSegmentBuffer(segment, partValues[segment.dataset.datePart]);
|
||||
});
|
||||
}
|
||||
|
||||
function initField(picker, calendarState) {
|
||||
var field = picker.querySelector("[data-date-range-field]");
|
||||
var segments = Array.prototype.slice.call(
|
||||
picker.querySelectorAll("input[data-date-part]")
|
||||
);
|
||||
|
||||
// Adopt server-rendered values (prefilled filter) as typed buffers.
|
||||
segments.forEach(function (segment) {
|
||||
if (segment.value) setSegmentBuffer(segment, segment.value);
|
||||
});
|
||||
|
||||
// Clicking anywhere in the container that is not a date part activates
|
||||
// the first date part.
|
||||
field.addEventListener("mousedown", function (event) {
|
||||
if (event.target.closest("input[data-date-part]")) return;
|
||||
if (event.target.closest("[data-date-range-calendar-toggle]")) return;
|
||||
event.preventDefault();
|
||||
segments[0].focus();
|
||||
});
|
||||
|
||||
segments.forEach(function (segment, segmentIndex) {
|
||||
segment.addEventListener("keydown", function (event) {
|
||||
if (event.key === "Tab") return; // native Tab / Shift+Tab navigation
|
||||
if (event.key === "Enter") return; // let the filter form submit
|
||||
if (event.key === "Backspace" || event.key === "Delete") {
|
||||
event.preventDefault();
|
||||
setSegmentBuffer(segment, "");
|
||||
syncHiddenFromSegments(picker, segment.dataset.dateSide);
|
||||
return;
|
||||
}
|
||||
if (event.ctrlKey || event.metaKey || event.altKey) return;
|
||||
event.preventDefault();
|
||||
if (!/^[0-9]$/.test(event.key)) return; // only numbers can be typed
|
||||
var maximumLength = parseInt(segment.getAttribute("maxlength"), 10);
|
||||
var buffer = segmentBuffer(segment);
|
||||
// Typing into an already-full part starts it over.
|
||||
buffer = buffer.length >= maximumLength ? event.key : buffer + event.key;
|
||||
setSegmentBuffer(segment, buffer);
|
||||
syncHiddenFromSegments(picker, segment.dataset.dateSide);
|
||||
if (buffer.length === maximumLength && segmentIndex + 1 < segments.length) {
|
||||
segments[segmentIndex + 1].focus();
|
||||
}
|
||||
});
|
||||
// Swallow any input that bypassed keydown (e.g. IME/paste).
|
||||
segment.addEventListener("input", function () {
|
||||
setSegmentBuffer(segment, segmentBuffer(segment));
|
||||
});
|
||||
segment.addEventListener("focus", function () {
|
||||
if (calendarState) calendarState.refreshFromField();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── DateRangeCalendar: popup month grid ────────────────────────────────
|
||||
|
||||
function createCalendarState(picker) {
|
||||
var popup = picker.querySelector("[data-date-range-calendar]");
|
||||
var grid = popup.querySelector("[data-date-range-grid]");
|
||||
var monthLabel = popup.querySelector("[data-date-range-month-label]");
|
||||
|
||||
var today = new Date();
|
||||
var state = {
|
||||
open: false,
|
||||
viewYear: today.getFullYear(),
|
||||
viewMonth: today.getMonth(),
|
||||
startIso: "",
|
||||
endIso: "",
|
||||
// The anchor is the fixed endpoint: "start" while picking the EndDate,
|
||||
// "end" once the range is complete (further picks move the StartDate).
|
||||
anchor: "",
|
||||
hoverIso: "",
|
||||
// True while showing a committed range the user has not edited yet —
|
||||
// the track renders muted until the first pick.
|
||||
readOnly: false,
|
||||
};
|
||||
|
||||
function hiddenValue(side) {
|
||||
return picker.querySelector(
|
||||
'input[data-date-range-hidden="' + side + '"]'
|
||||
).value;
|
||||
}
|
||||
|
||||
state.refreshFromField = function () {
|
||||
if (state.open) return;
|
||||
state.startIso = hiddenValue("min");
|
||||
state.endIso = hiddenValue("max");
|
||||
};
|
||||
|
||||
function syncSelectionToField() {
|
||||
setSideValue(picker, "min", state.startIso);
|
||||
setSideValue(picker, "max", state.endIso);
|
||||
}
|
||||
|
||||
function openPopup() {
|
||||
state.startIso = hiddenValue("min");
|
||||
state.endIso = hiddenValue("max");
|
||||
state.anchor = state.startIso && state.endIso ? "end" : state.startIso ? "start" : "";
|
||||
state.readOnly = Boolean(state.startIso && state.endIso);
|
||||
state.hoverIso = "";
|
||||
var focusDate = state.startIso ? dateFromIso(state.startIso) : new Date();
|
||||
state.viewYear = focusDate.getFullYear();
|
||||
state.viewMonth = focusDate.getMonth();
|
||||
state.open = true;
|
||||
popup.classList.remove("hidden");
|
||||
render();
|
||||
}
|
||||
|
||||
function closePopup() {
|
||||
state.open = false;
|
||||
state.hoverIso = "";
|
||||
popup.classList.add("hidden");
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
state.startIso = "";
|
||||
state.endIso = "";
|
||||
state.anchor = "";
|
||||
state.hoverIso = "";
|
||||
state.readOnly = false;
|
||||
syncSelectionToField();
|
||||
}
|
||||
|
||||
/**
|
||||
* Anchor-style picking:
|
||||
* - no selection: the pick becomes the StartDate anchor
|
||||
* - anchor=start (picking EndDate): a pick on/after the StartDate
|
||||
* completes the range and moves the anchor to the EndDate; a pick
|
||||
* before it clears the range and restarts
|
||||
* - anchor=end (adjusting StartDate): a pick on/before the EndDate
|
||||
* moves the StartDate (extend/shorten); a pick after it clears the
|
||||
* range and restarts from the clicked date
|
||||
*/
|
||||
function pickDate(isoString) {
|
||||
state.readOnly = false;
|
||||
if (!state.startIso) {
|
||||
state.startIso = isoString;
|
||||
state.anchor = "start";
|
||||
} else if (state.anchor === "start" && !state.endIso) {
|
||||
if (isoString >= state.startIso) {
|
||||
state.endIso = isoString;
|
||||
state.anchor = "end";
|
||||
} else {
|
||||
state.startIso = isoString;
|
||||
state.endIso = "";
|
||||
state.anchor = "start";
|
||||
}
|
||||
} else {
|
||||
if (isoString <= state.endIso) {
|
||||
state.startIso = isoString;
|
||||
} else {
|
||||
state.startIso = isoString;
|
||||
state.endIso = "";
|
||||
state.anchor = "start";
|
||||
}
|
||||
}
|
||||
syncSelectionToField();
|
||||
render();
|
||||
}
|
||||
|
||||
function applyPreset(presetName) {
|
||||
var range = presetRange(presetName);
|
||||
if (!range) return;
|
||||
state.startIso = isoFromDate(range[0]);
|
||||
state.endIso = isoFromDate(range[1]);
|
||||
state.anchor = "end";
|
||||
state.readOnly = false;
|
||||
state.viewYear = range[0].getFullYear();
|
||||
state.viewMonth = range[0].getMonth();
|
||||
syncSelectionToField();
|
||||
render();
|
||||
}
|
||||
|
||||
/** The (inclusive-exclusive of endpoints) track between the two range
|
||||
* ends; while picking the second date the hovered day acts as the
|
||||
* provisional other end. */
|
||||
function trackBounds() {
|
||||
if (state.startIso && state.endIso) {
|
||||
return [state.startIso, state.endIso, state.readOnly ? TRACK_MUTED_CLASS : TRACK_FILLED_CLASS];
|
||||
}
|
||||
if (state.startIso && state.hoverIso && state.hoverIso !== state.startIso) {
|
||||
var lower = state.hoverIso < state.startIso ? state.hoverIso : state.startIso;
|
||||
var upper = state.hoverIso < state.startIso ? state.startIso : state.hoverIso;
|
||||
return [lower, upper, TRACK_OUTLINED_CLASS];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function dayCellClass(isoString, inViewMonth) {
|
||||
var classes = [DAY_BASE_CLASS];
|
||||
var isStart = isoString === state.startIso;
|
||||
var isEnd = isoString === state.endIso;
|
||||
var isAnchor =
|
||||
(state.anchor === "start" && isStart) || (state.anchor === "end" && isEnd);
|
||||
var track = trackBounds();
|
||||
var inTrack = track && isoString > track[0] && isoString < track[1];
|
||||
if (inTrack) {
|
||||
classes.push(track[2]);
|
||||
} else {
|
||||
classes.push(DAY_ROUNDED_CLASS);
|
||||
}
|
||||
if (isAnchor && !state.readOnly) {
|
||||
classes.push(DAY_ANCHOR_CLASS);
|
||||
} else if (isStart || isEnd) {
|
||||
classes.push(DAY_SELECTED_CLASS);
|
||||
} else if (!inViewMonth) {
|
||||
classes.push(DAY_OUTSIDE_MONTH_CLASS);
|
||||
}
|
||||
return classes.join(" ");
|
||||
}
|
||||
|
||||
function render() {
|
||||
monthLabel.textContent = new Date(
|
||||
state.viewYear,
|
||||
state.viewMonth,
|
||||
1
|
||||
).toLocaleDateString(undefined, { month: "long", year: "numeric" });
|
||||
|
||||
grid.textContent = "";
|
||||
WEEKDAY_LABELS.forEach(function (weekdayLabel) {
|
||||
var headerCell = document.createElement("span");
|
||||
headerCell.className = WEEKDAY_CLASS;
|
||||
headerCell.textContent = weekdayLabel;
|
||||
grid.appendChild(headerCell);
|
||||
});
|
||||
|
||||
var firstOfMonth = new Date(state.viewYear, state.viewMonth, 1);
|
||||
// Monday-first offset of the leading overflow days.
|
||||
var leadingDays = (firstOfMonth.getDay() + 6) % 7;
|
||||
var cellDate = addDays(firstOfMonth, -leadingDays);
|
||||
for (var cellIndex = 0; cellIndex < 42; cellIndex++) {
|
||||
var isoString = isoFromDate(cellDate);
|
||||
var dayButton = document.createElement("button");
|
||||
dayButton.type = "button";
|
||||
dayButton.setAttribute("data-date", isoString);
|
||||
dayButton.className = dayCellClass(
|
||||
isoString,
|
||||
cellDate.getMonth() === state.viewMonth
|
||||
);
|
||||
dayButton.textContent = String(cellDate.getDate());
|
||||
grid.appendChild(dayButton);
|
||||
cellDate = addDays(cellDate, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Wiring ──
|
||||
picker
|
||||
.querySelector("[data-date-range-calendar-toggle]")
|
||||
.addEventListener("click", function () {
|
||||
if (state.open) closePopup();
|
||||
else openPopup();
|
||||
});
|
||||
|
||||
grid.addEventListener("click", function (event) {
|
||||
var dayButton = event.target.closest("button[data-date]");
|
||||
if (dayButton) pickDate(dayButton.getAttribute("data-date"));
|
||||
});
|
||||
|
||||
grid.addEventListener("mouseover", function (event) {
|
||||
if (!state.startIso || state.endIso) return;
|
||||
var dayButton = event.target.closest("button[data-date]");
|
||||
if (!dayButton) return;
|
||||
var hoveredIso = dayButton.getAttribute("data-date");
|
||||
if (hoveredIso === state.hoverIso) return;
|
||||
state.hoverIso = hoveredIso;
|
||||
render();
|
||||
});
|
||||
|
||||
popup
|
||||
.querySelector("[data-date-range-prev]")
|
||||
.addEventListener("click", function () {
|
||||
state.viewMonth -= 1;
|
||||
if (state.viewMonth < 0) {
|
||||
state.viewMonth = 11;
|
||||
state.viewYear -= 1;
|
||||
}
|
||||
render();
|
||||
});
|
||||
|
||||
popup
|
||||
.querySelector("[data-date-range-next]")
|
||||
.addEventListener("click", function () {
|
||||
state.viewMonth += 1;
|
||||
if (state.viewMonth > 11) {
|
||||
state.viewMonth = 0;
|
||||
state.viewYear += 1;
|
||||
}
|
||||
render();
|
||||
});
|
||||
|
||||
popup.querySelectorAll("[data-date-range-preset]").forEach(function (button) {
|
||||
button.addEventListener("click", function () {
|
||||
applyPreset(button.getAttribute("data-date-range-preset"));
|
||||
});
|
||||
});
|
||||
|
||||
// Cancel: close the popup and clear the selected dates.
|
||||
popup
|
||||
.querySelector("[data-date-range-cancel]")
|
||||
.addEventListener("click", function () {
|
||||
clearSelection();
|
||||
closePopup();
|
||||
});
|
||||
|
||||
// Clear: clear the selected dates but keep the popup open.
|
||||
popup
|
||||
.querySelector("[data-date-range-clear]")
|
||||
.addEventListener("click", function () {
|
||||
clearSelection();
|
||||
render();
|
||||
});
|
||||
|
||||
// Select: close the popup, keeping the selected dates.
|
||||
popup
|
||||
.querySelector("[data-date-range-select]")
|
||||
.addEventListener("click", function () {
|
||||
closePopup();
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", function (event) {
|
||||
if (event.key === "Escape" && state.open) closePopup();
|
||||
});
|
||||
|
||||
document.addEventListener("mousedown", function (event) {
|
||||
if (state.open && !picker.contains(event.target)) closePopup();
|
||||
});
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
function initPicker(picker) {
|
||||
if (picker.dataset.dateRangePickerInitialized) return;
|
||||
picker.dataset.dateRangePickerInitialized = "true";
|
||||
var calendarState = createCalendarState(picker);
|
||||
initField(picker, calendarState);
|
||||
}
|
||||
|
||||
function initAllPickers() {
|
||||
document.querySelectorAll("[data-date-range-picker]").forEach(initPicker);
|
||||
}
|
||||
|
||||
window.initDateRangePickers = initAllPickers;
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", initAllPickers);
|
||||
} else {
|
||||
initAllPickers();
|
||||
}
|
||||
})();
|
||||
File diff suppressed because it is too large
Load Diff
+206
-81
@@ -4,6 +4,8 @@
|
||||
* Handles form submission, preset loading/saving, and preset list rendering.
|
||||
* No HTMX — plain fetch() and window.location for all interactions.
|
||||
*/
|
||||
import { onSwap } from "./utils.js";
|
||||
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
@@ -30,6 +32,24 @@
|
||||
return isNaN(val) ? "" : val;
|
||||
}
|
||||
|
||||
/** Read a raw <input> value as string, or "" if not found. */
|
||||
function stringValue(form, name) {
|
||||
var el = form.querySelector('[name="' + name + '"]');
|
||||
return el ? el.value : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a range criterion ({value, value2?, modifier}) from a (min, max)
|
||||
* pair, or null if both bounds are empty. Shared by the numeric-range and
|
||||
* date-range serializers.
|
||||
*/
|
||||
function buildRangeCriterion(vMin, vMax) {
|
||||
if (vMin !== "" && vMax !== "") return criterion(vMin, vMax, "BETWEEN");
|
||||
if (vMin !== "") return criterion(vMin, null, "GREATER_THAN");
|
||||
if (vMax !== "") return criterion(vMax, null, "LESS_THAN");
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Read all checked checkboxes with a given name, returning an array of ints. */
|
||||
function checkedValues(form, name) {
|
||||
var els = form.querySelectorAll('[name="' + name + '"]:checked');
|
||||
@@ -47,11 +67,6 @@
|
||||
*/
|
||||
function buildFilterJSON(form) {
|
||||
var filter = {};
|
||||
var yearMin = numberValue(form, "filter-year-min");
|
||||
var yearMax = numberValue(form, "filter-year-max");
|
||||
var playMin = numberValue(form, "filter-playtime-min");
|
||||
var playMax = numberValue(form, "filter-playtime-max");
|
||||
var mastered = form.querySelector('[name="filter-mastered"]');
|
||||
|
||||
// ── Search field ──
|
||||
var searchInput = form.querySelector('[name="filter-search"]');
|
||||
@@ -87,62 +102,100 @@
|
||||
}
|
||||
});
|
||||
|
||||
// ── Session-specific fields ──
|
||||
var pageIsSessions =
|
||||
!!form.querySelector('[data-search-select][data-search-select-mode="filter"][data-name="game"]');
|
||||
// 1. Text Fields
|
||||
var textFields = [
|
||||
{ name: "filter-price_currency", key: "price_currency" },
|
||||
{ name: "filter-converted_currency", key: "converted_currency" },
|
||||
{ name: "filter-name", key: "name" },
|
||||
{ name: "filter-group", key: "group" },
|
||||
{ name: "filter-playevent_note", key: "playevent_note" },
|
||||
{ name: "filter-note", key: "note" }
|
||||
];
|
||||
textFields.forEach(function (tf) {
|
||||
var modifierEl = form.querySelector('[name="' + tf.name + '-modifier"]:checked');
|
||||
var modifier = modifierEl ? modifierEl.value : "EQUALS";
|
||||
|
||||
// Emulated checkbox (sessions page)
|
||||
var emulated = form.querySelector('[name="filter-emulated"]');
|
||||
if (emulated && emulated.checked) {
|
||||
filter.emulated = criterion(true, null, "EQUALS");
|
||||
}
|
||||
|
||||
// Active checkbox (sessions page)
|
||||
var active = form.querySelector('[name="filter-active"]');
|
||||
if (active && active.checked) {
|
||||
filter.is_active = criterion(true, null, "EQUALS");
|
||||
}
|
||||
|
||||
if (yearMin !== "" && yearMax !== "") {
|
||||
filter.year_released = criterion(yearMin, yearMax, "BETWEEN");
|
||||
} else if (yearMin !== "") {
|
||||
filter.year_released = criterion(yearMin, null, "GREATER_THAN");
|
||||
} else if (yearMax !== "") {
|
||||
filter.year_released = criterion(yearMax, null, "LESS_THAN");
|
||||
}
|
||||
|
||||
if (playMin !== "" || playMax !== "") {
|
||||
var pMin = playMin !== "" ? Math.round(playMin * 60) : 0;
|
||||
var pMax = playMax !== "" ? Math.round(playMax * 60) : 0;
|
||||
// Skip if both are 0 — means slider is at default (no real filter)
|
||||
if (pMin === 0 && pMax === 0) {
|
||||
// don't add filter
|
||||
var isPresence = modifier === "IS_NULL" || modifier === "NOT_NULL";
|
||||
if (isPresence) {
|
||||
filter[tf.key] = { modifier: modifier };
|
||||
} else {
|
||||
var durKey = pageIsSessions ? "duration_minutes" : "playtime_minutes";
|
||||
if (playMin !== "" && playMax !== "") {
|
||||
filter[durKey] = criterion(pMin, pMax, "BETWEEN");
|
||||
} else if (playMin !== "") {
|
||||
filter[durKey] = criterion(pMin, null, "GREATER_THAN");
|
||||
} else if (playMax !== "") {
|
||||
filter[durKey] = criterion(pMax, null, "LESS_THAN");
|
||||
var el = form.querySelector('[name="' + tf.name + '"]');
|
||||
if (el && el.value.trim()) {
|
||||
filter[tf.key] = { value: el.value.trim(), modifier: modifier };
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ── Purchase-specific: num_purchases ──
|
||||
var numGamesMin = numberValue(form, "filter-num-purchases-min");
|
||||
var numGamesMax = numberValue(form, "filter-num-purchases-max");
|
||||
if (numGamesMin !== "" && numGamesMax !== "") {
|
||||
filter.num_purchases = criterion(parseInt(numGamesMin, 10), parseInt(numGamesMax, 10), "BETWEEN");
|
||||
} else if (numGamesMin !== "") {
|
||||
filter.num_purchases = criterion(parseInt(numGamesMin, 10), null, "GREATER_THAN");
|
||||
} else if (numGamesMax !== "") {
|
||||
filter.num_purchases = criterion(parseInt(numGamesMax, 10), null, "LESS_THAN");
|
||||
}
|
||||
// 2. Boolean Fields (Radio Button Groups)
|
||||
var booleanFields = [
|
||||
{ name: "filter-mastered", key: "mastered" },
|
||||
{ name: "filter-emulated", key: "emulated" },
|
||||
{ name: "filter-active", key: "is_active" },
|
||||
{ name: "filter-refunded", key: "is_refunded" },
|
||||
{ name: "filter-infinite", key: "infinite" },
|
||||
{ name: "filter-needs-price-update", key: "needs_price_update" },
|
||||
{ name: "filter-purchase-refunded", key: "purchase_refunded" },
|
||||
{ name: "filter-purchase-infinite", key: "purchase_infinite" },
|
||||
{ name: "filter-session-emulated", key: "session_emulated" }
|
||||
];
|
||||
booleanFields.forEach(function (bf) {
|
||||
var el = form.querySelector('[name="' + bf.name + '"]:checked');
|
||||
if (el) {
|
||||
var val = el.value === "true";
|
||||
filter[bf.key] = criterion(val, null, "EQUALS");
|
||||
}
|
||||
});
|
||||
|
||||
if (mastered && mastered.checked) {
|
||||
filter.mastered = criterion(true, null, "EQUALS");
|
||||
}
|
||||
// 3. Range Fields
|
||||
var rangeFields = [
|
||||
{ prefix: "filter-year", key: "year_released" },
|
||||
{ prefix: "filter-original-year", key: "original_year_released" },
|
||||
{ prefix: "filter-session-count", key: "session_count" },
|
||||
{ prefix: "filter-session-average", key: "session_average" },
|
||||
{ prefix: "filter-purchase-count", key: "purchase_count" },
|
||||
{ prefix: "filter-playevent-count", key: "playevent_count" },
|
||||
{ prefix: "filter-duration-total-hours", key: "duration_total_hours" },
|
||||
{ prefix: "filter-duration-manual-hours", key: "duration_manual_hours" },
|
||||
{ prefix: "filter-duration-calculated-hours", key: "duration_calculated_hours" },
|
||||
{ prefix: "filter-manual-playtime-hours", key: "manual_playtime_hours" },
|
||||
{ prefix: "filter-calculated-playtime-hours", key: "calculated_playtime_hours" },
|
||||
{ prefix: "filter-num-purchases", key: "num_purchases" },
|
||||
{ prefix: "filter-price", key: "price" },
|
||||
{ prefix: "filter-purchase-price-total", key: "purchase_price_total" },
|
||||
{ prefix: "filter-purchase-price-any", key: "purchase_price_any" },
|
||||
{ prefix: "filter-days-to-finish", key: "days_to_finish" },
|
||||
{ prefix: "filter-playtime-hours", key: "playtime_hours", ignoreZeroZero: true }
|
||||
];
|
||||
|
||||
rangeFields.forEach(function (rf) {
|
||||
var vMin = numberValue(form, rf.prefix + "-min");
|
||||
var vMax = numberValue(form, rf.prefix + "-max");
|
||||
|
||||
if (rf.convert) {
|
||||
if (vMin !== "") vMin = rf.convert(vMin);
|
||||
if (vMax !== "") vMax = rf.convert(vMax);
|
||||
}
|
||||
|
||||
if (rf.ignoreZeroZero && vMin === 0 && vMax === 0) {
|
||||
return; // both 0 means slider at default
|
||||
}
|
||||
|
||||
var c = buildRangeCriterion(vMin, vMax);
|
||||
if (c !== null) filter[rf.key] = c;
|
||||
});
|
||||
|
||||
// 4. Date Range Fields — ISO date strings from <input type="date">; no
|
||||
// numeric coercion. Same modifier derivation as numeric ranges.
|
||||
var dateRangeFields = [
|
||||
{ prefix: "filter-date-purchased", key: "date_purchased" },
|
||||
{ prefix: "filter-date-refunded", key: "date_refunded" },
|
||||
];
|
||||
dateRangeFields.forEach(function (df) {
|
||||
var vMin = stringValue(form, df.prefix + "-min");
|
||||
var vMax = stringValue(form, df.prefix + "-max");
|
||||
var c = buildRangeCriterion(vMin, vMax);
|
||||
if (c !== null) filter[df.key] = c;
|
||||
});
|
||||
|
||||
return filter;
|
||||
}
|
||||
@@ -196,10 +249,19 @@
|
||||
if (!url) return;
|
||||
|
||||
var mode = "games";
|
||||
if (window.location.pathname.indexOf("session") !== -1) mode = "sessions";
|
||||
else if (window.location.pathname.indexOf("purchase") !== -1) mode = "purchases";
|
||||
var path = window.location.pathname;
|
||||
if (path.indexOf("session") !== -1) mode = "sessions";
|
||||
else if (path.indexOf("purchase") !== -1) mode = "purchases";
|
||||
else if (path.indexOf("device") !== -1) mode = "devices";
|
||||
else if (path.indexOf("platform") !== -1) mode = "platforms";
|
||||
else if (path.indexOf("playevent") !== -1) mode = "playevents";
|
||||
|
||||
fetch(url + "?mode=" + mode, { credentials: "same-origin" })
|
||||
var query = "";
|
||||
if (url.indexOf("mode=") === -1) {
|
||||
query = (url.indexOf("?") !== -1 ? "&" : "?") + "mode=" + mode;
|
||||
}
|
||||
|
||||
fetch(url + query, { credentials: "same-origin" })
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error("Failed to load presets");
|
||||
return r.text();
|
||||
@@ -250,6 +312,27 @@
|
||||
});
|
||||
}
|
||||
|
||||
/** Enable/disable the input text box depending on selected string modifier. */
|
||||
window.toggleStringFilterInput = function (radio) {
|
||||
var container = radio.closest(".flex-col");
|
||||
if (!container) return;
|
||||
var textInput = container.querySelector('input[type="text"]');
|
||||
if (!textInput) return;
|
||||
|
||||
// Find the currently checked radio in the container
|
||||
var checkedRadio = container.querySelector('input[type="radio"]:checked');
|
||||
var val = checkedRadio ? checkedRadio.value : "";
|
||||
|
||||
if (val === "IS_NULL" || val === "NOT_NULL") {
|
||||
textInput.disabled = true;
|
||||
textInput.value = "";
|
||||
textInput.classList.add("opacity-50", "cursor-not-allowed");
|
||||
} else {
|
||||
textInput.disabled = false;
|
||||
textInput.classList.remove("opacity-50", "cursor-not-allowed");
|
||||
}
|
||||
};
|
||||
|
||||
/** Show the preset name input field and the confirm button. */
|
||||
window.showPresetNameInput = function () {
|
||||
var input = document.getElementById("preset-name-input");
|
||||
@@ -277,8 +360,12 @@
|
||||
var body = new URLSearchParams();
|
||||
body.append("name", name);
|
||||
var mode = "games";
|
||||
if (window.location.pathname.indexOf("session") !== -1) mode = "sessions";
|
||||
else if (window.location.pathname.indexOf("purchase") !== -1) mode = "purchases";
|
||||
var path = window.location.pathname;
|
||||
if (path.indexOf("session") !== -1) mode = "sessions";
|
||||
else if (path.indexOf("purchase") !== -1) mode = "purchases";
|
||||
else if (path.indexOf("device") !== -1) mode = "devices";
|
||||
else if (path.indexOf("platform") !== -1) mode = "platforms";
|
||||
else if (path.indexOf("playevent") !== -1) mode = "playevents";
|
||||
body.append("mode", mode);
|
||||
body.append("filter", JSON.stringify(filterObj));
|
||||
|
||||
@@ -325,30 +412,68 @@
|
||||
|
||||
// ── Init on page load ───────────────────────────────────────────────────
|
||||
|
||||
// ── Inject search inputs into filter forms ──
|
||||
function injectSearchInputs() {
|
||||
document.querySelectorAll('[id^="filter-bar-form"]').forEach(function (form) {
|
||||
if (form.querySelector('[name="filter-search"]')) return; // already added
|
||||
var input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.name = "filter-search";
|
||||
input.placeholder = "Search\u2026";
|
||||
input.className = "block w-full rounded-base border border-default-medium bg-neutral-secondary-medium text-sm text-heading p-2 mb-4 focus:ring-brand focus:border-brand";
|
||||
// Pre-fill from existing filter JSON
|
||||
var hidden = form.querySelector('[name="filter"]');
|
||||
if (hidden && hidden.parentNode) {
|
||||
try {
|
||||
var existing = JSON.parse(hidden.value || "{}");
|
||||
if (existing.search && existing.search.value) {
|
||||
input.value = existing.search.value;
|
||||
}
|
||||
} catch (e) {}
|
||||
hidden.parentNode.insertBefore(input, hidden.nextSibling);
|
||||
// ── Inject the search input into a filter form ──
|
||||
function injectSearchInput(form) {
|
||||
if (form.querySelector('[name="filter-search"]')) return; // already added
|
||||
var input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.name = "filter-search";
|
||||
input.placeholder = "Search\u2026";
|
||||
input.className = "block w-full rounded-base border border-default-medium bg-neutral-secondary-medium text-sm text-heading p-2 mb-4 focus:ring-brand focus:border-brand";
|
||||
// Pre-fill from existing filter JSON
|
||||
var hidden = form.querySelector('[name="filter"]');
|
||||
if (hidden && hidden.parentNode) {
|
||||
try {
|
||||
var existing = JSON.parse(hidden.value || "{}");
|
||||
if (existing.search && existing.search.value) {
|
||||
input.value = existing.search.value;
|
||||
}
|
||||
} catch (e) {}
|
||||
hidden.parentNode.insertBefore(input, hidden.nextSibling);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable deselect-on-click behavior for filter radio buttons.
|
||||
*/
|
||||
function setupDeselectableRadios() {
|
||||
document.querySelectorAll('input[type="radio"]').forEach(function (radio) {
|
||||
radio.addEventListener('click', function (e) {
|
||||
if (this.wasChecked) {
|
||||
this.checked = false;
|
||||
this.wasChecked = false;
|
||||
this.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
} else {
|
||||
var name = this.getAttribute('name');
|
||||
if (name) {
|
||||
document.querySelectorAll('input[type="radio"][name="' + name + '"]').forEach(function (r) {
|
||||
r.wasChecked = false;
|
||||
});
|
||||
}
|
||||
this.wasChecked = true;
|
||||
}
|
||||
});
|
||||
if (radio.checked) {
|
||||
radio.wasChecked = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
injectSearchInputs();
|
||||
|
||||
/**
|
||||
* Set up event listeners for string modifier radio buttons.
|
||||
*/
|
||||
function setupStringFilters() {
|
||||
document.querySelectorAll('input[data-string-modifier-radio]').forEach(function (radio) {
|
||||
radio.addEventListener('change', function () {
|
||||
window.toggleStringFilterInput(this);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
onSwap('[id^="filter-bar-form"]', function (form) {
|
||||
injectSearchInput(form);
|
||||
setupDeselectableRadios();
|
||||
setupStringFilters();
|
||||
loadPresets();
|
||||
});
|
||||
})();
|
||||
|
||||
Vendored
+2
File diff suppressed because one or more lines are too long
+194
-160
@@ -8,189 +8,223 @@
|
||||
* Handles track-fill positioning and sync between handles and the connected
|
||||
* number inputs (linked via data-target attributes).
|
||||
*/
|
||||
import { onSwap } from "./utils.js";
|
||||
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
function initAll(force) {
|
||||
document.querySelectorAll(".range-slider").forEach(function (slider) {
|
||||
if (force) slider._rsInit = false;
|
||||
if (slider._rsInit) return;
|
||||
slider._rsInit = true;
|
||||
function initializeSlider(slider) {
|
||||
var mode = slider.getAttribute("data-mode") || "range";
|
||||
var trackFill = slider.querySelector(".range-track-fill");
|
||||
var minHandle = slider.querySelector(".range-handle-min");
|
||||
var maxHandle = slider.querySelector(".range-handle-max");
|
||||
if (!minHandle || !maxHandle) return;
|
||||
|
||||
var mode = slider.getAttribute("data-mode") || "range";
|
||||
var trackFill = slider.querySelector(".range-track-fill");
|
||||
var minHandle = slider.querySelector(".range-handle-min");
|
||||
var maxHandle = slider.querySelector(".range-handle-max");
|
||||
if (!minHandle || !maxHandle) return;
|
||||
var minTarget = document.getElementById(
|
||||
minHandle.getAttribute("data-target")
|
||||
);
|
||||
var maxTarget = document.getElementById(
|
||||
maxHandle.getAttribute("data-target")
|
||||
);
|
||||
var dataMin = parseInt(slider.getAttribute("data-min"), 10);
|
||||
var dataMax = parseInt(slider.getAttribute("data-max"), 10);
|
||||
var step = parseInt(slider.getAttribute("data-step"), 10) || 1;
|
||||
|
||||
var minTarget = document.getElementById(
|
||||
minHandle.getAttribute("data-target")
|
||||
);
|
||||
var maxTarget = document.getElementById(
|
||||
maxHandle.getAttribute("data-target")
|
||||
);
|
||||
var dataMin = parseInt(slider.getAttribute("data-min"), 10);
|
||||
var dataMax = parseInt(slider.getAttribute("data-max"), 10);
|
||||
var step = parseInt(slider.getAttribute("data-step"), 10) || 1;
|
||||
// ── Helpers ──
|
||||
|
||||
// ── Helpers ──
|
||||
function valueToPercent(value) {
|
||||
return ((value - dataMin) / (dataMax - dataMin)) * 100;
|
||||
}
|
||||
function percentToValue(percent) {
|
||||
var raw = dataMin + (percent / 100) * (dataMax - dataMin);
|
||||
return Math.round(raw / step) * step;
|
||||
}
|
||||
function clamp(value, lo, hi) {
|
||||
return Math.max(lo, Math.min(hi, value));
|
||||
}
|
||||
|
||||
function valueToPercent(value) {
|
||||
return ((value - dataMin) / (dataMax - dataMin)) * 100;
|
||||
}
|
||||
function percentToValue(percent) {
|
||||
var raw = dataMin + (percent / 100) * (dataMax - dataMin);
|
||||
return Math.round(raw / step) * step;
|
||||
}
|
||||
function clamp(value, lo, hi) {
|
||||
return Math.max(lo, Math.min(hi, value));
|
||||
}
|
||||
function getTargetValue(target, defaultVal) {
|
||||
if (!target || target.value === "") return defaultVal;
|
||||
var parsed = parseInt(target.value, 10);
|
||||
return isNaN(parsed) ? defaultVal : parsed;
|
||||
}
|
||||
function setTargetValue(target, value) {
|
||||
if (target) target.value = value;
|
||||
}
|
||||
|
||||
function getTargetValue(target) {
|
||||
return parseInt(target ? target.value : 0, 10) || dataMin;
|
||||
}
|
||||
function setTargetValue(target, value) {
|
||||
if (target) target.value = value;
|
||||
}
|
||||
// ── Track fill positioning ──
|
||||
|
||||
// ── Track fill positioning ──
|
||||
|
||||
function updateTrackFill() {
|
||||
if (!trackFill) return;
|
||||
var minValue = getTargetValue(minTarget);
|
||||
var maxValue = getTargetValue(maxTarget);
|
||||
if (mode === "point") {
|
||||
trackFill.style.left = "0%";
|
||||
trackFill.style.width = valueToPercent(maxValue) + "%";
|
||||
} else {
|
||||
var leftPct = valueToPercent(minValue);
|
||||
var widthPct = valueToPercent(maxValue) - leftPct;
|
||||
trackFill.style.left = leftPct + "%";
|
||||
trackFill.style.width = widthPct + "%";
|
||||
function updateTrackFill() {
|
||||
if (!trackFill) return;
|
||||
var minVal = clamp(getTargetValue(minTarget, dataMin), dataMin, dataMax);
|
||||
var maxVal = clamp(getTargetValue(maxTarget, dataMax), dataMin, dataMax);
|
||||
if (mode === "point") {
|
||||
trackFill.style.left = "0%";
|
||||
trackFill.style.width = valueToPercent(maxVal) + "%";
|
||||
} else {
|
||||
var leftPct = valueToPercent(minVal);
|
||||
var rightPct = valueToPercent(maxVal);
|
||||
if (leftPct > rightPct) {
|
||||
var tmp = leftPct;
|
||||
leftPct = rightPct;
|
||||
rightPct = tmp;
|
||||
}
|
||||
var widthPct = rightPct - leftPct;
|
||||
trackFill.style.left = leftPct + "%";
|
||||
trackFill.style.width = widthPct + "%";
|
||||
}
|
||||
}
|
||||
|
||||
function updateHandles() {
|
||||
minHandle.style.left = valueToPercent(getTargetValue(minTarget)) + "%";
|
||||
maxHandle.style.left = valueToPercent(getTargetValue(maxTarget)) + "%";
|
||||
updateTrackFill();
|
||||
}
|
||||
function updateHandles() {
|
||||
var minVal = clamp(getTargetValue(minTarget, dataMin), dataMin, dataMax);
|
||||
var maxVal = clamp(getTargetValue(maxTarget, dataMax), dataMin, dataMax);
|
||||
minHandle.style.left = valueToPercent(minVal) + "%";
|
||||
maxHandle.style.left = valueToPercent(maxVal) + "%";
|
||||
updateTrackFill();
|
||||
}
|
||||
|
||||
// ── Dragging ──
|
||||
// ── Dragging ──
|
||||
|
||||
function makeDraggable(handle, isMin) {
|
||||
handle.addEventListener("mousedown", function (e) {
|
||||
e.preventDefault();
|
||||
var rect = slider.getBoundingClientRect();
|
||||
function makeDraggable(handle, isMin) {
|
||||
handle.addEventListener("mousedown", function (e) {
|
||||
e.preventDefault();
|
||||
var rect = slider.getBoundingClientRect();
|
||||
|
||||
function onMove(ev) {
|
||||
var pct = ((ev.clientX - rect.left) / rect.width) * 100;
|
||||
var value = percentToValue(clamp(pct, 0, 100));
|
||||
function onMove(ev) {
|
||||
var pct = ((ev.clientX - rect.left) / rect.width) * 100;
|
||||
var value = percentToValue(clamp(pct, 0, 100));
|
||||
|
||||
if (mode === "point") {
|
||||
setTargetValue(minTarget, value);
|
||||
setTargetValue(maxTarget, value);
|
||||
if (minTarget)
|
||||
minTarget.dispatchEvent(
|
||||
new Event("input", { bubbles: true })
|
||||
);
|
||||
if (maxTarget)
|
||||
maxTarget.dispatchEvent(
|
||||
new Event("input", { bubbles: true })
|
||||
);
|
||||
} else if (isMin) {
|
||||
setTargetValue(
|
||||
minTarget,
|
||||
clamp(value, dataMin, getTargetValue(maxTarget))
|
||||
if (mode === "point") {
|
||||
setTargetValue(minTarget, value);
|
||||
setTargetValue(maxTarget, value);
|
||||
if (minTarget)
|
||||
minTarget.dispatchEvent(
|
||||
new Event("input", { bubbles: true })
|
||||
);
|
||||
if (minTarget)
|
||||
minTarget.dispatchEvent(
|
||||
new Event("input", { bubbles: true })
|
||||
);
|
||||
} else {
|
||||
setTargetValue(
|
||||
maxTarget,
|
||||
clamp(value, getTargetValue(minTarget), dataMax)
|
||||
if (maxTarget)
|
||||
maxTarget.dispatchEvent(
|
||||
new Event("input", { bubbles: true })
|
||||
);
|
||||
} else if (isMin) {
|
||||
setTargetValue(
|
||||
minTarget,
|
||||
clamp(value, dataMin, getTargetValue(maxTarget, dataMax))
|
||||
);
|
||||
if (minTarget)
|
||||
minTarget.dispatchEvent(
|
||||
new Event("input", { bubbles: true })
|
||||
);
|
||||
if (maxTarget)
|
||||
maxTarget.dispatchEvent(
|
||||
new Event("input", { bubbles: true })
|
||||
);
|
||||
}
|
||||
updateHandles();
|
||||
}
|
||||
|
||||
function onUp() {
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
}
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
onMove(e);
|
||||
});
|
||||
}
|
||||
|
||||
makeDraggable(minHandle, true);
|
||||
makeDraggable(maxHandle, false);
|
||||
|
||||
// ── Sync from number inputs back to handles ──
|
||||
|
||||
function syncFromInputs() {
|
||||
if (mode === "point") {
|
||||
var value =
|
||||
getTargetValue(minTarget) || getTargetValue(maxTarget);
|
||||
setTargetValue(minTarget, value);
|
||||
setTargetValue(maxTarget, value);
|
||||
}
|
||||
updateHandles();
|
||||
}
|
||||
if (minTarget)
|
||||
minTarget.addEventListener("input", syncFromInputs);
|
||||
if (maxTarget)
|
||||
maxTarget.addEventListener("input", syncFromInputs);
|
||||
|
||||
// ── Mode toggle ──
|
||||
|
||||
var block = slider.closest(".range-slider-block");
|
||||
var toggleButton =
|
||||
block && block.querySelector(".range-mode-toggle");
|
||||
if (toggleButton) {
|
||||
toggleButton.addEventListener("click", function () {
|
||||
var newMode = mode === "range" ? "point" : "range";
|
||||
slider.setAttribute("data-mode", newMode);
|
||||
|
||||
// Swap toggle icons
|
||||
var iconRange = toggleButton.querySelector(
|
||||
".range-mode-icon-range"
|
||||
);
|
||||
var iconPoint = toggleButton.querySelector(
|
||||
".range-mode-icon-point"
|
||||
);
|
||||
if (iconRange) iconRange.classList.toggle("hidden");
|
||||
if (iconPoint) iconPoint.classList.toggle("hidden");
|
||||
|
||||
var dashSpan = block && block.querySelector(".range-dash");
|
||||
if (newMode === "point") {
|
||||
minHandle.style.display = "none";
|
||||
setTargetValue(minTarget, getTargetValue(maxTarget));
|
||||
if (minTarget) minTarget.classList.add("hidden");
|
||||
if (dashSpan) dashSpan.classList.add("hidden");
|
||||
} else {
|
||||
minHandle.style.display = "";
|
||||
if (minTarget) minTarget.classList.remove("hidden");
|
||||
if (dashSpan) dashSpan.classList.remove("hidden");
|
||||
setTargetValue(
|
||||
maxTarget,
|
||||
clamp(value, getTargetValue(minTarget, dataMin), dataMax)
|
||||
);
|
||||
if (maxTarget)
|
||||
maxTarget.dispatchEvent(
|
||||
new Event("input", { bubbles: true })
|
||||
);
|
||||
}
|
||||
mode = newMode;
|
||||
updateHandles();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Initial position ──
|
||||
function onUp() {
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
}
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
onMove(e);
|
||||
});
|
||||
}
|
||||
|
||||
makeDraggable(minHandle, true);
|
||||
makeDraggable(maxHandle, false);
|
||||
|
||||
// ── Sync from number inputs back to handles ──
|
||||
|
||||
function syncFromInputs(e) {
|
||||
if (mode === "point") {
|
||||
var src = (e && e.target) || minTarget || maxTarget;
|
||||
var val = src ? src.value : "";
|
||||
setTargetValue(minTarget, val);
|
||||
setTargetValue(maxTarget, val);
|
||||
} else if (e && e.target) {
|
||||
var minVal = getTargetValue(minTarget, dataMin);
|
||||
var maxVal = getTargetValue(maxTarget, dataMax);
|
||||
if (e.target === minTarget) {
|
||||
if (minVal > maxVal) {
|
||||
setTargetValue(maxTarget, minVal);
|
||||
}
|
||||
} else if (e.target === maxTarget) {
|
||||
if (maxVal < minVal) {
|
||||
setTargetValue(minTarget, maxVal);
|
||||
}
|
||||
}
|
||||
}
|
||||
updateHandles();
|
||||
});
|
||||
}
|
||||
|
||||
function enforceStrictBounds(e) {
|
||||
if (e && e.target) {
|
||||
var val = parseInt(e.target.value, 10);
|
||||
if (!isNaN(val)) {
|
||||
var clamped = clamp(val, dataMin, dataMax);
|
||||
if (clamped !== val) {
|
||||
setTargetValue(e.target, clamped);
|
||||
e.target.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (minTarget) {
|
||||
minTarget.addEventListener("input", syncFromInputs);
|
||||
minTarget.addEventListener("change", enforceStrictBounds);
|
||||
}
|
||||
if (maxTarget) {
|
||||
maxTarget.addEventListener("input", syncFromInputs);
|
||||
maxTarget.addEventListener("change", enforceStrictBounds);
|
||||
}
|
||||
|
||||
// ── Mode toggle ──
|
||||
|
||||
var block = slider.closest(".range-slider-block");
|
||||
var toggleButton =
|
||||
block && block.querySelector(".range-mode-toggle");
|
||||
if (toggleButton) {
|
||||
toggleButton.addEventListener("click", function () {
|
||||
var newMode = mode === "range" ? "point" : "range";
|
||||
slider.setAttribute("data-mode", newMode);
|
||||
|
||||
// Swap toggle icons
|
||||
var iconRange = toggleButton.querySelector(
|
||||
".range-mode-icon-range"
|
||||
);
|
||||
var iconPoint = toggleButton.querySelector(
|
||||
".range-mode-icon-point"
|
||||
);
|
||||
if (iconRange) iconRange.classList.toggle("hidden");
|
||||
if (iconPoint) iconPoint.classList.toggle("hidden");
|
||||
|
||||
var dashSpan = block && block.querySelector(".range-dash");
|
||||
if (newMode === "point") {
|
||||
minHandle.style.display = "none";
|
||||
setTargetValue(minTarget, maxTarget ? maxTarget.value : "");
|
||||
if (minTarget) minTarget.classList.add("hidden");
|
||||
if (dashSpan) dashSpan.classList.add("hidden");
|
||||
} else {
|
||||
minHandle.style.display = "";
|
||||
if (minTarget) minTarget.classList.remove("hidden");
|
||||
if (dashSpan) dashSpan.classList.remove("hidden");
|
||||
}
|
||||
mode = newMode;
|
||||
updateHandles();
|
||||
});
|
||||
}
|
||||
|
||||
// ── Initial position ──
|
||||
updateHandles();
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", initAll);
|
||||
document.addEventListener("htmx:afterSwap", initAll);
|
||||
window.initRangeSliders = initAll;
|
||||
onSwap(".range-slider", initializeSlider);
|
||||
})();
|
||||
|
||||
+303
-238
@@ -12,8 +12,8 @@
|
||||
* pills. Filter widgets have no hidden inputs; readSearchSelect serialises their
|
||||
* state into data-included / data-excluded / data-modifier for the filter bar.
|
||||
*
|
||||
* initAll() runs on DOMContentLoaded + htmx:afterSwap, each widget guarded with
|
||||
* element._searchSelectInit.
|
||||
* Widgets are initialized via onSwap() (utils.js), which covers the initial
|
||||
* page load and every htmx-swapped fragment, once per widget.
|
||||
*
|
||||
* Dynamically-added rows and pills are cloned from hidden <template> elements
|
||||
* the server renders with the same Python components (Pill / SearchSelect /
|
||||
@@ -21,146 +21,185 @@
|
||||
* and data-* attributes — so all markup and Tailwind class strings live in one
|
||||
* place (the Python components), never duplicated here.
|
||||
*/
|
||||
(function () {
|
||||
import { onSwap } from "./utils.js";
|
||||
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
var DEBOUNCE_MS = 100;
|
||||
const DEBOUNCE_MS = 100;
|
||||
|
||||
// Must match Python common/components/filters.py:_PRESENCE_MODIFIERS.
|
||||
// These modifiers are mutually exclusive with value pills — selecting
|
||||
// one clears all value pills. Non-presence modifiers (INCLUDES_ALL,
|
||||
// INCLUDES_ONLY) coexist with value pills.
|
||||
var PRESENCE_MODIFIERS = ["NOT_NULL", "IS_NULL"];
|
||||
const PRESENCE_MODIFIERS = ["NOT_NULL", "IS_NULL"];
|
||||
|
||||
function initAll() {
|
||||
document.querySelectorAll("[data-search-select]").forEach(function (element) {
|
||||
if (element._searchSelectInit) return;
|
||||
element._searchSelectInit = true;
|
||||
initWidget(element);
|
||||
});
|
||||
}
|
||||
|
||||
function initWidget(container) {
|
||||
var search = container.querySelector("[data-search-select-search]");
|
||||
var options = container.querySelector("[data-search-select-options]");
|
||||
var pills = container.querySelector("[data-search-select-pills]");
|
||||
const initWidget = (container) => {
|
||||
const search = container.querySelector("[data-search-select-search]");
|
||||
const options = container.querySelector("[data-search-select-options]");
|
||||
const pills = container.querySelector("[data-search-select-pills]");
|
||||
if (!search || !options || !pills) return;
|
||||
|
||||
var name = container.getAttribute("data-name");
|
||||
var searchUrl = container.getAttribute("data-search-url");
|
||||
var isFilter = container.getAttribute("data-search-select-mode") === "filter";
|
||||
var multi = container.getAttribute("data-multi") === "true";
|
||||
var alwaysVisible = container.getAttribute("data-always-visible") === "true";
|
||||
var itemsScroll = parseInt(container.getAttribute("data-items-scroll"), 10) || 10;
|
||||
var prefetch = parseInt(container.getAttribute("data-prefetch"), 10) || 0;
|
||||
var syncUrl = container.getAttribute("data-sync-url") === "true";
|
||||
const name = container.getAttribute("data-name");
|
||||
const searchUrl = container.getAttribute("data-search-url");
|
||||
const isFilter = container.getAttribute("data-search-select-mode") === "filter";
|
||||
const freeText = container.getAttribute("data-search-select-free-text") === "true";
|
||||
const multi = container.getAttribute("data-multi") === "true";
|
||||
const alwaysVisible = container.getAttribute("data-always-visible") === "true";
|
||||
const prefetch = parseInt(container.getAttribute("data-prefetch"), 10) || 0;
|
||||
const syncUrl = container.getAttribute("data-sync-url") === "true";
|
||||
|
||||
var noResults = options.querySelector("[data-search-select-no-results]");
|
||||
var debounceTimer = null;
|
||||
var pendingRequest = null; // in-flight AbortController, so newer queries win
|
||||
var hasPrefetched = false;
|
||||
const noResults = options.querySelector("[data-search-select-no-results]");
|
||||
let debounceTimer = null;
|
||||
let pendingRequest = null; // in-flight AbortController, so newer queries win
|
||||
let hasPrefetched = false;
|
||||
|
||||
function showPanel() {
|
||||
options.classList.remove("hidden");
|
||||
}
|
||||
function hidePanel() {
|
||||
const hasVisibleContent = () => {
|
||||
const optionRows = options.querySelectorAll("[data-search-select-option]");
|
||||
for (let i = 0; i < optionRows.length; i++) {
|
||||
if (optionRows[i].style.display !== "none") return true;
|
||||
}
|
||||
if (noResults && !noResults.classList.contains("hidden")) return true;
|
||||
if (options.querySelector("[data-search-select-modifier-option]")) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const showPanel = () => {
|
||||
if (alwaysVisible || hasVisibleContent()) {
|
||||
options.classList.remove("hidden");
|
||||
}
|
||||
};
|
||||
const hidePanel = () => {
|
||||
if (!alwaysVisible) options.classList.add("hidden");
|
||||
}
|
||||
};
|
||||
|
||||
function setNoResults(visible) {
|
||||
if (noResults) noResults.classList.toggle("hidden", !visible);
|
||||
}
|
||||
const setNoResults = (visible) => {
|
||||
if (!noResults) return;
|
||||
noResults.classList.toggle("hidden", !visible);
|
||||
if (visible) showPanel();
|
||||
};
|
||||
|
||||
// ── Highlight tracking (filter mode) ──
|
||||
var highlightedRow = null;
|
||||
let highlightedRow = null;
|
||||
|
||||
function highlightOption(row) {
|
||||
const highlightOption = (row) => {
|
||||
clearHighlight();
|
||||
if (!row) return;
|
||||
row.style.backgroundColor = "var(--color-brand, rgba(59, 130, 246, 0.15))";
|
||||
row.style.outline = "1px solid var(--color-brand, #3b82f6)";
|
||||
row.setAttribute("data-search-select-highlighted", "");
|
||||
highlightedRow = row;
|
||||
row.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
};
|
||||
|
||||
function clearHighlight() {
|
||||
const clearHighlight = () => {
|
||||
if (highlightedRow) {
|
||||
highlightedRow.style.backgroundColor = "";
|
||||
highlightedRow.style.outline = "";
|
||||
highlightedRow.removeAttribute("data-search-select-highlighted");
|
||||
highlightedRow = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function getVisibleOptions() {
|
||||
var all = options.querySelectorAll("[data-search-select-option]");
|
||||
return Array.prototype.filter.call(all, function (row) {
|
||||
return row.style.display !== "none";
|
||||
});
|
||||
}
|
||||
const getVisibleOptions = () => {
|
||||
const all = options.querySelectorAll("[data-search-select-option]");
|
||||
return Array.from(all).filter(row => row.style.display !== "none");
|
||||
};
|
||||
|
||||
function autoHighlight(query) {
|
||||
var visible = getVisibleOptions();
|
||||
const autoHighlight = (query) => {
|
||||
const visible = getVisibleOptions();
|
||||
if (visible.length === 0) {
|
||||
clearHighlight();
|
||||
return;
|
||||
}
|
||||
var lower = query.toLowerCase();
|
||||
const lower = query.toLowerCase();
|
||||
// 1. Starts-with match
|
||||
for (var i = 0; i < visible.length; i++) {
|
||||
var label = (visible[i].getAttribute("data-label") || "").toLowerCase();
|
||||
for (let i = 0; i < visible.length; i++) {
|
||||
const label = (visible[i].getAttribute("data-label") || "").toLowerCase();
|
||||
if (lower && label.startsWith(lower)) {
|
||||
highlightOption(visible[i]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 2. Substring match (fuzzy-lite)
|
||||
for (var j = 0; j < visible.length; j++) {
|
||||
var subLabel = (visible[j].getAttribute("data-label") || "").toLowerCase();
|
||||
if (lower && subLabel.indexOf(lower) !== -1) {
|
||||
for (let j = 0; j < visible.length; j++) {
|
||||
const subLabel = (visible[j].getAttribute("data-label") || "").toLowerCase();
|
||||
if (lower && subLabel.includes(lower)) {
|
||||
highlightOption(visible[j]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 3. Fallback: first visible option
|
||||
highlightOption(visible[0]);
|
||||
}
|
||||
};
|
||||
|
||||
// Get active values in both form and filter modes
|
||||
const getSelectedValues = () => {
|
||||
const vals = new Set();
|
||||
pills.querySelectorAll('input[type="hidden"]').forEach(input => {
|
||||
vals.add(input.value);
|
||||
});
|
||||
pills.querySelectorAll("[data-pill]").forEach(pill => {
|
||||
const val = pill.getAttribute("data-value");
|
||||
if (val) vals.add(val);
|
||||
});
|
||||
return vals;
|
||||
};
|
||||
|
||||
// ── Render server-fetched rows into the panel ──
|
||||
function renderRows(items) {
|
||||
options.querySelectorAll("[data-search-select-option]").forEach(function (row) {
|
||||
const renderRows = (items) => {
|
||||
const selectedVals = getSelectedValues();
|
||||
const preservedOptions = [];
|
||||
|
||||
// Extract existing option data for currently selected values before removing
|
||||
options.querySelectorAll("[data-search-select-option]").forEach(row => {
|
||||
const val = row.getAttribute("data-value");
|
||||
if (selectedVals.has(val)) {
|
||||
preservedOptions.push(optionFromRow(row));
|
||||
}
|
||||
row.remove();
|
||||
});
|
||||
items.slice(0, itemsScroll).forEach(function (item) {
|
||||
options.insertBefore(buildRow(item), noResults || null);
|
||||
|
||||
const renderedValues = new Set();
|
||||
|
||||
// Render preserved options first (to keep them at the top)
|
||||
preservedOptions.forEach(opt => {
|
||||
options.insertBefore(buildRow(opt), noResults || null);
|
||||
renderedValues.add(String(opt.value));
|
||||
});
|
||||
|
||||
// Render newly fetched items (excluding already rendered preserved ones)
|
||||
// Fix DOM-limit vs fetch mismatch: Do not slice the items, render all returned items.
|
||||
items.forEach(item => {
|
||||
if (!renderedValues.has(String(item.value))) {
|
||||
options.insertBefore(buildRow(item), noResults || null);
|
||||
renderedValues.add(String(item.value));
|
||||
}
|
||||
});
|
||||
|
||||
showPanel();
|
||||
}
|
||||
};
|
||||
|
||||
// ── Clone a server-rendered <template> prototype by name. The server emits
|
||||
// the mode-appropriate prototypes, so the JS never names a class. ──
|
||||
function cloneTemplate(name) {
|
||||
var template = container.querySelector('template[data-search-select-template="' + name + '"]');
|
||||
const cloneTemplate = (name) => {
|
||||
const template = container.querySelector(`template[data-search-select-template="${name}"]`);
|
||||
return template
|
||||
? template.content.firstElementChild.cloneNode(true)
|
||||
: null;
|
||||
}
|
||||
};
|
||||
|
||||
function setLabel(node, label) {
|
||||
var slot = node.querySelector("[data-search-select-label]");
|
||||
const setLabel = (node, label) => {
|
||||
const slot = node.querySelector("[data-search-select-label]");
|
||||
if (slot) slot.textContent = label;
|
||||
}
|
||||
};
|
||||
|
||||
function applyData(node, data) {
|
||||
data = data || {};
|
||||
Object.keys(data).forEach(function (key) {
|
||||
node.setAttribute("data-" + key, data[key]);
|
||||
const applyData = (node, data = {}) => {
|
||||
Object.keys(data).forEach(key => {
|
||||
node.setAttribute(`data-${key}`, data[key]);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Build an option row by cloning the "row" template (the same prototype the
|
||||
// server renders, so fetched and pre-rendered rows are identical).
|
||||
function buildRow(option) {
|
||||
var row = cloneTemplate("row");
|
||||
const buildRow = (option) => {
|
||||
const row = cloneTemplate("row");
|
||||
if (!row) return document.createComment("ss-row");
|
||||
row.setAttribute("data-value", option.value);
|
||||
row.setAttribute("data-label", option.label);
|
||||
@@ -168,80 +207,100 @@
|
||||
setLabel(row, option.label);
|
||||
row._searchSelectOption = option;
|
||||
return row;
|
||||
}
|
||||
};
|
||||
|
||||
// ── Client-side filter of the currently loaded rows. Returns the number of
|
||||
// visible rows so the caller decides whether to show the no-results node. ──
|
||||
function filterRows(query) {
|
||||
var lower = query.toLowerCase();
|
||||
var visibleCount = 0;
|
||||
options.querySelectorAll("[data-search-select-option]").forEach(function (item) {
|
||||
var label = (item.getAttribute("data-label") || "").toLowerCase();
|
||||
var match = label.indexOf(lower) !== -1;
|
||||
const filterRows = (query) => {
|
||||
const lower = query.toLowerCase();
|
||||
let visibleCount = 0;
|
||||
options.querySelectorAll("[data-search-select-option]").forEach(item => {
|
||||
const label = (item.getAttribute("data-label") || "").toLowerCase();
|
||||
const match = label.includes(lower);
|
||||
item.style.display = match ? "" : "none";
|
||||
if (match) visibleCount += 1;
|
||||
});
|
||||
return visibleCount;
|
||||
}
|
||||
};
|
||||
|
||||
// ── Fetch matching rows from the server. The previous in-flight request is
|
||||
// aborted so a slower earlier response can never overwrite a newer one. ──
|
||||
function fetchFromServer(query) {
|
||||
const fetchFromServer = (query) => {
|
||||
if (pendingRequest) pendingRequest.abort();
|
||||
pendingRequest = new AbortController();
|
||||
var url = searchUrl + "?q=" + encodeURIComponent(query);
|
||||
if (prefetch && !query) url += "&limit=" + prefetch;
|
||||
let url = `${searchUrl}?q=${encodeURIComponent(query)}`;
|
||||
if (prefetch && !query) url += `&limit=${prefetch}`;
|
||||
fetch(url, { credentials: "same-origin", signal: pendingRequest.signal })
|
||||
.then(function (response) {
|
||||
return response.json();
|
||||
})
|
||||
.then(function (items) {
|
||||
.then(response => response.json())
|
||||
.then(items => {
|
||||
pendingRequest = null;
|
||||
renderRows(items);
|
||||
// Re-apply the live query: the box may hold more text than was sent.
|
||||
setNoResults(filterRows(search.value.trim()) === 0);
|
||||
if (isFilter) autoHighlight(search.value.trim());
|
||||
autoHighlight(search.value.trim());
|
||||
})
|
||||
.catch(function (error) {
|
||||
if (error && error.name === "AbortError") return; // superseded
|
||||
.catch(error => {
|
||||
if (error?.name === "AbortError") return; // superseded
|
||||
pendingRequest = null;
|
||||
setNoResults(true);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// In free-text mode the typed text is the value itself: there is no
|
||||
// backing list, so we rebuild a single ephemeral option row reflecting the
|
||||
// current query so the +/− buttons (or Enter) can commit it as a pill.
|
||||
const rebuildFreeTextRow = (query) => {
|
||||
options.querySelectorAll("[data-search-select-option]").forEach(row => row.remove());
|
||||
if (!query) {
|
||||
setNoResults(false);
|
||||
clearHighlight();
|
||||
return;
|
||||
}
|
||||
const row = buildRow({ value: query, label: query, data: {} });
|
||||
options.insertBefore(row, noResults || null);
|
||||
setNoResults(false);
|
||||
highlightOption(row);
|
||||
};
|
||||
|
||||
// Called on every keystroke. With a search_url, filter the loaded window
|
||||
// instantly (zero latency) and debounce a server request for the rest;
|
||||
// no-results stays hidden until the response decides it, to avoid a flash
|
||||
// over an incomplete window. Without a search_url the loaded set is complete,
|
||||
// so the client-side filter is authoritative.
|
||||
function runSearch() {
|
||||
var query = search.value.trim();
|
||||
showPanel();
|
||||
const runSearch = () => {
|
||||
const query = search.value.trim();
|
||||
if (freeText) {
|
||||
rebuildFreeTextRow(query);
|
||||
showPanel();
|
||||
return;
|
||||
}
|
||||
if (searchUrl) {
|
||||
filterRows(query);
|
||||
setNoResults(false);
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(function () {
|
||||
debounceTimer = setTimeout(() => {
|
||||
fetchFromServer(query);
|
||||
}, DEBOUNCE_MS);
|
||||
} else {
|
||||
setNoResults(filterRows(query) === 0);
|
||||
}
|
||||
if (isFilter) autoHighlight(query);
|
||||
}
|
||||
autoHighlight(query);
|
||||
showPanel();
|
||||
};
|
||||
|
||||
// ── Single-select combobox: the search box shows the committed label;
|
||||
// focusing clears it to search, blurring restores it (or deselects). ──
|
||||
if (!multi) container._searchSelectLabel = search.value;
|
||||
|
||||
search.addEventListener("focus", function () {
|
||||
search.addEventListener("focus", () => {
|
||||
if (!multi) {
|
||||
// Hide the committed label so the box becomes a fresh search field.
|
||||
search.value = "";
|
||||
container._searchSelectDirty = false;
|
||||
}
|
||||
showPanel();
|
||||
if (searchUrl) {
|
||||
if (freeText) {
|
||||
rebuildFreeTextRow(search.value.trim());
|
||||
} else if (searchUrl) {
|
||||
if (prefetch && !hasPrefetched) {
|
||||
// Seed the window immediately on first open (not debounced).
|
||||
hasPrefetched = true;
|
||||
@@ -250,22 +309,33 @@
|
||||
// Show whatever is already loaded; the server decides no-results.
|
||||
filterRows(search.value.trim());
|
||||
setNoResults(false);
|
||||
if (isFilter) autoHighlight(search.value.trim());
|
||||
autoHighlight(search.value.trim());
|
||||
}
|
||||
} else {
|
||||
setNoResults(filterRows(search.value.trim()) === 0);
|
||||
if (isFilter) autoHighlight(search.value.trim());
|
||||
autoHighlight(search.value.trim());
|
||||
}
|
||||
showPanel();
|
||||
});
|
||||
search.addEventListener("input", function () {
|
||||
|
||||
search.addEventListener("input", () => {
|
||||
clearHighlight();
|
||||
if (!multi) container._searchSelectDirty = true;
|
||||
if (!multi) {
|
||||
if (!container._searchSelectDirty) {
|
||||
const label = container._searchSelectLabel || "";
|
||||
if (search.value.startsWith(label)) {
|
||||
search.value = search.value.slice(label.length);
|
||||
}
|
||||
container._searchSelectDirty = true;
|
||||
}
|
||||
}
|
||||
runSearch();
|
||||
});
|
||||
|
||||
if (!multi) {
|
||||
search.addEventListener("blur", function () {
|
||||
search.addEventListener("blur", () => {
|
||||
// Defer so an option click (which fires before blur settles) wins.
|
||||
setTimeout(function () {
|
||||
setTimeout(() => {
|
||||
if (container._searchSelectDirty && search.value.trim() === "") {
|
||||
// User intentionally cleared the box → deselect.
|
||||
pills.innerHTML = "";
|
||||
@@ -280,64 +350,72 @@
|
||||
});
|
||||
}
|
||||
|
||||
// ── Keyboard navigation (filter mode) ──
|
||||
search.addEventListener("keydown", function (event) {
|
||||
if (!isFilter) return;
|
||||
var key = event.key;
|
||||
if (key === "ArrowDown" || key === "ArrowUp" || key === "Enter" || key === "Escape") {
|
||||
var visible = getVisibleOptions();
|
||||
if (visible.length === 0) {
|
||||
if (key === "Escape") hidePanel();
|
||||
return;
|
||||
}
|
||||
// ── Keyboard navigation (both form and filter modes) ──
|
||||
search.addEventListener("keydown", (event) => {
|
||||
const { key } = event;
|
||||
|
||||
if (key === "ArrowDown") {
|
||||
if (!multi && key === "Backspace" && !container._searchSelectDirty) {
|
||||
event.preventDefault();
|
||||
search.value = "";
|
||||
search.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!["ArrowDown", "ArrowUp", "Enter", "Escape"].includes(key)) return;
|
||||
const visible = getVisibleOptions();
|
||||
if (visible.length === 0) {
|
||||
if (key === "Escape") hidePanel();
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
showPanel();
|
||||
const downIdx = highlightedRow ? visible.indexOf(highlightedRow) : -1;
|
||||
highlightOption(visible[(downIdx + 1) % visible.length]);
|
||||
} else if (key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
showPanel();
|
||||
const upIdx = highlightedRow ? visible.indexOf(highlightedRow) : -1;
|
||||
highlightOption(visible[(upIdx - 1 + visible.length) % visible.length]);
|
||||
} else if (key === "Enter") {
|
||||
if (highlightedRow) {
|
||||
event.preventDefault();
|
||||
showPanel();
|
||||
var idx = highlightedRow ? visible.indexOf(highlightedRow) : -1;
|
||||
var next = visible[(idx + 1) % visible.length];
|
||||
highlightOption(next);
|
||||
} else if (key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
showPanel();
|
||||
var idx = highlightedRow ? visible.indexOf(highlightedRow) : -1;
|
||||
var prev = visible[(idx - 1 + visible.length) % visible.length];
|
||||
highlightOption(prev);
|
||||
} else if (key === "Enter") {
|
||||
if (highlightedRow) {
|
||||
event.preventDefault();
|
||||
var option = optionFromRow(highlightedRow);
|
||||
const option = optionFromRow(highlightedRow);
|
||||
if (isFilter) {
|
||||
addFilterPill(option, "include");
|
||||
search.value = "";
|
||||
clearHighlight();
|
||||
hidePanel();
|
||||
} else {
|
||||
selectOption(option);
|
||||
}
|
||||
} else if (key === "Escape") {
|
||||
clearHighlight();
|
||||
hidePanel();
|
||||
}
|
||||
} else if (key === "Escape") {
|
||||
clearHighlight();
|
||||
hidePanel();
|
||||
}
|
||||
});
|
||||
|
||||
// Clicking an option must not blur the input before the click selects.
|
||||
options.addEventListener("mousedown", function (event) {
|
||||
options.addEventListener("mousedown", (event) => {
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
// ── Option click → select (form mode) or include/exclude (filter mode) ──
|
||||
options.addEventListener("click", function (event) {
|
||||
options.addEventListener("click", (event) => {
|
||||
if (isFilter) {
|
||||
handleFilterOptionClick(event);
|
||||
return;
|
||||
}
|
||||
var row = event.target.closest("[data-search-select-option]");
|
||||
const row = event.target.closest("[data-search-select-option]");
|
||||
if (!row) return;
|
||||
selectOption(optionFromRow(row));
|
||||
});
|
||||
|
||||
function handleFilterOptionClick(event) {
|
||||
const handleFilterOptionClick = (event) => {
|
||||
// Pinned modifier pseudo-option → set the (mutually exclusive) modifier.
|
||||
var modifierRow = event.target.closest("[data-search-select-modifier-option]");
|
||||
const modifierRow = event.target.closest("[data-search-select-modifier-option]");
|
||||
if (modifierRow) {
|
||||
setModifier(
|
||||
modifierRow.getAttribute("data-search-select-modifier-option"),
|
||||
@@ -346,85 +424,84 @@
|
||||
return;
|
||||
}
|
||||
// Include / exclude button on a value row.
|
||||
var button = event.target.closest("[data-search-select-action]");
|
||||
const button = event.target.closest("[data-search-select-action]");
|
||||
if (button) {
|
||||
var row = button.closest("[data-search-select-option]");
|
||||
const row = button.closest("[data-search-select-option]");
|
||||
if (!row) return;
|
||||
addFilterPill(optionFromRow(row), button.getAttribute("data-search-select-action"));
|
||||
return;
|
||||
}
|
||||
// Click on the option row itself → include.
|
||||
var optionRow = event.target.closest("[data-search-select-option]");
|
||||
const optionRow = event.target.closest("[data-search-select-option]");
|
||||
if (optionRow) {
|
||||
addFilterPill(optionFromRow(optionRow), "include");
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Add (or re-type) an include/exclude pill for a value. Selecting any value
|
||||
// clears a presence modifier — NOT_NULL / IS_NULL are mutually exclusive
|
||||
// with value pills. Non-presence modifiers (INCLUDES_ALL / INCLUDES_ONLY)
|
||||
// persist alongside value pills.
|
||||
function addFilterPill(option, kind) {
|
||||
var modPill = pills.querySelector("[data-search-select-modifier]");
|
||||
const addFilterPill = (option, kind) => {
|
||||
const modPill = pills.querySelector("[data-search-select-modifier]");
|
||||
if (modPill) {
|
||||
var modVal = modPill.getAttribute("data-search-select-modifier");
|
||||
if (PRESENCE_MODIFIERS.indexOf(modVal) !== -1) {
|
||||
const modVal = modPill.getAttribute("data-search-select-modifier");
|
||||
if (PRESENCE_MODIFIERS.includes(modVal)) {
|
||||
clearModifier();
|
||||
}
|
||||
}
|
||||
var existing = pills.querySelector(
|
||||
'[data-pill][data-value="' + cssEscape(option.value) + '"]'
|
||||
const existing = pills.querySelector(
|
||||
`[data-pill][data-value="${cssEscape(option.value)}"]`
|
||||
);
|
||||
if (existing) existing.remove();
|
||||
pills.appendChild(buildFilterValuePill(option, kind));
|
||||
search.value = "";
|
||||
emitChange(null);
|
||||
}
|
||||
};
|
||||
|
||||
function buildFilterValuePill(option, kind) {
|
||||
var pill = cloneTemplate(kind === "include" ? "pill-include" : "pill-exclude");
|
||||
const buildFilterValuePill = (option, kind) => {
|
||||
const pill = cloneTemplate(kind === "include" ? "pill-include" : "pill-exclude");
|
||||
pill.setAttribute("data-value", option.value);
|
||||
pill.setAttribute("data-label", option.label);
|
||||
applyData(pill, option.data);
|
||||
setLabel(pill, option.label);
|
||||
return pill;
|
||||
}
|
||||
};
|
||||
|
||||
// Set the modifier pill. Presence modifiers (NOT_NULL / IS_NULL) clear all
|
||||
// value pills — they are mutually exclusive. Non-presence modifiers
|
||||
// (INCLUDES_ALL / INCLUDES_ONLY) are prepended before existing value pills.
|
||||
function setModifier(modifierValue, label) {
|
||||
const setModifier = (modifierValue, label) => {
|
||||
// Remove any existing modifier pill to avoid duplicates.
|
||||
clearModifierPill();
|
||||
if (PRESENCE_MODIFIERS.indexOf(modifierValue) !== -1) {
|
||||
if (PRESENCE_MODIFIERS.includes(modifierValue)) {
|
||||
pills.innerHTML = "";
|
||||
}
|
||||
var pill = cloneTemplate("pill-modifier");
|
||||
const pill = cloneTemplate("pill-modifier");
|
||||
pill.setAttribute("data-search-select-modifier", modifierValue);
|
||||
setLabel(pill, label);
|
||||
pills.insertBefore(pill, pills.firstChild);
|
||||
container.setAttribute("data-modifier", modifierValue);
|
||||
hidePanel();
|
||||
emitChange(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Remove the modifier pill and its container attribute. Safe to call when
|
||||
// there is no modifier pill (no-op). Does not touch value pills.
|
||||
function clearModifierPill() {
|
||||
var modifierPill = pills.querySelector("[data-search-select-modifier]");
|
||||
const clearModifierPill = () => {
|
||||
const modifierPill = pills.querySelector("[data-search-select-modifier]");
|
||||
if (modifierPill) modifierPill.remove();
|
||||
container.removeAttribute("data-modifier");
|
||||
}
|
||||
};
|
||||
|
||||
function clearModifier() {
|
||||
const clearModifier = () => {
|
||||
clearModifierPill();
|
||||
}
|
||||
};
|
||||
|
||||
function optionFromRow(row) {
|
||||
const optionFromRow = (row) => {
|
||||
if (row._searchSelectOption) return row._searchSelectOption;
|
||||
var data = {};
|
||||
Object.keys(row.dataset).forEach(function (key) {
|
||||
const data = {};
|
||||
Object.keys(row.dataset).forEach(key => {
|
||||
if (key !== "value" && key !== "label" && key !== "ssOption") {
|
||||
data[key] = row.dataset[key];
|
||||
}
|
||||
@@ -432,15 +509,16 @@
|
||||
return {
|
||||
value: row.getAttribute("data-value"),
|
||||
label: row.getAttribute("data-label"),
|
||||
data: data,
|
||||
data,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
function selectOption(option) {
|
||||
const selectOption = (option) => {
|
||||
if (multi) {
|
||||
if (!pills.querySelector('input[value="' + cssEscape(option.value) + '"]')) {
|
||||
if (!pills.querySelector(`input[value="${cssEscape(option.value)}"]`)) {
|
||||
addPill(option);
|
||||
}
|
||||
search.value = "";
|
||||
} else {
|
||||
// Single-select: no pill — show the label in the search box and keep a
|
||||
// lone hidden input under [data-search-select-pills] for submission.
|
||||
@@ -452,36 +530,36 @@
|
||||
hidePanel();
|
||||
}
|
||||
emitChange(option);
|
||||
}
|
||||
};
|
||||
|
||||
function addPill(option) {
|
||||
var pill = buildPill(option);
|
||||
const addPill = (option) => {
|
||||
const pill = buildPill(option);
|
||||
if (pill) pills.appendChild(pill);
|
||||
pills.appendChild(buildHidden(option.value));
|
||||
}
|
||||
};
|
||||
|
||||
function buildPill(option) {
|
||||
var pill = cloneTemplate("pill");
|
||||
const buildPill = (option) => {
|
||||
const pill = cloneTemplate("pill");
|
||||
if (!pill) return null;
|
||||
pill.setAttribute("data-value", option.value);
|
||||
applyData(pill, option.data);
|
||||
setLabel(pill, option.label);
|
||||
return pill;
|
||||
}
|
||||
};
|
||||
|
||||
function buildHidden(value) {
|
||||
var input = document.createElement("input");
|
||||
const buildHidden = (value) => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "hidden";
|
||||
input.name = name;
|
||||
input.value = value;
|
||||
return input;
|
||||
}
|
||||
};
|
||||
|
||||
// ── Pill × → remove ──
|
||||
pills.addEventListener("click", function (event) {
|
||||
var removeButton = event.target.closest("[data-pill-remove]");
|
||||
pills.addEventListener("click", (event) => {
|
||||
const removeButton = event.target.closest("[data-pill-remove]");
|
||||
if (!removeButton) return;
|
||||
var pill = removeButton.closest("[data-pill]");
|
||||
const pill = removeButton.closest("[data-pill]");
|
||||
if (!pill) return;
|
||||
if (isFilter) {
|
||||
// Filter pills have no hidden input.
|
||||
@@ -493,86 +571,79 @@
|
||||
emitChange(null);
|
||||
return;
|
||||
}
|
||||
var value = pill.getAttribute("data-value");
|
||||
const value = pill.getAttribute("data-value");
|
||||
pill.remove();
|
||||
var hidden = pills.querySelector('input[value="' + cssEscape(value) + '"]');
|
||||
const hidden = pills.querySelector(`input[value="${cssEscape(value)}"]`);
|
||||
if (hidden) hidden.remove();
|
||||
emitChange(null);
|
||||
});
|
||||
|
||||
function currentValues() {
|
||||
return Array.prototype.map.call(
|
||||
pills.querySelectorAll('input[type="hidden"]'),
|
||||
function (input) {
|
||||
return input.value;
|
||||
}
|
||||
);
|
||||
}
|
||||
const currentValues = () => {
|
||||
return Array.from(pills.querySelectorAll('input[type="hidden"]')).map(input => input.value);
|
||||
};
|
||||
|
||||
function emitChange(last) {
|
||||
var values = currentValues();
|
||||
const emitChange = (last) => {
|
||||
const values = currentValues();
|
||||
if (syncUrl) syncToUrl(values);
|
||||
container.dispatchEvent(
|
||||
new CustomEvent("search-select:change", {
|
||||
bubbles: true,
|
||||
detail: { name: name, values: values, last: last },
|
||||
detail: { name, values, last },
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
function syncToUrl(values) {
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
const syncToUrl = (values) => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.delete(name);
|
||||
values.forEach(function (v) {
|
||||
values.forEach(v => {
|
||||
params.append(name, v);
|
||||
});
|
||||
var qs = params.toString();
|
||||
history.replaceState(null, "", qs ? "?" + qs : window.location.pathname);
|
||||
}
|
||||
const qs = params.toString();
|
||||
history.replaceState(null, "", qs ? `?${qs}` : window.location.pathname);
|
||||
};
|
||||
|
||||
// On init, restore from URL params if the server supplied no selected pills.
|
||||
if (syncUrl && !pills.querySelector("[data-pill]")) {
|
||||
var initial = new URLSearchParams(window.location.search).getAll(name);
|
||||
initial.forEach(function (v) {
|
||||
const initial = new URLSearchParams(window.location.search).getAll(name);
|
||||
initial.forEach(v => {
|
||||
addPill({ value: v, label: v, data: {} });
|
||||
});
|
||||
}
|
||||
|
||||
// ── Close panel on outside click ──
|
||||
document.addEventListener("click", function (event) {
|
||||
document.addEventListener("click", (event) => {
|
||||
if (!container.contains(event.target)) hidePanel();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/** Minimal escape for use inside an attribute-value selector. */
|
||||
function cssEscape(value) {
|
||||
return String(value).replace(/["\\]/g, "\\$&");
|
||||
}
|
||||
const cssEscape = (value) => String(value).replace(/["\\]/g, "\\$&");
|
||||
|
||||
// Serialise each widget's current state onto data-* attributes for the caller.
|
||||
// Form widgets expose data-values (the submitted hidden-input values); filter
|
||||
// widgets expose data-included / data-excluded / data-modifier for the filter
|
||||
// bar to read.
|
||||
window.readSearchSelect = function (form) {
|
||||
form.querySelectorAll("[data-search-select]").forEach(function (container) {
|
||||
var pills = container.querySelector("[data-search-select-pills]");
|
||||
window.readSearchSelect = (form) => {
|
||||
form.querySelectorAll("[data-search-select]").forEach(container => {
|
||||
const pills = container.querySelector("[data-search-select-pills]");
|
||||
if (container.getAttribute("data-search-select-mode") === "filter") {
|
||||
var included = [];
|
||||
var excluded = [];
|
||||
var modifier = "";
|
||||
const included = [];
|
||||
const excluded = [];
|
||||
let modifier = "";
|
||||
if (pills) {
|
||||
pills.querySelectorAll("[data-pill]").forEach(function (pill) {
|
||||
var pillModifier = pill.getAttribute("data-search-select-modifier");
|
||||
pills.querySelectorAll("[data-pill]").forEach(pill => {
|
||||
const pillModifier = pill.getAttribute("data-search-select-modifier");
|
||||
if (pillModifier) {
|
||||
modifier = pillModifier; // last modifier pill wins
|
||||
return; // skip value extraction for this pill
|
||||
}
|
||||
var value = pill.getAttribute("data-value");
|
||||
var label = pill.getAttribute("data-label") || "";
|
||||
const value = pill.getAttribute("data-value");
|
||||
const label = pill.getAttribute("data-label") || "";
|
||||
if (pill.getAttribute("data-search-select-type") === "exclude") {
|
||||
excluded.push({id: value, label: label});
|
||||
excluded.push({ id: value, label });
|
||||
} else {
|
||||
included.push({id: value, label: label});
|
||||
included.push({ id: value, label });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -582,18 +653,12 @@
|
||||
else container.removeAttribute("data-modifier");
|
||||
return;
|
||||
}
|
||||
var values = pills
|
||||
? Array.prototype.map.call(
|
||||
pills.querySelectorAll('input[type="hidden"]'),
|
||||
function (input) {
|
||||
return input.value;
|
||||
}
|
||||
)
|
||||
const values = pills
|
||||
? Array.from(pills.querySelectorAll('input[type="hidden"]')).map(input => input.value)
|
||||
: [];
|
||||
container.setAttribute("data-values", JSON.stringify(values));
|
||||
});
|
||||
};
|
||||
|
||||
document.addEventListener("DOMContentLoaded", initAll);
|
||||
document.addEventListener("htmx:afterSwap", initAll);
|
||||
onSwap("[data-search-select]", initWidget);
|
||||
})();
|
||||
|
||||
@@ -1,3 +1,28 @@
|
||||
/**
|
||||
* @description Runs initializeElement once for each element matching selector,
|
||||
* on initial page load and inside every htmx-swapped fragment (a port of
|
||||
* FastHTML's proc_htmx). htmx fires htmx:load for the initial document and for
|
||||
* each swapped-in element, so a single registration covers both; the WeakSet
|
||||
* guarantees once-per-element initialization, replacing the old
|
||||
* DOMContentLoaded + htmx:afterSwap + per-element guard-flag pattern.
|
||||
* @param {string} selector
|
||||
* @param {function(Element): void} initializeElement
|
||||
*/
|
||||
function onSwap(selector, initializeElement) {
|
||||
const initialized = new WeakSet();
|
||||
htmx.onLoad((swappedElement) => {
|
||||
const elements = Array.from(htmx.findAll(swappedElement, selector));
|
||||
if (swappedElement.matches && swappedElement.matches(selector)) {
|
||||
elements.unshift(swappedElement);
|
||||
}
|
||||
for (const element of elements) {
|
||||
if (initialized.has(element)) continue;
|
||||
initialized.add(element);
|
||||
initializeElement(element);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Formats Date to a UTC string accepted by the datetime-local input field.
|
||||
* @param {Date} date
|
||||
@@ -202,6 +227,7 @@ function disableElementsWhenTrue(targetSelect, targetValue, elementList) {
|
||||
}
|
||||
|
||||
export {
|
||||
onSwap,
|
||||
toISOUTCString,
|
||||
syncSelectInputUntilChanged,
|
||||
getEl,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { onSwap } from "./utils.js";
|
||||
|
||||
onSwap("#year-picker-input", function(pickerEl) {
|
||||
const selectedYear = pickerEl.dataset.selectedYear;
|
||||
const urlTemplate = pickerEl.dataset.urlTemplate;
|
||||
const currentYear = new Date().getFullYear();
|
||||
const availableYears = new Set(
|
||||
pickerEl.dataset.availableYears
|
||||
.split(",")
|
||||
.map(s => parseInt(s.trim()))
|
||||
.filter(n => !isNaN(n))
|
||||
);
|
||||
|
||||
const picker = new Datepicker(pickerEl, {
|
||||
pickLevel: 2,
|
||||
format: "yyyy",
|
||||
minDate: new Date(1999, 0, 1),
|
||||
maxDate: new Date(currentYear, 11, 31),
|
||||
autohide: false,
|
||||
orientation: "bottom end",
|
||||
showOnClick: false,
|
||||
showOnFocus: false,
|
||||
beforeShowYear: (date) => ({ enabled: availableYears.has(date.getFullYear()) }),
|
||||
});
|
||||
pickerEl._pickerInstance = picker;
|
||||
|
||||
picker.element.addEventListener("changeDate", (event) => {
|
||||
const year = event.detail.date?.getFullYear();
|
||||
if (year && urlTemplate) {
|
||||
window.location.href = urlTemplate.replace("__year__", year);
|
||||
}
|
||||
});
|
||||
|
||||
if (selectedYear) {
|
||||
picker.dates = [new Date(parseInt(selectedYear), 0, 1)];
|
||||
picker.update();
|
||||
}
|
||||
});
|
||||
+13
-15
@@ -3,24 +3,22 @@ registration/login.html)."""
|
||||
|
||||
from django.contrib.auth import views as auth_views
|
||||
from django.http import HttpResponse
|
||||
from django.utils.safestring import SafeText, mark_safe
|
||||
|
||||
from common.components import Component, CsrfInput, Div, Input
|
||||
from common.components import CsrfInput, Div, Element, Input, Node, Safe
|
||||
from common.components.primitives import Td, Tr
|
||||
from common.layout import render_page
|
||||
|
||||
|
||||
def _login_content(form, request) -> SafeText:
|
||||
table = Component(
|
||||
tag_name="table",
|
||||
def _login_content(form, request) -> Node:
|
||||
table = Element(
|
||||
"table",
|
||||
children=[
|
||||
CsrfInput(request),
|
||||
mark_safe(str(form.as_table())),
|
||||
Component(
|
||||
tag_name="tr",
|
||||
Safe(str(form.as_table())),
|
||||
Tr(
|
||||
children=[
|
||||
Component(tag_name="td"),
|
||||
Component(
|
||||
tag_name="td",
|
||||
Td(),
|
||||
Td(
|
||||
children=[
|
||||
Input(type="submit", attributes=[("value", "Login")])
|
||||
],
|
||||
@@ -32,13 +30,13 @@ def _login_content(form, request) -> SafeText:
|
||||
return Div(
|
||||
[("class", "flex items-center flex-col")],
|
||||
[
|
||||
Component(
|
||||
tag_name="h2",
|
||||
Element(
|
||||
"h2",
|
||||
attributes=[("class", "text-3xl text-white mb-8")],
|
||||
children=["Please log in to continue"],
|
||||
),
|
||||
Component(
|
||||
tag_name="form",
|
||||
Element(
|
||||
"form",
|
||||
attributes=[("method", "post")],
|
||||
children=[table],
|
||||
),
|
||||
|
||||
+27
-6
@@ -6,26 +6,37 @@ from django.urls import reverse
|
||||
from common.components import (
|
||||
A,
|
||||
AddForm,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
DeviceFilterBar,
|
||||
Fragment,
|
||||
Icon,
|
||||
StyledButton,
|
||||
paginated_table_content,
|
||||
)
|
||||
from common.layout import render_page
|
||||
from common.time import dateformat, local_strftime
|
||||
from common.utils import paginate
|
||||
from games.filters import parse_device_filter
|
||||
from games.forms import DeviceForm
|
||||
from games.models import Device
|
||||
|
||||
|
||||
@login_required
|
||||
def list_devices(request: HttpRequest) -> HttpResponse:
|
||||
devices, page_obj, elided_page_range = paginate(
|
||||
request, Device.objects.order_by("-created_at")
|
||||
)
|
||||
devices = Device.objects.order_by("-created_at")
|
||||
|
||||
filter_json = request.GET.get("filter", "")
|
||||
if filter_json:
|
||||
device_filter = parse_device_filter(filter_json)
|
||||
if device_filter is not None:
|
||||
devices = devices.filter(device_filter.to_q())
|
||||
|
||||
devices, page_obj, elided_page_range = paginate(request, devices)
|
||||
|
||||
data = {
|
||||
"header_action": A([], Button([], "Add device"), url_name="games:add_device"),
|
||||
"header_action": A(href=reverse("games:add_device"))[
|
||||
StyledButton()["Add device"]
|
||||
],
|
||||
"columns": [
|
||||
"Name",
|
||||
"Type",
|
||||
@@ -61,7 +72,17 @@ def list_devices(request: HttpRequest) -> HttpResponse:
|
||||
elided_page_range=elided_page_range,
|
||||
request=request,
|
||||
)
|
||||
return render_page(request, content, title="Manage devices")
|
||||
filter_bar = DeviceFilterBar(
|
||||
filter_json=filter_json,
|
||||
preset_list_url=reverse("games:list_presets") + "?mode=devices",
|
||||
preset_save_url=reverse("games:save_preset") + "?mode=devices",
|
||||
)
|
||||
content = Fragment(filter_bar, content)
|
||||
return render_page(
|
||||
request,
|
||||
content,
|
||||
title="Manage devices",
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
|
||||
@@ -8,7 +8,6 @@ from django.contrib.auth.decorators import login_required
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.urls import reverse
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from games.models import FilterPreset
|
||||
|
||||
@@ -40,7 +39,7 @@ def list_presets(request: HttpRequest) -> HttpResponse:
|
||||
if not items:
|
||||
items = ['<li class="px-4 py-2 text-sm text-body italic">No saved presets</li>']
|
||||
|
||||
return HttpResponse(mark_safe(f'<ul class="py-1">{"".join(items)}</ul>'))
|
||||
return HttpResponse(f'<ul class="py-1">{"".join(items)}</ul>')
|
||||
|
||||
|
||||
@login_required
|
||||
|
||||
+121
-149
@@ -2,39 +2,43 @@ from typing import Any
|
||||
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.core.paginator import Paginator
|
||||
from django.middleware.csrf import get_token
|
||||
from django.db.models import Q
|
||||
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
|
||||
from django.middleware.csrf import get_token
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.template.defaultfilters import date as date_filter
|
||||
from django.urls import reverse
|
||||
from django.utils.safestring import SafeText, mark_safe
|
||||
from django.utils.safestring import SafeText
|
||||
|
||||
from common.components import (
|
||||
H1,
|
||||
A,
|
||||
AddForm,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Component,
|
||||
CsrfInput,
|
||||
Div,
|
||||
Element,
|
||||
FilterBar,
|
||||
Fragment,
|
||||
GameStatus,
|
||||
GameStatusSelector,
|
||||
H1,
|
||||
Icon,
|
||||
SearchField,
|
||||
LinkedPurchase,
|
||||
Modal,
|
||||
ModuleScript,
|
||||
NameWithIcon,
|
||||
Node,
|
||||
Popover,
|
||||
PopoverTruncated,
|
||||
PurchasePrice,
|
||||
Safe,
|
||||
SearchField,
|
||||
SimpleTable,
|
||||
StyledButton,
|
||||
Ul,
|
||||
paginated_table_content,
|
||||
)
|
||||
from common.icons import get_icon
|
||||
from common.components.primitives import Li, P, Span, Strong
|
||||
from common.layout import render_page
|
||||
from common.time import (
|
||||
dateformat,
|
||||
@@ -86,12 +90,11 @@ def list_games(request: HttpRequest, search_string: str = "") -> HttpResponse:
|
||||
|
||||
data = {
|
||||
"header_action": Div(
|
||||
children=[
|
||||
SearchField(search_string=search_string),
|
||||
A([], Button([], "Add game"), url_name="games:add_game"),
|
||||
],
|
||||
attributes=[("class", "flex justify-between")],
|
||||
),
|
||||
class_="flex justify-between",
|
||||
)[
|
||||
SearchField(search_string=search_string),
|
||||
A(href=reverse("games:add_game"))[StyledButton()["Add game"]],
|
||||
],
|
||||
"columns": [
|
||||
"Name",
|
||||
"Sort Name",
|
||||
@@ -143,14 +146,11 @@ def list_games(request: HttpRequest, search_string: str = "") -> HttpResponse:
|
||||
preset_list_url=reverse("games:list_presets"),
|
||||
preset_save_url=reverse("games:save_preset"),
|
||||
)
|
||||
content = mark_safe(str(filter_bar) + str(content))
|
||||
content = Fragment(filter_bar, content)
|
||||
return render_page(
|
||||
request,
|
||||
content,
|
||||
title="Manage games",
|
||||
scripts=ModuleScript("range_slider.js")
|
||||
+ ModuleScript("search_select.js")
|
||||
+ ModuleScript("filter_bar.js"),
|
||||
)
|
||||
|
||||
|
||||
@@ -171,7 +171,7 @@ def add_game(request: HttpRequest) -> HttpResponse:
|
||||
AddForm(
|
||||
form,
|
||||
request=request,
|
||||
additional_row=Button(
|
||||
additional_row=StyledButton(
|
||||
[],
|
||||
"Submit & Create Purchase",
|
||||
color="gray",
|
||||
@@ -193,22 +193,16 @@ def _delete_game_confirmation_modal(
|
||||
) -> SafeText:
|
||||
data_items = []
|
||||
if session_count:
|
||||
data_items.append(
|
||||
Component(tag_name="li", children=[f"{session_count} session(s)"])
|
||||
)
|
||||
data_items.append(Li(children=[f"{session_count} session(s)"]))
|
||||
if purchase_count:
|
||||
data_items.append(
|
||||
Component(tag_name="li", children=[f"{purchase_count} purchase(s)"])
|
||||
)
|
||||
data_items.append(Li(children=[f"{purchase_count} purchase(s)"]))
|
||||
if playevent_count:
|
||||
data_items.append(
|
||||
Component(tag_name="li", children=[f"{playevent_count} play event(s)"])
|
||||
)
|
||||
data_items.append(Li(children=[f"{playevent_count} play event(s)"]))
|
||||
if not (session_count or purchase_count or playevent_count):
|
||||
data_items.append(Component(tag_name="li", children=["No associated data"]))
|
||||
data_items.append(Li(children=["No associated data"]))
|
||||
|
||||
form = Component(
|
||||
tag_name="form",
|
||||
form = Element(
|
||||
"form",
|
||||
attributes=[
|
||||
("hx-post", reverse("games:delete_game", args=[game.id])),
|
||||
("hx-replace-url", "true"),
|
||||
@@ -218,8 +212,7 @@ def _delete_game_confirmation_modal(
|
||||
],
|
||||
children=[
|
||||
CsrfInput(request),
|
||||
Component(
|
||||
tag_name="p",
|
||||
P(
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
@@ -231,8 +224,7 @@ def _delete_game_confirmation_modal(
|
||||
"This will permanently delete this game and all associated data:"
|
||||
],
|
||||
),
|
||||
Component(
|
||||
tag_name="ul",
|
||||
Ul(
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
@@ -242,8 +234,7 @@ def _delete_game_confirmation_modal(
|
||||
],
|
||||
children=data_items,
|
||||
),
|
||||
Component(
|
||||
tag_name="p",
|
||||
P(
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
@@ -256,14 +247,14 @@ def _delete_game_confirmation_modal(
|
||||
Div(
|
||||
[("class", "items-center mt-5")],
|
||||
[
|
||||
Button(
|
||||
StyledButton(
|
||||
[("class", "w-full")],
|
||||
"Delete",
|
||||
color="red",
|
||||
size="lg",
|
||||
type="submit",
|
||||
),
|
||||
Button(
|
||||
StyledButton(
|
||||
[("class", "mt-0 w-full")],
|
||||
"Cancel",
|
||||
color="gray",
|
||||
@@ -279,8 +270,7 @@ def _delete_game_confirmation_modal(
|
||||
return Modal(
|
||||
"delete-game-confirmation-modal",
|
||||
children=[
|
||||
Component(
|
||||
tag_name="h1",
|
||||
P(
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
@@ -289,12 +279,11 @@ def _delete_game_confirmation_modal(
|
||||
],
|
||||
children=["Delete Game"],
|
||||
),
|
||||
Component(
|
||||
tag_name="p",
|
||||
P(
|
||||
attributes=[("class", "dark:text-white text-center mt-5")],
|
||||
children=[
|
||||
"Are you sure you want to delete ",
|
||||
Component(tag_name="strong", children=[game.name]),
|
||||
Strong(children=[game.name]),
|
||||
"?",
|
||||
],
|
||||
),
|
||||
@@ -349,69 +338,69 @@ _STAT_SVGS = {
|
||||
"playrange": '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"><path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5m-9-6h.008v.008H12v-.008ZM12 15h.008v.008H12V15Zm0 2.25h.008v.008H12v-.008ZM9.75 15h.008v.008H9.75V15Zm0 2.25h.008v.008H9.75v-.008ZM7.5 15h.008v.008H7.5V15Zm0 2.25h.008v.008H7.5v-.008Zm6.75-4.5h.008v.008h-.008v-.008Zm0 2.25h.008v.008h-.008V15Zm0 2.25h.008v.008h-.008v-.008Zm2.25-4.5h.008v.008H16.5v-.008Zm0 2.25h.008v.008H16.5V15Z" /></svg>',
|
||||
}
|
||||
|
||||
_PLAYED_ROW_TEMPLATE = """<div class="flex gap-2 items-center" x-data="{ open: false }">
|
||||
<span class="uppercase">Played</span>
|
||||
<div class="inline-flex rounded-md shadow-2xs" role="group" x-data="{ played: @@PLAYED_COUNT@@ }">
|
||||
<a href="@@ADD_PE@@">
|
||||
<button type="button" class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-s-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white hover:cursor-pointer">
|
||||
<span x-text="played"></span> times
|
||||
</button>
|
||||
</a>
|
||||
<button type="button" x-on:click="open = !open" @click.outside="open = false" class="relative px-4 py-2 text-sm font-medium text-gray-900 bg-white border-e border-b border-t border-gray-200 rounded-e-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white align-middle hover:cursor-pointer">
|
||||
@@ARROWDOWN@@
|
||||
<div
|
||||
class="absolute top-full -left-px w-auto whitespace-nowrap z-10 text-sm font-medium bg-gray-800/20 backdrop-blur-lg rounded-md rounded-tl-none border border-gray-200 dark:border-gray-700"
|
||||
x-show="open"
|
||||
>
|
||||
<ul
|
||||
class=""
|
||||
>
|
||||
<li class="px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white rounded-tr-md">
|
||||
<a href="@@ADD_PE_FOR_GAME@@">Add playthrough...</a>
|
||||
</li>
|
||||
<li
|
||||
x-on:click="createPlayEvent"
|
||||
class="relative px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white rounded-b-md"
|
||||
>
|
||||
Played times +1
|
||||
</li>
|
||||
<script>
|
||||
function createPlayEvent() {
|
||||
this.played++;
|
||||
// TODO: migrate to hx-post + hx-on::after-request for HTMX-native toast handling
|
||||
fetchWithHtmxTriggers('@@API_CREATE@@', {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRFToken': '@@CSRF@@', 'Content-Type': 'application/json' },
|
||||
body: '{"game_id": @@GAME_ID@@}'
|
||||
})
|
||||
.catch(() => {
|
||||
this.played--;
|
||||
console.error('Failed to record play');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</ul>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>"""
|
||||
_PLAYED_BTN = (
|
||||
"px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 "
|
||||
"hover:bg-gray-100 hover:text-blue-700 dark:bg-gray-800 dark:border-gray-700 "
|
||||
"dark:text-white dark:hover:bg-gray-700 hover:cursor-pointer"
|
||||
)
|
||||
_PLAYED_MENU = (
|
||||
"absolute top-full -left-px w-auto whitespace-nowrap z-10 text-sm font-medium "
|
||||
"bg-gray-800/20 backdrop-blur-lg rounded-md rounded-tl-none border "
|
||||
"border-gray-200 dark:border-gray-700"
|
||||
)
|
||||
|
||||
|
||||
def _played_row(game: Game, request: HttpRequest) -> SafeText:
|
||||
"""The 'Played N times' control with its Alpine.js dropdown."""
|
||||
replacements = {
|
||||
"@@PLAYED_COUNT@@": str(game.playevents.count()),
|
||||
"@@ADD_PE@@": reverse("games:add_playevent"),
|
||||
"@@ARROWDOWN@@": get_icon("arrowdown"),
|
||||
"@@ADD_PE_FOR_GAME@@": reverse("games:add_playevent_for_game", args=[game.id]),
|
||||
"@@API_CREATE@@": reverse("api-1.0.0:create_playevent"),
|
||||
"@@CSRF@@": get_token(request),
|
||||
"@@GAME_ID@@": str(game.id),
|
||||
}
|
||||
html = _PLAYED_ROW_TEMPLATE
|
||||
for token, value in replacements.items():
|
||||
html = html.replace(token, value)
|
||||
return mark_safe(html)
|
||||
def _played_row(game: Game, request: HttpRequest) -> Node:
|
||||
"""'Played N times' control as a custom element (ts/elements/play-event-row.ts)."""
|
||||
from common.components import Element
|
||||
from common.components.custom_elements import _PlayEventRow
|
||||
from common.components.primitives import Button
|
||||
|
||||
played: int = 0
|
||||
played = game.playevents.count()
|
||||
|
||||
count_button = A(href=reverse("games:add_playevent"))[
|
||||
Button(class_=_PLAYED_BTN + " rounded-s-lg")[
|
||||
Span(data_count="")[str(played)], " times"
|
||||
]
|
||||
]
|
||||
menu = Div(data_menu="", hidden=True, class_=_PLAYED_MENU)[
|
||||
Ul()[
|
||||
Li(class_="px-4 py-2")[
|
||||
A(href=reverse("games:add_playevent_for_game", args=[game.id]))[
|
||||
"Add playthrough..."
|
||||
]
|
||||
],
|
||||
Li(class_="px-4 py-2 cursor-pointer")[
|
||||
Element(
|
||||
"button",
|
||||
[("type", "button"), ("data-add-play", "")],
|
||||
children=["Played times +1"],
|
||||
)
|
||||
],
|
||||
]
|
||||
]
|
||||
toggle = Element(
|
||||
"button",
|
||||
[
|
||||
("type", "button"),
|
||||
("data-toggle", ""),
|
||||
("class", _PLAYED_BTN + " rounded-e-lg"),
|
||||
],
|
||||
[Icon("arrowdown")],
|
||||
)
|
||||
# Menu is a SIBLING of the toggle (not nested inside it): a <button> may not
|
||||
# contain another <button>, and that invalid nesting makes the HTML parser
|
||||
# close ancestors early, ejecting later page sections from their container.
|
||||
toggle_group = Div(class_="relative inline-flex")[toggle, menu]
|
||||
group = Div(class_="inline-flex items-stretch rounded-md shadow-2xs")[
|
||||
count_button, toggle_group
|
||||
]
|
||||
return _PlayEventRow(
|
||||
game_id=game.id,
|
||||
csrf=get_token(request),
|
||||
api_create_url=reverse("api-1.0.0:create_playevent"),
|
||||
)[Div(class_="flex gap-2 items-center")[Span(class_="uppercase")["Played"], group]]
|
||||
|
||||
|
||||
def _stat_popover(popover_id: str, tooltip: str, svg_key: str, value: str) -> SafeText:
|
||||
@@ -419,17 +408,13 @@ def _stat_popover(popover_id: str, tooltip: str, svg_key: str, value: str) -> Sa
|
||||
popover_content=tooltip,
|
||||
wrapped_classes="flex gap-2 items-center",
|
||||
id=popover_id,
|
||||
children=[mark_safe(_STAT_SVGS[svg_key]), str(value)],
|
||||
children=[Safe(_STAT_SVGS[svg_key]), str(value)],
|
||||
)
|
||||
|
||||
|
||||
def _meta_row(
|
||||
label: str, value: SafeText | str, extra: SafeText | str = ""
|
||||
) -> SafeText:
|
||||
children: list[SafeText | str] = [
|
||||
Component(
|
||||
tag_name="span", attributes=[("class", "uppercase")], children=[label]
|
||||
),
|
||||
def _meta_row(label: str, value: Node | str, extra: Node | str = "") -> Node:
|
||||
children: list[Node | str] = [
|
||||
Span(attributes=[("class", "uppercase")], children=[label]),
|
||||
value,
|
||||
]
|
||||
if extra:
|
||||
@@ -452,27 +437,25 @@ def _game_action_buttons(game: Game) -> SafeText:
|
||||
"dark:text-white dark:hover:text-white dark:hover:bg-red-700 "
|
||||
"dark:focus:ring-blue-500 dark:focus:text-white hover:cursor-pointer"
|
||||
)
|
||||
edit_link = Component(
|
||||
tag_name="a",
|
||||
attributes=[("href", reverse("games:edit_game", args=[game.id]))],
|
||||
edit_link = A(
|
||||
href=reverse("games:edit_game", args=[game.id]),
|
||||
children=[
|
||||
Component(
|
||||
tag_name="button",
|
||||
Element(
|
||||
"button",
|
||||
attributes=[("type", "button"), ("class", edit_class)],
|
||||
children=["Edit"],
|
||||
)
|
||||
],
|
||||
)
|
||||
delete_link = Component(
|
||||
tag_name="a",
|
||||
delete_link = A(
|
||||
href="#",
|
||||
attributes=[
|
||||
("href", "#"),
|
||||
("hx-get", reverse("games:delete_game_confirmation", args=[game.id])),
|
||||
("hx-target", "#global-modal-container"),
|
||||
],
|
||||
children=[
|
||||
Component(
|
||||
tag_name="button",
|
||||
Element(
|
||||
"button",
|
||||
attributes=[("type", "button"), ("class", delete_class)],
|
||||
children=["Delete"],
|
||||
)
|
||||
@@ -499,21 +482,16 @@ def _game_history(statuschanges) -> SafeText:
|
||||
status=change.new_status,
|
||||
children=[change.get_new_status_display()],
|
||||
)
|
||||
edit = Component(
|
||||
tag_name="a",
|
||||
attributes=[("href", reverse("games:edit_statuschange", args=[change.id]))],
|
||||
edit = A(
|
||||
href=reverse("games:edit_statuschange", args=[change.id]),
|
||||
children=["Edit"],
|
||||
)
|
||||
delete = Component(
|
||||
tag_name="a",
|
||||
attributes=[
|
||||
("href", reverse("games:delete_statuschange", args=[change.id]))
|
||||
],
|
||||
delete = A(
|
||||
href=reverse("games:delete_statuschange", args=[change.id]),
|
||||
children=["Delete"],
|
||||
)
|
||||
items.append(
|
||||
Component(
|
||||
tag_name="li",
|
||||
Li(
|
||||
attributes=[("class", "text-slate-500")],
|
||||
children=[
|
||||
f"{prefix} status from ",
|
||||
@@ -528,8 +506,7 @@ def _game_history(statuschanges) -> SafeText:
|
||||
],
|
||||
)
|
||||
)
|
||||
return Component(
|
||||
tag_name="ul",
|
||||
return Ul(
|
||||
attributes=[("class", "list-disc list-inside")],
|
||||
children=items,
|
||||
)
|
||||
@@ -576,19 +553,17 @@ def _game_overview_metrics(game: Game) -> dict[str, Any]:
|
||||
|
||||
def _game_header(game: Game, request: HttpRequest, metrics: dict[str, Any]) -> SafeText:
|
||||
grey_value_class = "text-black dark:text-slate-300"
|
||||
title_span = Component(
|
||||
tag_name="span",
|
||||
title_span = Span(
|
||||
attributes=[("class", "text-balance max-w-120 text-4xl")],
|
||||
children=[
|
||||
Component(
|
||||
tag_name="span",
|
||||
Span(
|
||||
attributes=[("class", "font-bold font-serif")],
|
||||
children=[game.name],
|
||||
),
|
||||
]
|
||||
+ (
|
||||
[
|
||||
mark_safe(" "),
|
||||
Safe(" "),
|
||||
Popover(
|
||||
popover_content="Original release year",
|
||||
wrapped_classes="text-slate-500 text-2xl",
|
||||
@@ -634,8 +609,7 @@ def _game_header(game: Game, request: HttpRequest, metrics: dict[str, Any]) -> S
|
||||
[
|
||||
_meta_row(
|
||||
"Original year",
|
||||
Component(
|
||||
tag_name="span",
|
||||
Span(
|
||||
attributes=[("class", grey_value_class)],
|
||||
children=[str(game.original_year_released)],
|
||||
),
|
||||
@@ -648,8 +622,7 @@ def _game_header(game: Game, request: HttpRequest, metrics: dict[str, Any]) -> S
|
||||
_played_row(game, request),
|
||||
_meta_row(
|
||||
"Platform",
|
||||
Component(
|
||||
tag_name="span",
|
||||
Span(
|
||||
attributes=[("class", grey_value_class)],
|
||||
children=[str(game.platform)],
|
||||
),
|
||||
@@ -711,10 +684,9 @@ def _sessions_section(game: Game, request: HttpRequest) -> SafeText:
|
||||
|
||||
header_action = Div(
|
||||
children=[
|
||||
A(
|
||||
url_name="games:add_session",
|
||||
children=Button(icon=True, size="xs", children=[Icon("play"), "LOG"]),
|
||||
),
|
||||
A(href=reverse("games:add_session"))[
|
||||
StyledButton(icon=True, color="blue", size="xs")[Icon("plus")]
|
||||
],
|
||||
A(
|
||||
href=reverse(
|
||||
"games:list_sessions_start_session_from_session",
|
||||
@@ -723,7 +695,7 @@ def _sessions_section(game: Game, request: HttpRequest) -> SafeText:
|
||||
children=Popover(
|
||||
popover_content=last_session.game.name,
|
||||
children=[
|
||||
Button(
|
||||
StyledButton(
|
||||
icon=True,
|
||||
color="gray",
|
||||
size="xs",
|
||||
@@ -809,7 +781,7 @@ def _history_section(game: Game) -> SafeText:
|
||||
)
|
||||
|
||||
|
||||
_GET_SESSION_COUNT_SCRIPT = mark_safe(
|
||||
_GET_SESSION_COUNT_SCRIPT = Safe(
|
||||
"<script>\n"
|
||||
" function getSessionCount() {\n"
|
||||
" return document.getElementById('session-count')"
|
||||
|
||||
+4
-11
@@ -13,17 +13,14 @@ from django.urls import reverse
|
||||
from django.utils.timezone import localtime
|
||||
from django.utils.timezone import now as timezone_now
|
||||
|
||||
from common.components import ExternalScript
|
||||
from common.layout import render_page
|
||||
from common.time import format_duration
|
||||
from games.models import Game, Platform, Purchase, Session
|
||||
from games.views.stats_content import stats_content
|
||||
from games.views.stats_data import compute_stats
|
||||
|
||||
# Flowbite-datepicker UMD bundle, hoisted into the stats pages for YearPicker.
|
||||
_STATS_SCRIPTS = ExternalScript(
|
||||
"https://cdn.jsdelivr.net/npm/flowbite-datepicker@2.0.0/dist/Datepicker.umd.min.js"
|
||||
)
|
||||
# The Flowbite-datepicker UMD bundle is declared as media on the YearPicker
|
||||
# component, so Page() loads it automatically on the stats pages.
|
||||
|
||||
|
||||
def model_counts(request: HttpRequest) -> dict[str, bool]:
|
||||
@@ -77,9 +74,7 @@ def use_custom_redirect(
|
||||
def stats_alltime(request: HttpRequest) -> HttpResponse:
|
||||
request.session["return_path"] = request.path
|
||||
data = compute_stats(None)
|
||||
return render_page(
|
||||
request, stats_content(data), title=data["title"], scripts=_STATS_SCRIPTS
|
||||
)
|
||||
return render_page(request, stats_content(data), title=data["title"])
|
||||
|
||||
|
||||
@login_required
|
||||
@@ -93,9 +88,7 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
|
||||
return HttpResponseRedirect(reverse("games:stats_alltime"))
|
||||
request.session["return_path"] = request.path
|
||||
data = compute_stats(year)
|
||||
return render_page(
|
||||
request, stats_content(data), title=data["title"], scripts=_STATS_SCRIPTS
|
||||
)
|
||||
return render_page(request, stats_content(data), title=data["title"])
|
||||
|
||||
|
||||
@login_required
|
||||
|
||||
+27
-8
@@ -6,14 +6,17 @@ from django.urls import reverse
|
||||
from common.components import (
|
||||
A,
|
||||
AddForm,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Fragment,
|
||||
Icon,
|
||||
PlatformFilterBar,
|
||||
StyledButton,
|
||||
paginated_table_content,
|
||||
)
|
||||
from common.layout import render_page
|
||||
from common.time import dateformat, local_strftime
|
||||
from common.utils import paginate
|
||||
from games.filters import parse_platform_filter
|
||||
from games.forms import PlatformForm
|
||||
from games.models import Platform
|
||||
from games.views.general import use_custom_redirect
|
||||
@@ -21,14 +24,20 @@ from games.views.general import use_custom_redirect
|
||||
|
||||
@login_required
|
||||
def list_platforms(request: HttpRequest) -> HttpResponse:
|
||||
platforms, page_obj, elided_page_range = paginate(
|
||||
request, Platform.objects.order_by("name")
|
||||
)
|
||||
platforms = Platform.objects.order_by("name")
|
||||
|
||||
filter_json = request.GET.get("filter", "")
|
||||
if filter_json:
|
||||
platform_filter = parse_platform_filter(filter_json)
|
||||
if platform_filter is not None:
|
||||
platforms = platforms.filter(platform_filter.to_q())
|
||||
|
||||
platforms, page_obj, elided_page_range = paginate(request, platforms)
|
||||
|
||||
data = {
|
||||
"header_action": A(
|
||||
[], Button([], "Add platform"), url_name="games:add_platform"
|
||||
),
|
||||
"header_action": A(href=reverse("games:add_platform"))[
|
||||
StyledButton()["Add platform"]
|
||||
],
|
||||
"columns": [
|
||||
"Name",
|
||||
"Icon",
|
||||
@@ -68,7 +77,17 @@ def list_platforms(request: HttpRequest) -> HttpResponse:
|
||||
elided_page_range=elided_page_range,
|
||||
request=request,
|
||||
)
|
||||
return render_page(request, content, title="Manage platforms")
|
||||
filter_bar = PlatformFilterBar(
|
||||
filter_json=filter_json,
|
||||
preset_list_url=reverse("games:list_presets") + "?mode=platforms",
|
||||
preset_save_url=reverse("games:save_preset") + "?mode=platforms",
|
||||
)
|
||||
content = Fragment(filter_bar, content)
|
||||
return render_page(
|
||||
request,
|
||||
content,
|
||||
title="Manage platforms",
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
|
||||
@@ -12,15 +12,18 @@ from django.urls import reverse
|
||||
from common.components import (
|
||||
A,
|
||||
AddForm,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Fragment,
|
||||
Icon,
|
||||
ModuleScript,
|
||||
PlayEventFilterBar,
|
||||
StyledButton,
|
||||
paginated_table_content,
|
||||
)
|
||||
from common.layout import render_page
|
||||
from common.time import dateformat, format_duration, local_strftime
|
||||
from common.utils import paginate
|
||||
from games.filters import parse_playevent_filter
|
||||
from games.forms import PlayEventForm
|
||||
from games.models import Game, PlayEvent, Session
|
||||
|
||||
@@ -83,9 +86,9 @@ def create_playevent_tabledata(
|
||||
for row in row_list
|
||||
]
|
||||
return {
|
||||
"header_action": A(
|
||||
[], Button([], "Add play event"), url_name="games:add_playevent"
|
||||
),
|
||||
"header_action": A(href=reverse("games:add_playevent"))[
|
||||
StyledButton()["Add play event"]
|
||||
],
|
||||
"columns": list(filtered_column_list),
|
||||
"rows": filtered_row_list,
|
||||
}
|
||||
@@ -126,9 +129,15 @@ def _get_formatted_playtime_for_game_sessions_in_range(
|
||||
|
||||
@login_required
|
||||
def list_playevents(request: HttpRequest) -> HttpResponse:
|
||||
playevents, page_obj, elided_page_range = paginate(
|
||||
request, PlayEvent.objects.order_by("-created_at")
|
||||
)
|
||||
playevents = PlayEvent.objects.order_by("-created_at")
|
||||
|
||||
filter_json = request.GET.get("filter", "")
|
||||
if filter_json:
|
||||
playevent_filter = parse_playevent_filter(filter_json)
|
||||
if playevent_filter is not None:
|
||||
playevents = playevents.filter(playevent_filter.to_q())
|
||||
|
||||
playevents, page_obj, elided_page_range = paginate(request, playevents)
|
||||
data = create_playevent_tabledata(playevents, request=request)
|
||||
content = paginated_table_content(
|
||||
data,
|
||||
@@ -136,7 +145,17 @@ def list_playevents(request: HttpRequest) -> HttpResponse:
|
||||
elided_page_range=elided_page_range,
|
||||
request=request,
|
||||
)
|
||||
return render_page(request, content, title="Manage play events")
|
||||
filter_bar = PlayEventFilterBar(
|
||||
filter_json=filter_json,
|
||||
preset_list_url=reverse("games:list_presets") + "?mode=playevents",
|
||||
preset_save_url=reverse("games:save_preset") + "?mode=playevents",
|
||||
)
|
||||
content = Fragment(filter_bar, content)
|
||||
return render_page(
|
||||
request,
|
||||
content,
|
||||
title="Manage play events",
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
|
||||
+33
-38
@@ -6,32 +6,34 @@ from django.http import (
|
||||
HttpResponseRedirect,
|
||||
)
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.views.decorators.http import require_POST
|
||||
|
||||
from django.template.defaultfilters import date as date_filter
|
||||
from django.template.defaultfilters import floatformat
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.safestring import SafeText, mark_safe
|
||||
from django.views.decorators.http import require_POST
|
||||
|
||||
from common.components import (
|
||||
A,
|
||||
AddForm,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Component,
|
||||
CsrfInput,
|
||||
Div,
|
||||
Element,
|
||||
Fragment,
|
||||
GameLink,
|
||||
Icon,
|
||||
LinkedPurchase,
|
||||
Modal,
|
||||
ModuleScript,
|
||||
Node,
|
||||
PriceConverted,
|
||||
PurchasePrice,
|
||||
StyledButton,
|
||||
TableRow,
|
||||
paginated_table_content,
|
||||
)
|
||||
from common.components.primitives import Li, P, Td, Tr, Ul
|
||||
from common.layout import render_page
|
||||
from common.time import dateformat
|
||||
from common.utils import paginate
|
||||
@@ -108,9 +110,9 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
|
||||
purchases, page_obj, elided_page_range = paginate(request, purchases)
|
||||
|
||||
data = {
|
||||
"header_action": A(
|
||||
[], Button([], "Add purchase"), url_name="games:add_purchase"
|
||||
),
|
||||
"header_action": A(href=reverse("games:add_purchase"))[
|
||||
StyledButton()["Add purchase"]
|
||||
],
|
||||
"columns": [
|
||||
"Name",
|
||||
"Type",
|
||||
@@ -129,34 +131,29 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
|
||||
elided_page_range=elided_page_range,
|
||||
request=request,
|
||||
)
|
||||
from common.components import PurchaseFilterBar, ModuleScript
|
||||
from common.components import PurchaseFilterBar
|
||||
|
||||
filter_bar = PurchaseFilterBar(
|
||||
filter_json=filter_json,
|
||||
preset_list_url=reverse("games:list_presets"),
|
||||
preset_save_url=reverse("games:save_preset"),
|
||||
)
|
||||
content = mark_safe(str(filter_bar) + str(content))
|
||||
content = Fragment(filter_bar, content)
|
||||
return render_page(
|
||||
request,
|
||||
content,
|
||||
title="Manage purchases",
|
||||
scripts=ModuleScript("range_slider.js")
|
||||
+ ModuleScript("search_select.js")
|
||||
+ ModuleScript("filter_bar.js"),
|
||||
)
|
||||
|
||||
|
||||
def _purchase_additional_row() -> SafeText:
|
||||
"""The 'Submit & Create Session' row shown below the main Submit button."""
|
||||
return Component(
|
||||
tag_name="tr",
|
||||
return Tr(
|
||||
children=[
|
||||
Component(tag_name="td"),
|
||||
Component(
|
||||
tag_name="td",
|
||||
Td(),
|
||||
Td(
|
||||
children=[
|
||||
Button(
|
||||
StyledButton(
|
||||
[],
|
||||
"Submit & Create Session",
|
||||
color="gray",
|
||||
@@ -262,8 +259,7 @@ def _view_purchase_content(purchase: Purchase) -> SafeText:
|
||||
Div(
|
||||
[("class", row_class)],
|
||||
[
|
||||
Component(
|
||||
tag_name="p",
|
||||
P(
|
||||
children=[
|
||||
"Price per game: ",
|
||||
PriceConverted([floatformat(purchase.price_per_game, 0)]),
|
||||
@@ -273,10 +269,9 @@ def _view_purchase_content(purchase: Purchase) -> SafeText:
|
||||
],
|
||||
),
|
||||
Div([("class", row_class)], ["Games included in this purchase:"]),
|
||||
Component(
|
||||
tag_name="ul",
|
||||
Ul(
|
||||
children=[
|
||||
Component(tag_name="li", children=[GameLink(game.id, game.name)])
|
||||
Li(children=[GameLink(game.id, game.name)])
|
||||
for game in purchase.games.all()
|
||||
],
|
||||
),
|
||||
@@ -307,9 +302,9 @@ def drop_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
|
||||
return redirect("games:list_purchases")
|
||||
|
||||
|
||||
def _refund_confirmation_modal(purchase_id: int, request: HttpRequest) -> SafeText:
|
||||
form = Component(
|
||||
tag_name="form",
|
||||
def _refund_confirmation_modal(purchase_id: int, request: HttpRequest) -> Node:
|
||||
form = Element(
|
||||
"form",
|
||||
attributes=[
|
||||
("hx-post", reverse("games:refund_purchase", args=[purchase_id])),
|
||||
("hx-target", f"#purchase-row-{purchase_id}"),
|
||||
@@ -317,22 +312,21 @@ def _refund_confirmation_modal(purchase_id: int, request: HttpRequest) -> SafeTe
|
||||
],
|
||||
children=[
|
||||
CsrfInput(request),
|
||||
Component(
|
||||
tag_name="p",
|
||||
P(
|
||||
attributes=[("class", "dark:text-white text-center mt-3 text-sm")],
|
||||
children=["Games will be marked as abandoned."],
|
||||
),
|
||||
Div(
|
||||
[("class", "items-center mt-5")],
|
||||
[
|
||||
Button(
|
||||
StyledButton(
|
||||
[("class", "w-full")],
|
||||
"Refund",
|
||||
color="blue",
|
||||
size="lg",
|
||||
type="submit",
|
||||
),
|
||||
Button(
|
||||
StyledButton(
|
||||
[("class", "mt-0 w-full")],
|
||||
"Cancel",
|
||||
color="gray",
|
||||
@@ -346,8 +340,8 @@ def _refund_confirmation_modal(purchase_id: int, request: HttpRequest) -> SafeTe
|
||||
return Modal(
|
||||
"refund-confirmation-modal",
|
||||
children=[
|
||||
Component(
|
||||
tag_name="h1",
|
||||
Element(
|
||||
"h1",
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
@@ -356,8 +350,7 @@ def _refund_confirmation_modal(purchase_id: int, request: HttpRequest) -> SafeTe
|
||||
],
|
||||
children=["Confirm Refund"],
|
||||
),
|
||||
Component(
|
||||
tag_name="p",
|
||||
P(
|
||||
attributes=[("class", "dark:text-white text-center mt-5")],
|
||||
children=["Are you sure you want to mark this purchase as refunded?"],
|
||||
),
|
||||
@@ -408,8 +401,10 @@ def related_purchase_by_game(request: HttpRequest) -> HttpResponse:
|
||||
from games.forms import related_purchase_queryset
|
||||
|
||||
form = PurchaseForm()
|
||||
qs = related_purchase_queryset().filter(games__in=games).order_by(
|
||||
"games__sort_name"
|
||||
qs = (
|
||||
related_purchase_queryset()
|
||||
.filter(games__in=games)
|
||||
.order_by("games__sort_name")
|
||||
)
|
||||
|
||||
form.fields["related_purchase"].queryset = qs
|
||||
|
||||
+44
-68
@@ -13,18 +13,22 @@ from django.utils.safestring import SafeText, mark_safe
|
||||
from common.components import (
|
||||
A,
|
||||
AddForm,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Component,
|
||||
Div,
|
||||
Fragment,
|
||||
Icon,
|
||||
ModuleScript,
|
||||
NameWithIcon,
|
||||
Node,
|
||||
Popover,
|
||||
Safe,
|
||||
SearchField,
|
||||
SessionDeviceSelector,
|
||||
SessionTimestampButtons,
|
||||
StyledButton,
|
||||
paginated_table_content,
|
||||
)
|
||||
from common.components.primitives import Span, Td, Tr
|
||||
from common.layout import render_page
|
||||
from common.time import (
|
||||
dateformat,
|
||||
@@ -73,13 +77,13 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
|
||||
Div(
|
||||
children=[
|
||||
A(
|
||||
url_name="games:add_session",
|
||||
children=Button(
|
||||
href=reverse("games:add_session"),
|
||||
)[
|
||||
StyledButton(
|
||||
icon=True,
|
||||
size="xs",
|
||||
children=[Icon("play"), "LOG"],
|
||||
),
|
||||
),
|
||||
)[Icon("play"), "LOG"]
|
||||
],
|
||||
A(
|
||||
href=reverse(
|
||||
"games:list_sessions_start_session_from_session",
|
||||
@@ -88,7 +92,7 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
|
||||
children=Popover(
|
||||
popover_content=last_session.game.name,
|
||||
children=[
|
||||
Button(
|
||||
StyledButton(
|
||||
icon=True,
|
||||
color="gray",
|
||||
size="xs",
|
||||
@@ -176,14 +180,11 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
|
||||
preset_list_url=reverse("games:list_presets"),
|
||||
preset_save_url=reverse("games:save_preset"),
|
||||
)
|
||||
content = mark_safe(str(filter_bar) + str(content))
|
||||
content = Fragment(filter_bar, content)
|
||||
return render_page(
|
||||
request,
|
||||
content,
|
||||
title="Manage sessions",
|
||||
scripts=ModuleScript("range_slider.js")
|
||||
+ ModuleScript("search_select.js")
|
||||
+ ModuleScript("filter_bar.js"),
|
||||
)
|
||||
|
||||
|
||||
@@ -192,52 +193,39 @@ def search_sessions(request: HttpRequest) -> HttpResponse:
|
||||
return list_sessions(request, search_string=request.GET.get("search_string", ""))
|
||||
|
||||
|
||||
def _session_fields(form) -> SafeText:
|
||||
def _session_fields(form) -> Fragment:
|
||||
"""Manual per-field layout for the session form.
|
||||
|
||||
Mirrors the old add_session.html: each field gets its label and widget,
|
||||
and the timestamp fields gain a row of now/toggle/copy helper buttons.
|
||||
"""
|
||||
rows: list[SafeText] = []
|
||||
rows: list[Node] = []
|
||||
for field in form:
|
||||
children: list[SafeText | str] = [
|
||||
mark_safe(str(field.label_tag())),
|
||||
mark_safe(str(field)),
|
||||
children: list[Node | str] = [
|
||||
Safe(str(field.label_tag())),
|
||||
Safe(str(field)),
|
||||
]
|
||||
if field.name in ("timestamp_start", "timestamp_end"):
|
||||
this_side = "start" if field.name == "timestamp_start" else "end"
|
||||
other_side = "end" if field.name == "timestamp_start" else "start"
|
||||
children.append(
|
||||
Component(
|
||||
tag_name="span",
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
"form-row-button-group flex-row gap-3 justify-start mt-3",
|
||||
),
|
||||
("hx-boost", "false"),
|
||||
SessionTimestampButtons(
|
||||
class_="form-row-button-group flex-row gap-3 justify-start mt-3",
|
||||
hx_boost="false",
|
||||
)[
|
||||
StyledButton(data_target=field.name, data_type="now", size="xs")[
|
||||
"Set to now"
|
||||
],
|
||||
children=[
|
||||
Button(
|
||||
[("data-target", field.name), ("data-type", "now")],
|
||||
"Set to now",
|
||||
size="xs",
|
||||
),
|
||||
Button(
|
||||
[("data-target", field.name), ("data-type", "toggle")],
|
||||
"Toggle text",
|
||||
size="xs",
|
||||
),
|
||||
Button(
|
||||
[("data-target", field.name), ("data-type", "copy")],
|
||||
f"Copy {this_side} value to {other_side}",
|
||||
size="xs",
|
||||
),
|
||||
StyledButton(data_target=field.name, data_type="toggle", size="xs")[
|
||||
"Toggle text"
|
||||
],
|
||||
)
|
||||
StyledButton(data_target=field.name, data_type="copy", size="xs")[
|
||||
f"Copy {this_side} value to {other_side}"
|
||||
],
|
||||
]
|
||||
)
|
||||
rows.append(Div(children=children))
|
||||
return mark_safe("\n".join(rows))
|
||||
return Fragment(*rows, separator="\n")
|
||||
|
||||
|
||||
@login_required
|
||||
@@ -266,9 +254,7 @@ def add_session(request: HttpRequest, game_id: int = 0) -> HttpResponse:
|
||||
request,
|
||||
AddForm(form, request=request, fields=_session_fields(form), submit_class=""),
|
||||
title="Add New Session",
|
||||
scripts=mark_safe(
|
||||
ModuleScript("search_select.js") + ModuleScript("add_session.js")
|
||||
),
|
||||
scripts=mark_safe(ModuleScript("search_select.js")),
|
||||
)
|
||||
|
||||
|
||||
@@ -283,17 +269,15 @@ def edit_session(request: HttpRequest, session_id: int) -> HttpResponse:
|
||||
request,
|
||||
AddForm(form, request=request, fields=_session_fields(form), submit_class=""),
|
||||
title="Edit Session",
|
||||
scripts=mark_safe(
|
||||
ModuleScript("search_select.js") + ModuleScript("add_session.js")
|
||||
),
|
||||
scripts=mark_safe(ModuleScript("search_select.js")),
|
||||
)
|
||||
|
||||
|
||||
def _session_row_fragment(session: Session) -> SafeText:
|
||||
"""A single session <tr> (the old list_sessions.html#session-row partial),
|
||||
returned by the inline end/clone-session HTMX endpoints."""
|
||||
name_link = Component(
|
||||
tag_name="a",
|
||||
name_link = A(
|
||||
href=reverse("games:view_game", args=[session.game.id]),
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
@@ -305,12 +289,10 @@ def _session_row_fragment(session: Session) -> SafeText:
|
||||
"group-hover:outline-purple-400 group-hover:outline-4 "
|
||||
"group-hover:decoration-purple-900 group-hover:text-purple-100",
|
||||
),
|
||||
("href", reverse("games:view_game", args=[session.game.id])),
|
||||
],
|
||||
children=[session.game.name],
|
||||
)
|
||||
name_td = Component(
|
||||
tag_name="td",
|
||||
name_td = Td(
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
@@ -319,15 +301,13 @@ def _session_row_fragment(session: Session) -> SafeText:
|
||||
)
|
||||
],
|
||||
children=[
|
||||
Component(
|
||||
tag_name="span",
|
||||
Span(
|
||||
attributes=[("class", "inline-block relative")],
|
||||
children=[name_link],
|
||||
)
|
||||
],
|
||||
)
|
||||
start_td = Component(
|
||||
tag_name="td",
|
||||
start_td = Td(
|
||||
attributes=[
|
||||
("class", "px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden sm:table-cell")
|
||||
],
|
||||
@@ -336,10 +316,9 @@ def _session_row_fragment(session: Session) -> SafeText:
|
||||
|
||||
if not session.timestamp_end:
|
||||
end_url = reverse("games:list_sessions_end_session", args=[session.id])
|
||||
end_inner: SafeText | str = Component(
|
||||
tag_name="a",
|
||||
end_inner: SafeText | str = A(
|
||||
href=end_url,
|
||||
attributes=[
|
||||
("href", end_url),
|
||||
("hx-get", end_url),
|
||||
("hx-target", "closest tr"),
|
||||
("hx-swap", "outerHTML"),
|
||||
@@ -351,8 +330,7 @@ def _session_row_fragment(session: Session) -> SafeText:
|
||||
),
|
||||
],
|
||||
children=[
|
||||
Component(
|
||||
tag_name="span",
|
||||
Span(
|
||||
attributes=[("class", "text-yellow-300")],
|
||||
children=["Finish now?"],
|
||||
)
|
||||
@@ -362,19 +340,17 @@ def _session_row_fragment(session: Session) -> SafeText:
|
||||
end_inner = "--"
|
||||
else:
|
||||
end_inner = date_filter(session.timestamp_end, "d/m/Y H:i")
|
||||
end_td = Component(
|
||||
tag_name="td",
|
||||
end_td = Td(
|
||||
attributes=[
|
||||
("class", "px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden lg:table-cell")
|
||||
],
|
||||
children=[end_inner],
|
||||
)
|
||||
duration_td = Component(
|
||||
tag_name="td",
|
||||
duration_td = Td(
|
||||
attributes=[("class", "px-2 sm:px-4 md:px-6 md:py-2 font-mono")],
|
||||
children=[session.duration_formatted()],
|
||||
)
|
||||
return Component(tag_name="tr", children=[name_td, start_td, end_td, duration_td])
|
||||
return Tr(children=[name_td, start_td, end_td, duration_td])
|
||||
|
||||
|
||||
def clone_session_by_id(session_id: int) -> Session:
|
||||
|
||||
@@ -9,9 +9,19 @@ from django.template.defaultfilters import date as date_filter
|
||||
from django.template.defaultfilters import floatformat
|
||||
from django.urls import reverse
|
||||
from django.utils.html import conditional_escape
|
||||
from django.utils.safestring import SafeText, mark_safe
|
||||
|
||||
from common.components import A, Component, Div, GameLink, YearPicker
|
||||
from common.components import (
|
||||
A,
|
||||
Div,
|
||||
Element,
|
||||
GameLink,
|
||||
Node,
|
||||
Safe,
|
||||
Td,
|
||||
Th,
|
||||
Tr,
|
||||
YearPicker,
|
||||
)
|
||||
from common.time import durationformat, format_duration
|
||||
|
||||
_CELL = "px-2 sm:px-4 md:px-6 md:py-2"
|
||||
@@ -19,41 +29,40 @@ _CELL_MONO = f"{_CELL} font-mono"
|
||||
_NAME_TH = f"{_CELL} purchase-name truncate max-w-20char"
|
||||
|
||||
|
||||
def _td(children, cls: str = _CELL_MONO) -> SafeText:
|
||||
def _td(children, cls: str = _CELL_MONO) -> Node:
|
||||
if not isinstance(children, list):
|
||||
children = [children]
|
||||
children = [c if isinstance(c, (str, SafeText)) else str(c) for c in children]
|
||||
return Component(tag_name="td", attributes=[("class", cls)], children=children)
|
||||
return Td(attributes=[("class", cls)], children=children)
|
||||
|
||||
|
||||
def _th(text: str, cls: str = _CELL) -> SafeText:
|
||||
return Component(tag_name="th", attributes=[("class", cls)], children=[text])
|
||||
def _th(text: str, cls: str = _CELL) -> Node:
|
||||
return Th(attributes=[("class", cls)], children=[text])
|
||||
|
||||
|
||||
def _tr(cells: list) -> SafeText:
|
||||
return Component(tag_name="tr", children=cells)
|
||||
def _tr(cells: list) -> Node:
|
||||
return Tr(children=cells)
|
||||
|
||||
|
||||
def _kv(label, value) -> SafeText:
|
||||
def _kv(label, value) -> Node:
|
||||
"""A label/value row: plain label cell + mono value cell."""
|
||||
return _tr([_td(label, _CELL), _td(value)])
|
||||
|
||||
|
||||
def _h1(title: str) -> SafeText:
|
||||
return Component(
|
||||
tag_name="h1",
|
||||
def _h1(title: str) -> Node:
|
||||
return Element(
|
||||
"h1",
|
||||
attributes=[("class", "text-3xl text-heading text-center my-6")],
|
||||
children=[title],
|
||||
)
|
||||
|
||||
|
||||
def _table(rows: list, thead: SafeText | None = None) -> SafeText:
|
||||
def _table(rows: list, thead: Node | None = None) -> Node:
|
||||
children = []
|
||||
if thead is not None:
|
||||
children.append(thead)
|
||||
children.append(Component(tag_name="tbody", children=rows))
|
||||
return Component(
|
||||
tag_name="table",
|
||||
children.append(Element("tbody", children=rows))
|
||||
return Element(
|
||||
"table",
|
||||
attributes=[("class", "responsive-table")],
|
||||
children=children,
|
||||
)
|
||||
@@ -63,7 +72,7 @@ def _dur(value) -> str:
|
||||
return format_duration(value, durationformat)
|
||||
|
||||
|
||||
def _purchase_name(purchase) -> SafeText:
|
||||
def _purchase_name(purchase) -> Node:
|
||||
"""Mirror of the `purchase-name` partial in the old template."""
|
||||
game_name = getattr(purchase, "game_name", None)
|
||||
first_game = purchase.first_game
|
||||
@@ -71,12 +80,12 @@ def _purchase_name(purchase) -> SafeText:
|
||||
name = game_name or purchase.name
|
||||
link = GameLink(first_game.id, name)
|
||||
suffix = f" ({first_game.name} {purchase.get_type_display()})"
|
||||
return mark_safe(str(link) + conditional_escape(suffix))
|
||||
return Safe(str(link) + conditional_escape(suffix))
|
||||
name = game_name or first_game.name
|
||||
return GameLink(first_game.id, name)
|
||||
|
||||
|
||||
def _year_nav(year, year_range, url_template) -> SafeText:
|
||||
def _year_nav(year, year_range, url_template) -> Node:
|
||||
# `year` is an int for a specific year, or "Alltime" (from compute_stats)
|
||||
# for the all-time view. Normalize to int-or-None so nothing downstream has
|
||||
# to know about the "Alltime" sentinel.
|
||||
@@ -92,10 +101,9 @@ def _year_nav(year, year_range, url_template) -> SafeText:
|
||||
else "text-body hover:text-heading underline decoration-dotted"
|
||||
)
|
||||
alltime_btn = A(
|
||||
url_name="games:stats_alltime",
|
||||
attributes=[("class", alltime_classes)],
|
||||
children=["All-time stats"],
|
||||
)
|
||||
href=reverse("games:stats_alltime"),
|
||||
class_=alltime_classes,
|
||||
)["All-time stats"]
|
||||
picker = YearPicker(
|
||||
year=year_int,
|
||||
available_years=tuple(year_range or []),
|
||||
@@ -107,7 +115,7 @@ def _year_nav(year, year_range, url_template) -> SafeText:
|
||||
)
|
||||
|
||||
|
||||
def _playtime_table(ctx) -> SafeText:
|
||||
def _playtime_table(ctx) -> Node:
|
||||
year = ctx.get("year")
|
||||
rows = [
|
||||
_kv("Hours", ctx.get("total_hours")),
|
||||
@@ -186,7 +194,7 @@ def _playtime_table(ctx) -> SafeText:
|
||||
return _table(rows)
|
||||
|
||||
|
||||
def _purchases_table(ctx) -> SafeText:
|
||||
def _purchases_table(ctx) -> Node:
|
||||
rows = [
|
||||
_kv("Total", ctx.get("all_purchased_this_year_count")),
|
||||
_kv(
|
||||
@@ -213,18 +221,18 @@ def _purchases_table(ctx) -> SafeText:
|
||||
return _table(rows)
|
||||
|
||||
|
||||
def _two_col_table(header: str, items, name_key, value_fn) -> SafeText:
|
||||
thead = Component(
|
||||
tag_name="thead",
|
||||
def _two_col_table(header: str, items, name_key, value_fn) -> Node:
|
||||
thead = Element(
|
||||
"thead",
|
||||
children=[_tr([_th(header), _th("Playtime")])],
|
||||
)
|
||||
rows = [_tr([_td(name_key(item)), _td(value_fn(item))]) for item in items]
|
||||
return _table(rows, thead)
|
||||
|
||||
|
||||
def _finished_table(purchases) -> SafeText:
|
||||
thead = Component(
|
||||
tag_name="thead",
|
||||
def _finished_table(purchases) -> Node:
|
||||
thead = Element(
|
||||
"thead",
|
||||
children=[_tr([_th("Name", _NAME_TH), _th("Date")])],
|
||||
)
|
||||
rows = [
|
||||
@@ -234,9 +242,9 @@ def _finished_table(purchases) -> SafeText:
|
||||
return _table(rows, thead)
|
||||
|
||||
|
||||
def _priced_table(purchases, currency) -> SafeText:
|
||||
thead = Component(
|
||||
tag_name="thead",
|
||||
def _priced_table(purchases, currency) -> Node:
|
||||
thead = Element(
|
||||
"thead",
|
||||
children=[
|
||||
_tr([_th("Name", _NAME_TH), _th(f"Price ({currency})"), _th("Date")])
|
||||
],
|
||||
@@ -254,7 +262,7 @@ def _priced_table(purchases, currency) -> SafeText:
|
||||
return _table(rows, thead)
|
||||
|
||||
|
||||
def stats_content(ctx: dict) -> SafeText:
|
||||
def stats_content(ctx: dict) -> Node:
|
||||
year = ctx.get("year")
|
||||
currency = ctx.get("total_spent_currency")
|
||||
# Build a navigation URL with an `__year__` placeholder the picker's JS
|
||||
|
||||
@@ -7,12 +7,13 @@ from django.utils.safestring import SafeText
|
||||
from common.components import (
|
||||
A,
|
||||
AddForm,
|
||||
Button,
|
||||
Component,
|
||||
CsrfInput,
|
||||
Div,
|
||||
Element,
|
||||
StyledButton,
|
||||
paginated_table_content,
|
||||
)
|
||||
from common.components.primitives import P
|
||||
from common.layout import render_page
|
||||
from common.time import dateformat, local_strftime
|
||||
from common.utils import paginate
|
||||
@@ -75,22 +76,21 @@ def _delete_statuschange_content(statuschange, request: HttpRequest) -> SafeText
|
||||
inner = Div(
|
||||
[],
|
||||
[
|
||||
Component(
|
||||
tag_name="p",
|
||||
P(
|
||||
children=["Are you sure you want to delete this status change?"],
|
||||
),
|
||||
Button(
|
||||
StyledButton(
|
||||
[("class", "w-full")], "Delete", color="red", type="submit", size="lg"
|
||||
),
|
||||
A(
|
||||
[("class", "")],
|
||||
Button([("class", "w-full")], "Cancel", color="gray"),
|
||||
StyledButton([("class", "w-full")], "Cancel", color="gray"),
|
||||
href=reverse("games:view_game", args=[statuschange.game.id]),
|
||||
),
|
||||
],
|
||||
)
|
||||
form = Component(
|
||||
tag_name="form",
|
||||
form = Element(
|
||||
"form",
|
||||
attributes=[("method", "post"), ("class", "dark:text-white")],
|
||||
children=[CsrfInput(request), inner],
|
||||
)
|
||||
|
||||
+3
-1
@@ -1,10 +1,12 @@
|
||||
{
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
"devDependencies": {
|
||||
"@tailwindcss/forms": "^0.5.7",
|
||||
"@tailwindcss/typography": "^0.5.13",
|
||||
"concurrently": "^8.2.2",
|
||||
"npm-check-updates": "^16.14.20",
|
||||
"tailwindcss": "^4.1.18"
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "^5.6.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/cli": "^4.1.18",
|
||||
|
||||
Generated
+3358
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -1,2 +1,2 @@
|
||||
allowBuilds:
|
||||
'@parcel/watcher': false
|
||||
overrides:
|
||||
tar: ^7.5.11
|
||||
|
||||
@@ -43,6 +43,7 @@ dev = [
|
||||
"django-debug-toolbar>=4.4.2,<5",
|
||||
"ruff",
|
||||
"pytest-django>=4.12.0",
|
||||
"pytest-playwright>=0.8.0",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
|
||||
@@ -8,8 +8,15 @@ pkgs.mkShell {
|
||||
python3
|
||||
uv
|
||||
ruff
|
||||
pnpm
|
||||
];
|
||||
|
||||
# manylinux wheels with native extensions (greenlet, pulled in by
|
||||
# pytest-playwright) link against libstdc++.so.6, which the nixpkgs
|
||||
# Python cannot find on its default search path. Scoped to this dev
|
||||
# shell only — a global LD_LIBRARY_PATH would leak into other programs.
|
||||
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [ pkgs.stdenv.cc.cc.lib ];
|
||||
|
||||
shellHook = ''
|
||||
uv venv --clear
|
||||
. .venv/bin/activate
|
||||
|
||||
+260
-148
@@ -2,21 +2,29 @@ import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import django
|
||||
|
||||
from django.test import SimpleTestCase
|
||||
from django.utils.safestring import SafeText, mark_safe
|
||||
|
||||
from common import components
|
||||
from games.models import Platform, Game, Purchase, Session
|
||||
from games.models import Game, Platform, Purchase, Session
|
||||
|
||||
# Component builders return lazy ``Node`` objects; these tests assert on rendered
|
||||
# HTML, so node-returning calls are wrapped in ``str(...)`` at the call site
|
||||
# (``Node.__str__`` returns a ``SafeText``). Non-node helpers (``randomid``,
|
||||
# ``_resolve_name_with_icon``, ``_render_element``) are called
|
||||
# directly.
|
||||
|
||||
|
||||
class ComponentIntegrationTest(unittest.TestCase):
|
||||
"""Test Component() works correctly with caching transparent."""
|
||||
"""Test Element() renders correctly with caching transparent."""
|
||||
|
||||
def test_tag_name_component(self):
|
||||
result = components.Component(
|
||||
tag_name="div",
|
||||
attributes=[("class", "test")],
|
||||
children="hello",
|
||||
result = str(
|
||||
components.Element(
|
||||
tag_name="div",
|
||||
attributes=[("class", "test")],
|
||||
children="hello",
|
||||
)
|
||||
)
|
||||
self.assertEqual(result, '<div class="test">hello</div>')
|
||||
|
||||
@@ -28,9 +36,17 @@ class ComponentCacheTest(unittest.TestCase):
|
||||
components._render_element.cache_clear()
|
||||
|
||||
def test_identical_components_hit_cache(self):
|
||||
components.Component(tag_name="div", attributes=[("class", "x")], children="hi")
|
||||
str(
|
||||
components.Element(
|
||||
tag_name="div", attributes=[("class", "x")], children="hi"
|
||||
)
|
||||
)
|
||||
misses = components._render_element.cache_info().misses
|
||||
components.Component(tag_name="div", attributes=[("class", "x")], children="hi")
|
||||
str(
|
||||
components.Element(
|
||||
tag_name="div", attributes=[("class", "x")], children="hi"
|
||||
)
|
||||
)
|
||||
info = components._render_element.cache_info()
|
||||
self.assertEqual(info.misses, misses) # no new miss
|
||||
self.assertGreaterEqual(info.hits, 1) # served from cache
|
||||
@@ -39,10 +55,12 @@ class ComponentCacheTest(unittest.TestCase):
|
||||
self.assertEqual(components._render_element.cache_parameters()["maxsize"], 4096)
|
||||
|
||||
def test_safe_and_unsafe_children_do_not_collide(self):
|
||||
"""A SafeText "<b>" and a plain "<b>" are equal as strings but must
|
||||
render differently — the cache key must keep them distinct."""
|
||||
safe = components.Component(tag_name="span", children=[mark_safe("<b>x</b>")])
|
||||
unsafe = components.Component(tag_name="span", children=["<b>x</b>"])
|
||||
"""A Safe-node ``<b>`` and a plain-string ``<b>`` render differently —
|
||||
the cache key must keep them distinct."""
|
||||
safe = str(
|
||||
components.Element(tag_name="span", children=[components.Safe("<b>x</b>")])
|
||||
)
|
||||
unsafe = str(components.Element(tag_name="span", children=["<b>x</b>"]))
|
||||
self.assertIn("<b>x</b>", safe)
|
||||
self.assertIn("<b>x</b>", unsafe)
|
||||
self.assertNotEqual(safe, unsafe)
|
||||
@@ -114,33 +132,37 @@ class PopoverDeterministicTest(unittest.TestCase):
|
||||
"""Test that Popover() produces deterministic HTML output."""
|
||||
|
||||
def test_same_popover_same_id(self):
|
||||
r1 = components.Popover("hello", wrapped_content="hello")
|
||||
r2 = components.Popover("hello", wrapped_content="hello")
|
||||
r1 = str(components.Popover("hello", wrapped_content="hello"))
|
||||
r2 = str(components.Popover("hello", wrapped_content="hello"))
|
||||
self.assertEqual(r1, r2)
|
||||
|
||||
def test_different_content_different_id(self):
|
||||
r1 = components.Popover("content_a", wrapped_content="content_a")
|
||||
r2 = components.Popover("content_b", wrapped_content="content_b")
|
||||
r1 = str(components.Popover("content_a", wrapped_content="content_a"))
|
||||
r2 = str(components.Popover("content_b", wrapped_content="content_b"))
|
||||
self.assertNotEqual(r1, r2)
|
||||
|
||||
def test_wrapped_classes_affect_id(self):
|
||||
r1 = components.Popover("c", wrapped_content="c", wrapped_classes="class_x")
|
||||
r2 = components.Popover("c", wrapped_content="c", wrapped_classes="class_y")
|
||||
r1 = str(
|
||||
components.Popover("c", wrapped_content="c", wrapped_classes="class_x")
|
||||
)
|
||||
r2 = str(
|
||||
components.Popover("c", wrapped_content="c", wrapped_classes="class_y")
|
||||
)
|
||||
self.assertNotEqual(r1, r2)
|
||||
|
||||
def test_wrapped_content_affects_id(self):
|
||||
r1 = components.Popover("popover", wrapped_content="wrapped_a")
|
||||
r2 = components.Popover("popover", wrapped_content="wrapped_b")
|
||||
r1 = str(components.Popover("popover", wrapped_content="wrapped_a"))
|
||||
r2 = str(components.Popover("popover", wrapped_content="wrapped_b"))
|
||||
self.assertNotEqual(r1, r2)
|
||||
|
||||
def test_popover_content_affects_id(self):
|
||||
r1 = components.Popover("popover_a", wrapped_content="wrapped")
|
||||
r2 = components.Popover("popover_b", wrapped_content="wrapped")
|
||||
r1 = str(components.Popover("popover_a", wrapped_content="wrapped"))
|
||||
r2 = str(components.Popover("popover_b", wrapped_content="wrapped"))
|
||||
self.assertNotEqual(r1, r2)
|
||||
|
||||
def test_full_html_deterministic(self):
|
||||
r1 = components.Popover("hello world", wrapped_content="hello world")
|
||||
r2 = components.Popover("hello world", wrapped_content="hello world")
|
||||
r1 = str(components.Popover("hello world", wrapped_content="hello world"))
|
||||
r2 = str(components.Popover("hello world", wrapped_content="hello world"))
|
||||
self.assertEqual(r1.encode(), r2.encode())
|
||||
|
||||
|
||||
@@ -180,63 +202,50 @@ class ComponentReturnTypeTest(unittest.TestCase):
|
||||
"""Test that component functions return SafeText and render correctly."""
|
||||
|
||||
def test_div_returns_safe_text(self):
|
||||
result = components.Div([("class", "x")], "hello")
|
||||
result = str(components.Div([("class", "x")], "hello"))
|
||||
self.assertIsInstance(result, SafeText)
|
||||
|
||||
def test_div_deterministic(self):
|
||||
r1 = components.Div([("class", "x")], "hello")
|
||||
r2 = components.Div([("class", "x")], "hello")
|
||||
r1 = str(components.Div([("class", "x")], "hello"))
|
||||
r2 = str(components.Div([("class", "x")], "hello"))
|
||||
self.assertEqual(r1, r2)
|
||||
self.assertIn('<div class="x">hello</div>', r1)
|
||||
|
||||
def test_div_no_args(self):
|
||||
result = components.Div(children="test")
|
||||
result = str(components.Div(children="test"))
|
||||
self.assertIsInstance(result, SafeText)
|
||||
self.assertIn("<div>test</div>", result)
|
||||
|
||||
def test_a_returns_safe_text(self):
|
||||
result = components.A([], "link")
|
||||
result = str(components.A([], "link"))
|
||||
self.assertIsInstance(result, SafeText)
|
||||
|
||||
def test_a_literal_href(self):
|
||||
result = components.A([], "x", href="/literal/path")
|
||||
result = str(components.A([], "x", href="/literal/path"))
|
||||
self.assertIn('href="/literal/path"', result)
|
||||
|
||||
def test_a_url_name_reversed(self):
|
||||
from unittest.mock import patch
|
||||
|
||||
with patch(
|
||||
"common.components.primitives.reverse", return_value="/resolved/url"
|
||||
):
|
||||
result = components.A([], "link", url_name="some_name")
|
||||
self.assertIn('href="/resolved/url"', result)
|
||||
|
||||
def test_a_no_url_or_href(self):
|
||||
result = components.A([], "link")
|
||||
result = str(components.A([], "link"))
|
||||
self.assertIn("<a>link</a>", result)
|
||||
self.assertNotIn("href=", result)
|
||||
|
||||
def test_a_both_url_name_and_href_raises(self):
|
||||
with self.assertRaises(ValueError):
|
||||
components.A(href="/path", url_name="some_name")
|
||||
|
||||
def test_button_returns_safe_text(self):
|
||||
result = components.Button([], "click")
|
||||
result = str(components.StyledButton([], "click"))
|
||||
self.assertIsInstance(result, SafeText)
|
||||
self.assertIn("<button", result)
|
||||
|
||||
def test_button_default_colors(self):
|
||||
result = components.Button([], "click")
|
||||
result = str(components.StyledButton([], "click"))
|
||||
self.assertIn("text-white bg-brand", result)
|
||||
|
||||
def test_name_with_icon_no_link(self):
|
||||
result = components.NameWithIcon(name="Game", linkify=False)
|
||||
result = str(components.NameWithIcon(name="Game", linkify=False))
|
||||
self.assertIsInstance(result, SafeText)
|
||||
self.assertIn("Game", result)
|
||||
self.assertNotIn("<a ", result)
|
||||
|
||||
def test_name_with_icon_no_trailing_comma(self):
|
||||
result = components.NameWithIcon(name="Test", linkify=False)
|
||||
result = str(components.NameWithIcon(name="Test", linkify=False))
|
||||
self.assertIsInstance(result, SafeText)
|
||||
self.assertNotIsInstance(result, tuple)
|
||||
|
||||
@@ -246,21 +255,23 @@ class ComponentOutputIsNotEscapedTest(unittest.TestCase):
|
||||
|
||||
def test_component_output_starts_with_tag(self):
|
||||
for label, html in [
|
||||
("A", components.A(href="/foo", children=["link"])),
|
||||
("Button", components.Button([], "click")),
|
||||
("Div", components.Div([], ["hello"])),
|
||||
("Input", components.Input()),
|
||||
("ButtonGroup", components.ButtonGroup([])),
|
||||
("A", str(components.A(href="/foo", children=["link"]))),
|
||||
("Button", str(components.StyledButton([], "click"))),
|
||||
("Div", str(components.Div([], ["hello"]))),
|
||||
("Input", str(components.Input())),
|
||||
("ButtonGroup", str(components.ButtonGroup([]))),
|
||||
(
|
||||
"ButtonGroup with buttons",
|
||||
components.ButtonGroup(
|
||||
[{"href": "/", "slot": components.Icon("edit")}]
|
||||
str(
|
||||
components.ButtonGroup(
|
||||
[{"href": "/", "slot": components.Icon("edit")}]
|
||||
)
|
||||
),
|
||||
),
|
||||
("SearchField", components.SearchField()),
|
||||
("PriceConverted", components.PriceConverted(["27 CZK"])),
|
||||
("H1", components.H1(["Title"])),
|
||||
("H1 with badge", components.H1(["Title"], badge="3")),
|
||||
("SearchField", str(components.SearchField())),
|
||||
("PriceConverted", str(components.PriceConverted(["27 CZK"]))),
|
||||
("H1", str(components.H1(["Title"]))),
|
||||
("H1 with badge", str(components.H1(["Title"], badge="3"))),
|
||||
]:
|
||||
with self.subTest(component=label):
|
||||
self.assertTrue(
|
||||
@@ -269,90 +280,112 @@ class ComponentOutputIsNotEscapedTest(unittest.TestCase):
|
||||
)
|
||||
|
||||
def test_button_with_icon_children_not_escaped(self):
|
||||
result = components.Button(
|
||||
icon=True,
|
||||
size="xs",
|
||||
children=[components.Icon("play"), "LOG"],
|
||||
result = str(
|
||||
components.StyledButton(
|
||||
icon=True,
|
||||
size="xs",
|
||||
children=[components.Icon("play"), "LOG"],
|
||||
)
|
||||
)
|
||||
self.assertTrue(str(result).startswith("<button"))
|
||||
|
||||
def test_popover_with_button_children_not_escaped(self):
|
||||
result = components.Popover(
|
||||
popover_content="test tooltip",
|
||||
children=[
|
||||
components.Button(
|
||||
icon=True,
|
||||
color="gray",
|
||||
size="xs",
|
||||
children=[components.Icon("play"), "test"],
|
||||
),
|
||||
],
|
||||
result = str(
|
||||
components.Popover(
|
||||
popover_content="test tooltip",
|
||||
children=[
|
||||
components.StyledButton(
|
||||
icon=True,
|
||||
color="gray",
|
||||
size="xs",
|
||||
children=[components.Icon("play"), "test"],
|
||||
),
|
||||
],
|
||||
)
|
||||
)
|
||||
self.assertTrue(str(result).startswith("<span data-popover-target"))
|
||||
|
||||
def test_name_with_icon_output_not_escaped(self):
|
||||
result = components.NameWithIcon(name="Test", linkify=False)
|
||||
result = str(components.NameWithIcon(name="Test", linkify=False))
|
||||
self.assertTrue(str(result).startswith("<div"))
|
||||
|
||||
|
||||
class ComponentEdgeCasesTest(unittest.TestCase):
|
||||
"""Test Component() edge cases and error handling."""
|
||||
"""Test Element() edge cases and error handling."""
|
||||
|
||||
def test_no_tag_name_raises(self):
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
components.Component(children="hello")
|
||||
str(components.Element("", children="hello"))
|
||||
self.assertIn("tag_name", str(ctx.exception))
|
||||
|
||||
def test_single_string_children_wrapped(self):
|
||||
result = components.Component(tag_name="span", children="hello")
|
||||
result = str(components.Element(tag_name="span", children="hello"))
|
||||
self.assertIn("hello", result)
|
||||
|
||||
def test_multiple_children_joined_with_newlines(self):
|
||||
result = components.Component(tag_name="div", children=["hello", "world"])
|
||||
result = str(components.Element(tag_name="div", children=["hello", "world"]))
|
||||
self.assertIn("hello\nworld", result)
|
||||
self.assertIn("<div>", result)
|
||||
self.assertIn("</div>", result)
|
||||
|
||||
def test_raw_html_children_are_escaped(self):
|
||||
result = components.Component(
|
||||
tag_name="div", children=["<script>alert('xss')</script>"]
|
||||
result = str(
|
||||
components.Element(
|
||||
tag_name="div", children=["<script>alert('xss')</script>"]
|
||||
)
|
||||
)
|
||||
self.assertNotIn("<script>", result)
|
||||
self.assertIn("<script>", result)
|
||||
|
||||
def test_mark_safe_children_pass_through(self):
|
||||
result = components.Component(
|
||||
tag_name="div", children=[mark_safe("<span>safe</span>")]
|
||||
def test_safe_node_children_pass_through(self):
|
||||
result = str(
|
||||
components.Element(
|
||||
tag_name="div", children=[components.Safe("<span>safe</span>")]
|
||||
)
|
||||
)
|
||||
self.assertIn("<span>safe</span>", result)
|
||||
|
||||
def test_mark_safe_string_children_are_escaped(self):
|
||||
# Trusted markup must be a Safe node; a mark_safe string is still a
|
||||
# string, so it is escaped like any other text child.
|
||||
result = str(
|
||||
components.Element(
|
||||
tag_name="div", children=[mark_safe("<span>safe</span>")]
|
||||
)
|
||||
)
|
||||
self.assertIn("<span>safe</span>", result)
|
||||
|
||||
def test_attribute_values_are_escaped(self):
|
||||
result = components.Component(
|
||||
tag_name="div",
|
||||
attributes=[("data-x", 'foo"bar')],
|
||||
result = str(
|
||||
components.Element(
|
||||
tag_name="div",
|
||||
attributes=[("data-x", 'foo"bar')],
|
||||
)
|
||||
)
|
||||
self.assertIn(""", result)
|
||||
self.assertNotIn('"foo"bar"', result)
|
||||
|
||||
def test_attributes_serialized_correctly(self):
|
||||
result = components.Component(
|
||||
tag_name="div", attributes=[("class", "foo"), ("id", "bar")]
|
||||
result = str(
|
||||
components.Element(
|
||||
tag_name="div", attributes=[("class", "foo"), ("id", "bar")]
|
||||
)
|
||||
)
|
||||
self.assertIn('class="foo"', result)
|
||||
self.assertIn('id="bar"', result)
|
||||
|
||||
def test_empty_attributes_no_extra_space(self):
|
||||
result = components.Component(tag_name="span", children="x")
|
||||
result = str(components.Element(tag_name="span", children="x"))
|
||||
self.assertEqual(result, "<span>x</span>")
|
||||
self.assertNotIn(" <span", result)
|
||||
|
||||
def test_non_string_children_not_supported(self):
|
||||
"""Component only accepts str for children, not integers."""
|
||||
result = components.Component(tag_name="span", children=str(42))
|
||||
result = str(components.Element(tag_name="span", children=str(42)))
|
||||
self.assertIn("42", result)
|
||||
|
||||
def test_returns_safetext(self):
|
||||
result = components.Component(tag_name="div", children="test")
|
||||
result = str(components.Element(tag_name="div", children="test"))
|
||||
self.assertIsInstance(result, SafeText)
|
||||
|
||||
|
||||
@@ -360,22 +393,22 @@ class IconTest(unittest.TestCase):
|
||||
"""Test Icon() component function."""
|
||||
|
||||
def test_valid_icon_renders_svg(self):
|
||||
result = components.Icon("play")
|
||||
result = str(components.Icon("play"))
|
||||
self.assertIsInstance(result, SafeText)
|
||||
self.assertIn("<svg", result)
|
||||
self.assertIn("</svg>", result)
|
||||
|
||||
def test_unavailable_icon_falls_back(self):
|
||||
result = components.Icon("zzz_nonexistent_platform")
|
||||
result = str(components.Icon("zzz_nonexistent_platform"))
|
||||
self.assertIsInstance(result, SafeText)
|
||||
self.assertIn("<svg", result)
|
||||
|
||||
def test_icon_passes_attributes_to_template(self):
|
||||
result = components.Icon("play", attributes=[("title", "Play")])
|
||||
result = str(components.Icon("play", attributes=[("title", "Play")]))
|
||||
self.assertIsInstance(result, SafeText)
|
||||
|
||||
def test_returns_safetext(self):
|
||||
result = components.Icon("delete")
|
||||
result = str(components.Icon("delete"))
|
||||
self.assertIsInstance(result, SafeText)
|
||||
|
||||
|
||||
@@ -383,17 +416,19 @@ class InputTest(unittest.TestCase):
|
||||
"""Test the Input() component."""
|
||||
|
||||
def test_input_default_type_text(self):
|
||||
result = components.Input()
|
||||
result = str(components.Input())
|
||||
self.assertIn("<input", result)
|
||||
self.assertIn('type="text"', result)
|
||||
|
||||
def test_input_custom_type(self):
|
||||
result = components.Input(type="submit")
|
||||
result = str(components.Input(type="submit"))
|
||||
self.assertIn('type="submit"', result)
|
||||
|
||||
def test_input_attributes_merged_with_type(self):
|
||||
result = components.Input(
|
||||
type="email", attributes=[("id", "email"), ("class", "form-input")]
|
||||
result = str(
|
||||
components.Input(
|
||||
type="email", attributes=[("id", "email"), ("class", "form-input")]
|
||||
)
|
||||
)
|
||||
self.assertIn('type="email"', result)
|
||||
self.assertIn('id="email"', result)
|
||||
@@ -404,12 +439,12 @@ class PopoverTruncatedTest(unittest.TestCase):
|
||||
"""Test PopoverTruncated() component function."""
|
||||
|
||||
def test_short_string_no_popover(self):
|
||||
result = components.PopoverTruncated("hi")
|
||||
result = str(components.PopoverTruncated("hi"))
|
||||
self.assertEqual(result, "hi")
|
||||
|
||||
def test_long_string_wrapped_in_popover(self):
|
||||
long_text = "a" * 100
|
||||
result = components.PopoverTruncated(long_text)
|
||||
result = str(components.PopoverTruncated(long_text))
|
||||
# Should NOT equal the truncated form directly
|
||||
truncated = components.truncate(long_text, 30)
|
||||
self.assertNotEqual(result, truncated)
|
||||
@@ -418,47 +453,55 @@ class PopoverTruncatedTest(unittest.TestCase):
|
||||
|
||||
def test_custom_ellipsis_used(self):
|
||||
long_text = "a" * 50
|
||||
result = components.PopoverTruncated(long_text, ellipsis=">>")
|
||||
result = str(components.PopoverTruncated(long_text, ellipsis=">>"))
|
||||
# Django template escapes >> to >> in the wrapped_content
|
||||
self.assertIn(">>", result)
|
||||
|
||||
def test_popover_if_not_truncated_flag(self):
|
||||
short_text = "hi"
|
||||
result = components.PopoverTruncated(
|
||||
short_text, popover_content="full content", popover_if_not_truncated=True
|
||||
result = str(
|
||||
components.PopoverTruncated(
|
||||
short_text,
|
||||
popover_content="full content",
|
||||
popover_if_not_truncated=True,
|
||||
)
|
||||
)
|
||||
# Should be wrapped in popover even though short
|
||||
self.assertNotEqual(result, "hi")
|
||||
self.assertIn("data-popover-target", result)
|
||||
|
||||
def test_popover_content_override(self):
|
||||
result = components.PopoverTruncated("short", popover_content="custom popover")
|
||||
result = str(
|
||||
components.PopoverTruncated("short", popover_content="custom popover")
|
||||
)
|
||||
# With popover_if_not_truncated=False (default), short text returns as-is
|
||||
self.assertEqual(result, "short")
|
||||
|
||||
def test_popover_content_override_with_flag(self):
|
||||
result = components.PopoverTruncated(
|
||||
"short", popover_content="custom popover", popover_if_not_truncated=True
|
||||
result = str(
|
||||
components.PopoverTruncated(
|
||||
"short", popover_content="custom popover", popover_if_not_truncated=True
|
||||
)
|
||||
)
|
||||
self.assertIn("custom popover", result)
|
||||
|
||||
def test_endpart_visible_in_output(self):
|
||||
long_text = "a" * 50
|
||||
result = components.PopoverTruncated(long_text, endpart="...")
|
||||
result = str(components.PopoverTruncated(long_text, endpart="..."))
|
||||
self.assertIn("...", result)
|
||||
|
||||
def test_returns_safetext(self):
|
||||
result = components.PopoverTruncated("a" * 100)
|
||||
result = str(components.PopoverTruncated("a" * 100))
|
||||
self.assertIsInstance(result, SafeText)
|
||||
|
||||
def test_default_length(self):
|
||||
text = "a" * 31
|
||||
result = components.PopoverTruncated(text)
|
||||
result = str(components.PopoverTruncated(text))
|
||||
# 31 chars exceeds default length of 30, so should be truncated
|
||||
self.assertIn("data-popover-target", result)
|
||||
|
||||
def test_length_zero(self):
|
||||
result = components.PopoverTruncated("hello", length=0)
|
||||
result = str(components.PopoverTruncated("hello", length=0))
|
||||
# Even empty length triggers popover for any content
|
||||
self.assertIn("data-popover-target", result)
|
||||
|
||||
@@ -490,7 +533,7 @@ class ModelDependentComponentsTest(django.test.TestCase):
|
||||
def test_name_with_icon_linkify_with_game(self):
|
||||
platform = self._create_platform(name="Steam", icon="steam")
|
||||
game = self._create_game(platform)
|
||||
result = components.NameWithIcon(game=game, linkify=True)
|
||||
result = str(components.NameWithIcon(game=game, linkify=True))
|
||||
self.assertIsInstance(result, SafeText)
|
||||
self.assertIn("<a ", result)
|
||||
self.assertIn("Test Game", result)
|
||||
@@ -499,7 +542,9 @@ class ModelDependentComponentsTest(django.test.TestCase):
|
||||
def test_name_with_icon_no_linkify(self):
|
||||
platform = self._create_platform(name="GOG", icon="gog")
|
||||
game = self._create_game(platform)
|
||||
result = components.NameWithIcon(name="Test Game", game=game, linkify=False)
|
||||
result = str(
|
||||
components.NameWithIcon(name="Test Game", game=game, linkify=False)
|
||||
)
|
||||
self.assertIsInstance(result, SafeText)
|
||||
self.assertNotIn("<a ", result)
|
||||
self.assertIn("Test Game", result)
|
||||
@@ -512,13 +557,13 @@ class ModelDependentComponentsTest(django.test.TestCase):
|
||||
timestamp_start="2025-01-01 00:00:00+00:00",
|
||||
emulated=True,
|
||||
)
|
||||
result = components.NameWithIcon(session=session, linkify=True)
|
||||
result = str(components.NameWithIcon(session=session, linkify=True))
|
||||
self.assertIsInstance(result, SafeText)
|
||||
self.assertIn("<a ", result)
|
||||
self.assertIn("Emulated", result)
|
||||
|
||||
def test_name_with_icon_no_platform(self):
|
||||
result = components.NameWithIcon(name="Standalone", linkify=False)
|
||||
result = str(components.NameWithIcon(name="Standalone", linkify=False))
|
||||
self.assertIsInstance(result, SafeText)
|
||||
self.assertIn("Standalone", result)
|
||||
|
||||
@@ -529,7 +574,7 @@ class ModelDependentComponentsTest(django.test.TestCase):
|
||||
game=game,
|
||||
timestamp_start="2025-01-01 00:00:00+00:00",
|
||||
)
|
||||
result = components.NameWithIcon(session=session, linkify=True)
|
||||
result = str(components.NameWithIcon(session=session, linkify=True))
|
||||
self.assertIsInstance(result, SafeText)
|
||||
self.assertIn("Epic Game", result)
|
||||
|
||||
@@ -537,7 +582,7 @@ class ModelDependentComponentsTest(django.test.TestCase):
|
||||
platform = self._create_platform()
|
||||
game = self._create_game(platform)
|
||||
purchase = self._create_purchase([game], price=29.99)
|
||||
result = components.PurchasePrice(purchase)
|
||||
result = str(components.PurchasePrice(purchase))
|
||||
self.assertIsInstance(result, SafeText)
|
||||
# floatformat rounds to 1 decimal: 29.99 -> 30.0
|
||||
self.assertIn("30.0", result)
|
||||
@@ -548,7 +593,7 @@ class ModelDependentComponentsTest(django.test.TestCase):
|
||||
platform = self._create_platform(icon="steam")
|
||||
game = self._create_game(platform, name="Single Game")
|
||||
purchase = self._create_purchase([game], price=14.99)
|
||||
result = components.LinkedPurchase(purchase)
|
||||
result = str(components.LinkedPurchase(purchase))
|
||||
self.assertIsInstance(result, SafeText)
|
||||
self.assertIn("Single Game", result)
|
||||
self.assertIn("<a ", result)
|
||||
@@ -559,7 +604,7 @@ class ModelDependentComponentsTest(django.test.TestCase):
|
||||
game1 = self._create_game(platform, name="Game One")
|
||||
game2 = self._create_game(platform, name="Game Two")
|
||||
purchase = self._create_purchase([game1, game2], price=24.99)
|
||||
result = components.LinkedPurchase(purchase)
|
||||
result = str(components.LinkedPurchase(purchase))
|
||||
self.assertIsInstance(result, SafeText)
|
||||
self.assertIn("2 games", result)
|
||||
self.assertIn("<a ", result)
|
||||
@@ -575,7 +620,7 @@ class ModelDependentComponentsTest(django.test.TestCase):
|
||||
)
|
||||
purchase.name = "Bundle"
|
||||
purchase.save()
|
||||
result = components.LinkedPurchase(purchase)
|
||||
result = str(components.LinkedPurchase(purchase))
|
||||
self.assertIsInstance(result, SafeText)
|
||||
self.assertIn("Bundle", result)
|
||||
|
||||
@@ -584,7 +629,7 @@ class ModelDependentComponentsTest(django.test.TestCase):
|
||||
game1 = self._create_game(platform, name="Alpha")
|
||||
game2 = self._create_game(platform, name="Beta")
|
||||
purchase = self._create_purchase([game1, game2], price=19.99)
|
||||
result = components.LinkedPurchase(purchase)
|
||||
result = str(components.LinkedPurchase(purchase))
|
||||
self.assertIsInstance(result, SafeText)
|
||||
self.assertIn("Alpha", result)
|
||||
self.assertIn("Beta", result)
|
||||
@@ -595,18 +640,18 @@ class PurchaseTruncatedTest(unittest.TestCase):
|
||||
|
||||
def test_endpart_shorter_than_length(self):
|
||||
text = "a" * 50
|
||||
result = components.PopoverTruncated(text, length=10, endpart="x")
|
||||
result = str(components.PopoverTruncated(text, length=10, endpart="x"))
|
||||
# endpart=x takes 1 char, so content gets truncated at 9 chars
|
||||
self.assertIn("data-popover-target", result)
|
||||
self.assertIn("x", result)
|
||||
|
||||
def test_no_truncation_no_ellipsis(self):
|
||||
result = components.PopoverTruncated("short text")
|
||||
result = str(components.PopoverTruncated("short text"))
|
||||
self.assertEqual(result, "short text")
|
||||
|
||||
def test_custom_length(self):
|
||||
text = "hello world"
|
||||
result = components.PopoverTruncated(text, length=6)
|
||||
result = str(components.PopoverTruncated(text, length=6))
|
||||
self.assertIn("data-popover-target", result)
|
||||
|
||||
|
||||
@@ -620,12 +665,14 @@ class NameWithIconPlatformTest(django.test.TestCase):
|
||||
cls.game = Game.objects.create(name="Zelda", platform=cls.platform)
|
||||
|
||||
def test_name_with_icon_shows_platform_icon(self):
|
||||
result = components.NameWithIcon(name="Zelda", game=self.game, linkify=True)
|
||||
result = str(
|
||||
components.NameWithIcon(name="Zelda", game=self.game, linkify=True)
|
||||
)
|
||||
self.assertIsInstance(result, SafeText)
|
||||
self.assertIn("Zelda", result)
|
||||
|
||||
def test_name_with_icon_no_game_id_no_platform(self):
|
||||
result = components.NameWithIcon(name="Unknown Game", linkify=False)
|
||||
result = str(components.NameWithIcon(name="Unknown Game", linkify=False))
|
||||
self.assertIsInstance(result, SafeText)
|
||||
self.assertIn("Unknown Game", result)
|
||||
|
||||
@@ -749,9 +796,11 @@ class SimpleTableRenderingTest(unittest.TestCase):
|
||||
def test_simple_table_renders_list_rows(self):
|
||||
"""Verify list-style rows render as <tr> with <th scope='row'> + <td>."""
|
||||
result = str(
|
||||
components.SimpleTable(
|
||||
columns=["Game", "Started", "Ended"],
|
||||
rows=[["Game1", "2025-01-01", "2025-03-01"]],
|
||||
str(
|
||||
components.SimpleTable(
|
||||
columns=["Game", "Started", "Ended"],
|
||||
rows=[["Game1", "2025-01-01", "2025-03-01"]],
|
||||
)
|
||||
)
|
||||
)
|
||||
tbody = self._tbody(result)
|
||||
@@ -774,9 +823,11 @@ class SimpleTableRenderingTest(unittest.TestCase):
|
||||
def test_simple_table_multiple_rows(self):
|
||||
"""Verify multiple rows all render."""
|
||||
result = str(
|
||||
components.SimpleTable(
|
||||
columns=["Game", "Started"],
|
||||
rows=[["GameA", "2025-01-01"], ["GameB", "2025-02-01"]],
|
||||
str(
|
||||
components.SimpleTable(
|
||||
columns=["Game", "Started"],
|
||||
rows=[["GameA", "2025-01-01"], ["GameB", "2025-02-01"]],
|
||||
)
|
||||
)
|
||||
)
|
||||
tbody = self._tbody(result)
|
||||
@@ -786,13 +837,13 @@ class SimpleTableRenderingTest(unittest.TestCase):
|
||||
|
||||
def test_simple_table_header_action_as_caption(self):
|
||||
"""Verify header_action renders inside <caption>."""
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
result = str(
|
||||
components.SimpleTable(
|
||||
columns=["Game", "Started"],
|
||||
rows=[["Game1", "2025-01-01"]],
|
||||
header_action=mark_safe('<a href="/add">Add</a>'),
|
||||
str(
|
||||
components.SimpleTable(
|
||||
columns=["Game", "Started"],
|
||||
rows=[["Game1", "2025-01-01"]],
|
||||
header_action=components.Safe('<a href="/add">Add</a>'),
|
||||
)
|
||||
)
|
||||
)
|
||||
self.assertIn("<caption", result)
|
||||
@@ -802,15 +853,17 @@ class SimpleTableRenderingTest(unittest.TestCase):
|
||||
def test_simple_table_dict_rows_with_cell_data(self):
|
||||
"""Verify dict-style rows with row_id and cell_data render correctly."""
|
||||
result = str(
|
||||
components.SimpleTable(
|
||||
columns=["Name", "Date"],
|
||||
rows=[
|
||||
{
|
||||
"row_id": "session-row-1",
|
||||
"hx_trigger": "device-changed",
|
||||
"cell_data": ["Game1", "2025-01-01"],
|
||||
}
|
||||
],
|
||||
str(
|
||||
components.SimpleTable(
|
||||
columns=["Name", "Date"],
|
||||
rows=[
|
||||
{
|
||||
"row_id": "session-row-1",
|
||||
"hx_trigger": "device-changed",
|
||||
"cell_data": ["Game1", "2025-01-01"],
|
||||
}
|
||||
],
|
||||
)
|
||||
)
|
||||
)
|
||||
tbody = self._tbody(result)
|
||||
@@ -821,5 +874,64 @@ class SimpleTableRenderingTest(unittest.TestCase):
|
||||
self.assertIn("2025-01-01", tbody)
|
||||
|
||||
|
||||
class ComponentPrimitivesTest(SimpleTestCase):
|
||||
def test_checkbox_primitive(self):
|
||||
html = str(
|
||||
components.Checkbox(
|
||||
name="test-check", label="Accept Terms", checked=True, value="yes"
|
||||
)
|
||||
)
|
||||
self.assertIn('type="checkbox"', html)
|
||||
self.assertIn('name="test-check"', html)
|
||||
self.assertIn('value="yes"', html)
|
||||
self.assertIn('checked="true"', html)
|
||||
self.assertIn("Accept Terms", html)
|
||||
|
||||
def test_checkbox_headless(self):
|
||||
html = str(components.Checkbox(name="test-headless", label=None, checked=True))
|
||||
self.assertNotIn("<label", html)
|
||||
self.assertIn("<input", html)
|
||||
self.assertIn('type="checkbox"', html)
|
||||
self.assertIn('name="test-headless"', html)
|
||||
|
||||
def test_radio_primitive(self):
|
||||
html = str(
|
||||
components.Radio(
|
||||
name="test-radio", label="Option A", checked=False, value="A"
|
||||
)
|
||||
)
|
||||
self.assertIn('type="radio"', html)
|
||||
self.assertIn('name="test-radio"', html)
|
||||
self.assertIn('value="A"', html)
|
||||
self.assertNotIn('checked="true"', html)
|
||||
self.assertIn("Option A", html)
|
||||
|
||||
|
||||
class PrimitiveWidgetsTest(SimpleTestCase):
|
||||
def test_mixin_applies_widget_to_boolean_fields_only(self):
|
||||
from django import forms
|
||||
|
||||
from games.forms import PrimitiveCheckboxWidget, PrimitiveWidgetsMixin
|
||||
|
||||
class DummyForm(PrimitiveWidgetsMixin, forms.Form):
|
||||
agree = forms.BooleanField(required=False)
|
||||
name = forms.CharField(required=False)
|
||||
|
||||
form = DummyForm()
|
||||
self.assertIsInstance(form.fields["agree"].widget, PrimitiveCheckboxWidget)
|
||||
self.assertNotIsInstance(form.fields["name"].widget, PrimitiveCheckboxWidget)
|
||||
|
||||
def test_primitive_checkbox_widget_renders_headless(self):
|
||||
from games.forms import PrimitiveCheckboxWidget
|
||||
|
||||
widget = PrimitiveCheckboxWidget()
|
||||
html = widget.render(name="agree", value=True)
|
||||
self.assertNotIn("<label", html)
|
||||
self.assertIn("<input", html)
|
||||
self.assertIn('type="checkbox"', html)
|
||||
self.assertIn('name="agree"', html)
|
||||
self.assertIn('checked="true"', html)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import unittest
|
||||
from typing import TypedDict
|
||||
|
||||
from common.components import custom_element_builder, render
|
||||
from common.components.custom_elements import (
|
||||
ElementSpec,
|
||||
_ts_for_spec,
|
||||
register_element,
|
||||
)
|
||||
|
||||
|
||||
class SampleProps(TypedDict):
|
||||
game_id: int
|
||||
status: str
|
||||
is_on: bool
|
||||
|
||||
|
||||
class CustomElementBuilderTest(unittest.TestCase):
|
||||
def test_serializes_props_to_kebab_attributes(self):
|
||||
x_sample = custom_element_builder("x-sample")
|
||||
html = render(x_sample(game_id=3, status="f")["hi"])
|
||||
self.assertIn("<x-sample", html)
|
||||
self.assertIn('game-id="3"', html)
|
||||
self.assertIn('status="f"', html)
|
||||
self.assertIn(">hi</x-sample>", html)
|
||||
|
||||
def test_declares_compiled_module_media(self):
|
||||
from common.components import collect_media
|
||||
|
||||
x_sample = custom_element_builder("x-sample")
|
||||
node = x_sample(game_id=3)
|
||||
self.assertEqual(collect_media(node).js, ("dist/elements/x-sample.js",))
|
||||
|
||||
|
||||
class CodegenTest(unittest.TestCase):
|
||||
def test_emits_interface_and_reader(self):
|
||||
spec = ElementSpec("x-sample", "XSample", SampleProps)
|
||||
ts = _ts_for_spec(spec)
|
||||
self.assertIn("export interface XSampleProps {", ts)
|
||||
self.assertIn("gameId: number;", ts)
|
||||
self.assertIn("status: string;", ts)
|
||||
self.assertIn("isOn: boolean;", ts)
|
||||
self.assertIn(
|
||||
"export function readXSampleProps(el: HTMLElement): XSampleProps", ts
|
||||
)
|
||||
self.assertIn('Number(el.getAttribute("game-id"))', ts)
|
||||
self.assertIn('el.getAttribute("status") ?? ""', ts)
|
||||
self.assertIn('el.getAttribute("is-on") === "true"', ts)
|
||||
|
||||
|
||||
class RegistryTest(unittest.TestCase):
|
||||
def test_register_adds_spec(self):
|
||||
from common.components.custom_elements import ELEMENT_REGISTRY
|
||||
|
||||
before = len(ELEMENT_REGISTRY)
|
||||
register_element("x-reg-test", "XRegTest", SampleProps)
|
||||
self.assertEqual(len(ELEMENT_REGISTRY), before + 1)
|
||||
self.assertEqual(ELEMENT_REGISTRY[-1].tag, "x-reg-test")
|
||||
|
||||
|
||||
class GameStatusSelectorRenderTest(unittest.TestCase):
|
||||
def test_emits_tag_props_and_media(self):
|
||||
from types import SimpleNamespace
|
||||
|
||||
from common.components import GameStatusSelector, collect_media, render
|
||||
|
||||
game = SimpleNamespace(id=7, status="f", get_status_display=lambda: "Finished")
|
||||
node = GameStatusSelector(game, [("u", "Unplayed"), ("f", "Finished")], "tok")
|
||||
html = render(node)
|
||||
self.assertIn("<game-status-selector", html)
|
||||
self.assertIn('game-id="7"', html)
|
||||
self.assertIn('status="f"', html)
|
||||
self.assertIn('csrf="tok"', html)
|
||||
self.assertIn("data-option", html)
|
||||
self.assertIn('data-value="u"', html)
|
||||
self.assertNotIn("x-data", html) # no Alpine left
|
||||
self.assertIn("dist/elements/game-status-selector.js", collect_media(node).js)
|
||||
|
||||
|
||||
class SessionDeviceSelectorRenderTest(unittest.TestCase):
|
||||
def test_emits_tag_and_options(self):
|
||||
from types import SimpleNamespace
|
||||
|
||||
from common.components import SessionDeviceSelector, render
|
||||
|
||||
session = SimpleNamespace(id=4, device=SimpleNamespace(name="Desktop"))
|
||||
devices = [
|
||||
SimpleNamespace(id=1, name="Desktop"),
|
||||
SimpleNamespace(id=2, name="Deck"),
|
||||
]
|
||||
html = render(SessionDeviceSelector(session, devices, "tok"))
|
||||
self.assertIn("<session-device-selector", html)
|
||||
self.assertIn('session-id="4"', html)
|
||||
self.assertIn('data-value="2"', html)
|
||||
self.assertNotIn("x-data", html)
|
||||
@@ -0,0 +1,196 @@
|
||||
"""Unit tests for the DateRangePicker component family.
|
||||
|
||||
Pins the structural contract of DateRangeField / DateRangeCalendar /
|
||||
DateRangePicker — segment inputs ordered by ``dateformat_hyphenated``, the
|
||||
hidden ISO ``{prefix}-min`` / ``{prefix}-max`` inputs that ``filter_bar.js``
|
||||
serializes, the calendar's preset/footer hooks — and the PurchaseFilterBar
|
||||
integration that replaced the native-date DateRangeFilter for the Purchased
|
||||
field.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
|
||||
from django.test import SimpleTestCase, TestCase
|
||||
|
||||
from common.components import (
|
||||
DateRangeCalendar,
|
||||
DateRangeField,
|
||||
DateRangePicker,
|
||||
PurchaseFilterBar,
|
||||
)
|
||||
from common.time import date_parts, dateformat_hyphenated
|
||||
|
||||
_ESCAPED_TAG_MARKERS = ["<div", "<span", "<button", "<input"]
|
||||
|
||||
|
||||
class DatePartsTest(SimpleTestCase):
|
||||
def test_default_format_yields_day_month_year(self):
|
||||
parts = date_parts()
|
||||
self.assertEqual([part.name for part in parts], ["day", "month", "year"])
|
||||
self.assertEqual([part.placeholder for part in parts], ["DD", "MM", "YYYY"])
|
||||
self.assertEqual([part.length for part in parts], [2, 2, 4])
|
||||
|
||||
def test_parts_follow_format_order(self):
|
||||
parts = date_parts("%Y-%d-%m")
|
||||
self.assertEqual([part.name for part in parts], ["year", "day", "month"])
|
||||
|
||||
def test_dateformat_hyphenated_is_parseable(self):
|
||||
self.assertEqual(len(date_parts(dateformat_hyphenated)), 3)
|
||||
|
||||
|
||||
class DateRangeFieldTest(SimpleTestCase):
|
||||
def render(self, **kwargs):
|
||||
defaults = {"label": "Purchased", "input_name_prefix": "filter-date-purchased"}
|
||||
defaults.update(kwargs)
|
||||
return str(DateRangeField(**defaults))
|
||||
|
||||
def test_renders_hidden_iso_inputs(self):
|
||||
html = self.render(min_value="2024-03-15", max_value="2024-09-20")
|
||||
self.assertIn('name="filter-date-purchased-min"', html)
|
||||
self.assertIn('name="filter-date-purchased-max"', html)
|
||||
self.assertIn('data-date-range-hidden="min"', html)
|
||||
self.assertIn('data-date-range-hidden="max"', html)
|
||||
self.assertIn('value="2024-03-15"', html)
|
||||
self.assertIn('value="2024-09-20"', html)
|
||||
|
||||
def test_renders_segments_in_dateformat_order_for_both_sides(self):
|
||||
html = self.render()
|
||||
for side in ("min", "max"):
|
||||
side_segments = re.findall(
|
||||
rf'data-date-part="(\w+)" data-date-side="{side}"', html
|
||||
)
|
||||
self.assertEqual(side_segments, ["day", "month", "year"])
|
||||
|
||||
def test_segment_placeholders_and_lengths(self):
|
||||
html = self.render()
|
||||
self.assertEqual(html.count('placeholder="DD"'), 2)
|
||||
self.assertEqual(html.count('placeholder="MM"'), 2)
|
||||
self.assertEqual(html.count('placeholder="YYYY"'), 2)
|
||||
self.assertEqual(html.count('maxlength="2"'), 4)
|
||||
self.assertEqual(html.count('maxlength="4"'), 2)
|
||||
self.assertEqual(html.count('inputmode="numeric"'), 6)
|
||||
|
||||
def test_prefills_segments_from_iso_values(self):
|
||||
html = self.render(min_value="2024-03-15")
|
||||
self.assertIn('value="15" data-date-part="day" data-date-side="min"', html)
|
||||
self.assertIn('value="03" data-date-part="month" data-date-side="min"', html)
|
||||
self.assertIn('value="2024" data-date-part="year" data-date-side="min"', html)
|
||||
# The max side stays empty.
|
||||
self.assertIn('value="" data-date-part="day" data-date-side="max"', html)
|
||||
|
||||
def test_malformed_iso_value_renders_empty_segments(self):
|
||||
html = self.render(min_value="not-a-date")
|
||||
self.assertIn('value="" data-date-part="day" data-date-side="min"', html)
|
||||
|
||||
def test_renders_calendar_toggle(self):
|
||||
html = self.render()
|
||||
self.assertIn("data-date-range-calendar-toggle", html)
|
||||
self.assertIn('aria-label="Open Purchased calendar"', html)
|
||||
|
||||
def test_no_native_date_inputs(self):
|
||||
self.assertNotIn('type="date"', self.render())
|
||||
|
||||
|
||||
class DateRangeCalendarTest(SimpleTestCase):
|
||||
def render(self):
|
||||
return str(DateRangeCalendar(input_name_prefix="filter-date-purchased"))
|
||||
|
||||
def test_renders_all_presets(self):
|
||||
html = self.render()
|
||||
for preset in (
|
||||
"today",
|
||||
"yesterday",
|
||||
"last_7_days",
|
||||
"last_30_days",
|
||||
"this_month",
|
||||
"last_month",
|
||||
"this_year",
|
||||
):
|
||||
self.assertIn(f'data-date-range-preset="{preset}"', html)
|
||||
|
||||
def test_renders_footer_buttons(self):
|
||||
html = self.render()
|
||||
self.assertIn("data-date-range-cancel", html)
|
||||
self.assertIn("data-date-range-clear", html)
|
||||
self.assertIn("data-date-range-select", html)
|
||||
self.assertIn(">Cancel<", html)
|
||||
self.assertIn(">Clear<", html)
|
||||
self.assertIn(">Select<", html)
|
||||
|
||||
def test_renders_grid_and_navigation_hooks(self):
|
||||
html = self.render()
|
||||
self.assertIn("data-date-range-grid", html)
|
||||
self.assertIn("data-date-range-month-label", html)
|
||||
self.assertIn("data-date-range-prev", html)
|
||||
self.assertIn("data-date-range-next", html)
|
||||
|
||||
def test_starts_hidden(self):
|
||||
self.assertIn('class="hidden absolute', self.render())
|
||||
|
||||
def test_all_buttons_are_type_button(self):
|
||||
"""No button inside the calendar may submit the surrounding filter form."""
|
||||
html = self.render()
|
||||
button_count = html.count("<button")
|
||||
self.assertEqual(html.count('<button type="button"'), button_count)
|
||||
|
||||
|
||||
class DateRangePickerTest(SimpleTestCase):
|
||||
def test_composes_field_and_calendar(self):
|
||||
html = str(
|
||||
DateRangePicker(
|
||||
label="Purchased",
|
||||
input_name_prefix="filter-date-purchased",
|
||||
min_value="2024-01-01",
|
||||
max_value="2024-12-31",
|
||||
)
|
||||
)
|
||||
self.assertIn("data-date-range-picker", html)
|
||||
self.assertIn('data-input-name-prefix="filter-date-purchased"', html)
|
||||
self.assertIn("data-date-range-field", html)
|
||||
self.assertIn("data-date-range-calendar", html)
|
||||
for marker in _ESCAPED_TAG_MARKERS:
|
||||
self.assertNotIn(marker, html)
|
||||
|
||||
|
||||
class PurchaseFilterBarDateRangePickerTest(TestCase):
|
||||
"""The Purchased filter uses the DateRangePicker; Refunded keeps the
|
||||
native-date DateRangeFilter (the picker is a tryout on one field)."""
|
||||
|
||||
def render(self, filter_json=""):
|
||||
return str(
|
||||
PurchaseFilterBar(
|
||||
filter_json=filter_json, preset_list_url="/l", preset_save_url="/s"
|
||||
)
|
||||
)
|
||||
|
||||
def test_purchased_uses_date_range_picker(self):
|
||||
html = self.render()
|
||||
self.assertIn("data-date-range-picker", html)
|
||||
self.assertIn('data-input-name-prefix="filter-date-purchased"', html)
|
||||
# The hidden ISO inputs keep the names filter_bar.js serializes.
|
||||
self.assertIn('name="filter-date-purchased-min"', html)
|
||||
self.assertIn('name="filter-date-purchased-max"', html)
|
||||
|
||||
def test_refunded_keeps_native_date_inputs(self):
|
||||
html = self.render()
|
||||
refunded_min = html.find('name="filter-date-refunded-min"')
|
||||
self.assertGreater(refunded_min, 0)
|
||||
self.assertIn('type="date"', html)
|
||||
self.assertNotIn('data-input-name-prefix="filter-date-refunded"', html)
|
||||
|
||||
def test_prefilled_between_filter_round_trips_into_picker(self):
|
||||
filter_json = json.dumps(
|
||||
{
|
||||
"date_purchased": {
|
||||
"value": "2024-03-15",
|
||||
"value2": "2024-09-20",
|
||||
"modifier": "BETWEEN",
|
||||
}
|
||||
}
|
||||
)
|
||||
html = self.render(filter_json)
|
||||
self.assertIn('value="2024-03-15"', html)
|
||||
self.assertIn('value="2024-09-20"', html)
|
||||
self.assertIn('value="15" data-date-part="day" data-date-side="min"', html)
|
||||
self.assertIn('value="20" data-date-part="day" data-date-side="max"', html)
|
||||
@@ -186,3 +186,179 @@ class FilterBarRenderingTest(TestCase):
|
||||
self.assertNotIn("data-match=", html)
|
||||
self.assertIn("Finished", html)
|
||||
self.assertNoEscapedTags(html)
|
||||
|
||||
def test_device_filter_bar(self):
|
||||
from common.components import DeviceFilterBar
|
||||
|
||||
html = str(
|
||||
DeviceFilterBar(
|
||||
filter_json="",
|
||||
preset_list_url="/presets/devices/list",
|
||||
preset_save_url="/presets/devices/save",
|
||||
)
|
||||
)
|
||||
self._assert_shell(html, "/presets/devices/list", "/presets/devices/save")
|
||||
|
||||
def test_platform_filter_bar(self):
|
||||
from common.components import PlatformFilterBar
|
||||
|
||||
html = str(
|
||||
PlatformFilterBar(
|
||||
filter_json="",
|
||||
preset_list_url="/presets/platforms/list",
|
||||
preset_save_url="/presets/platforms/save",
|
||||
)
|
||||
)
|
||||
self._assert_shell(html, "/presets/platforms/list", "/presets/platforms/save")
|
||||
|
||||
def test_playevent_filter_bar(self):
|
||||
from common.components import PlayEventFilterBar
|
||||
|
||||
html = str(
|
||||
PlayEventFilterBar(
|
||||
filter_json="",
|
||||
preset_list_url="/presets/playevents/list",
|
||||
preset_save_url="/presets/playevents/save",
|
||||
)
|
||||
)
|
||||
self._assert_shell(html, "/presets/playevents/list", "/presets/playevents/save")
|
||||
|
||||
def test_game_filter_bar_has_new_widgets(self):
|
||||
"""The expanded games FilterBar exposes platform_group, device, playevent_note,
|
||||
purchase_type / purchase_ownership_type, plus count and aggregate-playtime
|
||||
range sliders and the new boolean checkboxes."""
|
||||
html = str(
|
||||
FilterBar(
|
||||
filter_json="",
|
||||
preset_list_url="/l",
|
||||
preset_save_url="/s",
|
||||
)
|
||||
)
|
||||
# New search-backed selects
|
||||
self.assertIn('data-search-url="/api/devices/search"', html)
|
||||
self.assertIn('data-search-url="/api/platforms/groups"', html)
|
||||
# New enum selects (purchase type / ownership)
|
||||
self.assertIn('data-name="purchase_type"', html)
|
||||
self.assertIn('data-name="purchase_ownership_type"', html)
|
||||
# Free-text widget for playevent notes (now StringFilter)
|
||||
self.assertIn('name="filter-playevent_note"', html)
|
||||
self.assertIn('name="filter-playevent_note-modifier"', html)
|
||||
# New range slider input prefixes
|
||||
self.assertIn('name="filter-purchase-count-min"', html)
|
||||
self.assertIn('name="filter-playevent-count-min"', html)
|
||||
self.assertIn('name="filter-manual-playtime-hours-min"', html)
|
||||
self.assertIn('name="filter-calculated-playtime-hours-min"', html)
|
||||
self.assertIn('name="filter-original-year-min"', html)
|
||||
self.assertIn('name="filter-purchase-price-total-min"', html)
|
||||
self.assertIn('name="filter-purchase-price-any-min"', html)
|
||||
# New boolean checkboxes
|
||||
self.assertIn('name="filter-purchase-refunded"', html)
|
||||
self.assertIn('name="filter-purchase-infinite"', html)
|
||||
self.assertIn('name="filter-session-emulated"', html)
|
||||
# Removed boolean checkboxes
|
||||
self.assertNotIn('name="filter-has-purchases"', html)
|
||||
self.assertNotIn('name="filter-has-playevents"', html)
|
||||
# Playtime label renamed
|
||||
self.assertIn("Total playtime", html)
|
||||
|
||||
def test_purchase_filter_bar_renders_date_inputs(self):
|
||||
"""PurchaseFilterBar surfaces date_purchased and date_refunded as
|
||||
type=date input pairs with -min/-max naming."""
|
||||
html = str(
|
||||
PurchaseFilterBar(
|
||||
filter_json="", preset_list_url="/l", preset_save_url="/s"
|
||||
)
|
||||
)
|
||||
for name in (
|
||||
"filter-date-purchased-min",
|
||||
"filter-date-purchased-max",
|
||||
"filter-date-refunded-min",
|
||||
"filter-date-refunded-max",
|
||||
):
|
||||
self.assertIn(f'name="{name}"', html)
|
||||
self.assertIn(f'id="{name}"', html)
|
||||
# Inputs are native date pickers, not text.
|
||||
self.assertIn('type="date"', html)
|
||||
self.assertNoEscapedTags(html)
|
||||
|
||||
def test_purchase_filter_bar_prepopulates_dates_between(self):
|
||||
"""A BETWEEN filter populates both date bounds via _parse_range."""
|
||||
filter_json = json.dumps(
|
||||
{
|
||||
"date_purchased": {
|
||||
"value": "2024-01-01",
|
||||
"value2": "2024-12-31",
|
||||
"modifier": "BETWEEN",
|
||||
}
|
||||
}
|
||||
)
|
||||
html = str(
|
||||
PurchaseFilterBar(
|
||||
filter_json=filter_json,
|
||||
preset_list_url="/l",
|
||||
preset_save_url="/s",
|
||||
)
|
||||
)
|
||||
self.assertIn(
|
||||
'name="filter-date-purchased-min" id="filter-date-purchased-min" '
|
||||
'value="2024-01-01"',
|
||||
html,
|
||||
)
|
||||
self.assertIn(
|
||||
'name="filter-date-purchased-max" id="filter-date-purchased-max" '
|
||||
'value="2024-12-31"',
|
||||
html,
|
||||
)
|
||||
|
||||
def test_purchase_filter_bar_prepopulates_dates_single_bound(self):
|
||||
"""A single-bound (GREATER_THAN) filter populates min only."""
|
||||
filter_json = json.dumps(
|
||||
{
|
||||
"date_refunded": {
|
||||
"value": "2024-06-01",
|
||||
"modifier": "GREATER_THAN",
|
||||
}
|
||||
}
|
||||
)
|
||||
html = str(
|
||||
PurchaseFilterBar(
|
||||
filter_json=filter_json,
|
||||
preset_list_url="/l",
|
||||
preset_save_url="/s",
|
||||
)
|
||||
)
|
||||
self.assertIn(
|
||||
'name="filter-date-refunded-min" id="filter-date-refunded-min" '
|
||||
'value="2024-06-01"',
|
||||
html,
|
||||
)
|
||||
# Max input is still present but with empty value.
|
||||
self.assertIn(
|
||||
'name="filter-date-refunded-max" id="filter-date-refunded-max" value=""',
|
||||
html,
|
||||
)
|
||||
|
||||
def test_boolean_fields_render_as_radio_groups(self):
|
||||
"""Boolean fields must render as radio groups with True/False choices."""
|
||||
from common.components import FilterBar, SessionFilterBar, PurchaseFilterBar
|
||||
|
||||
# 1. Games Filter Bar
|
||||
games_html = str(FilterBar(filter_json=""))
|
||||
self.assertIn('type="radio"', games_html)
|
||||
self.assertIn('name="filter-mastered"', games_html)
|
||||
self.assertIn('value="true"', games_html)
|
||||
self.assertIn('value="false"', games_html)
|
||||
|
||||
# 2. Session Filter Bar
|
||||
session_html = str(SessionFilterBar(filter_json=""))
|
||||
self.assertIn('type="radio"', session_html)
|
||||
self.assertIn('name="filter-emulated"', session_html)
|
||||
self.assertIn('value="true"', session_html)
|
||||
self.assertIn('value="false"', session_html)
|
||||
|
||||
# 3. Purchase Filter Bar
|
||||
purchase_html = str(PurchaseFilterBar(filter_json=""))
|
||||
self.assertIn('type="radio"', purchase_html)
|
||||
self.assertIn('name="filter-refunded"', purchase_html)
|
||||
self.assertIn('value="true"', purchase_html)
|
||||
self.assertIn('value="false"', purchase_html)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from django.test import SimpleTestCase
|
||||
|
||||
from common.components.filters import _parse_bool, _parse_range
|
||||
from common.components.filters import _parse_bool, _parse_range, _parse_bool_nullable
|
||||
|
||||
|
||||
class ParseRangeTest(SimpleTestCase):
|
||||
@@ -66,3 +66,22 @@ class ParseBoolTest(SimpleTestCase):
|
||||
|
||||
def test_missing_value_in_field(self):
|
||||
self.assertFalse(_parse_bool({"field": {}}, "field"))
|
||||
|
||||
|
||||
class ParseBoolNullableTest(SimpleTestCase):
|
||||
def test_missing_key(self):
|
||||
self.assertIsNone(_parse_bool_nullable({}, "field"))
|
||||
|
||||
def test_null_value(self):
|
||||
self.assertIsNone(_parse_bool_nullable({"field": None}, "field"))
|
||||
self.assertIsNone(_parse_bool_nullable({"field": {}}, "field"))
|
||||
|
||||
def test_boolean_values(self):
|
||||
self.assertTrue(_parse_bool_nullable({"field": {"value": True}}, "field"))
|
||||
self.assertFalse(_parse_bool_nullable({"field": {"value": False}}, "field"))
|
||||
|
||||
def test_string_values(self):
|
||||
self.assertTrue(_parse_bool_nullable({"field": {"value": "true"}}, "field"))
|
||||
self.assertTrue(_parse_bool_nullable({"field": {"value": "1"}}, "field"))
|
||||
self.assertFalse(_parse_bool_nullable({"field": {"value": "false"}}, "field"))
|
||||
self.assertFalse(_parse_bool_nullable({"field": {"value": "0"}}, "field"))
|
||||
|
||||
+585
-1
@@ -8,6 +8,7 @@ from django.db.models import Q
|
||||
from common.criteria import (
|
||||
BoolCriterion,
|
||||
ChoiceCriterion,
|
||||
DateCriterion,
|
||||
IntCriterion,
|
||||
Modifier,
|
||||
MultiCriterion,
|
||||
@@ -37,10 +38,34 @@ class TestStringCriterion:
|
||||
c = StringCriterion(value="zelda", modifier=Modifier.EQUALS)
|
||||
assert c.to_q("name") == Q(name="zelda")
|
||||
|
||||
def test_not_equals(self):
|
||||
c = StringCriterion(value="zelda", modifier=Modifier.NOT_EQUALS)
|
||||
assert c.to_q("name") == ~Q(name="zelda")
|
||||
|
||||
def test_includes(self):
|
||||
c = StringCriterion(value="zelda", modifier=Modifier.INCLUDES)
|
||||
assert c.to_q("name") == Q(name__icontains="zelda")
|
||||
|
||||
def test_excludes(self):
|
||||
c = StringCriterion(value="zelda", modifier=Modifier.EXCLUDES)
|
||||
assert c.to_q("name") == ~Q(name__icontains="zelda")
|
||||
|
||||
def test_matches_regex(self):
|
||||
c = StringCriterion(value="zelda", modifier=Modifier.MATCHES_REGEX)
|
||||
assert c.to_q("name") == Q(name__regex="zelda")
|
||||
|
||||
def test_not_matches_regex(self):
|
||||
c = StringCriterion(value="zelda", modifier=Modifier.NOT_MATCHES_REGEX)
|
||||
assert c.to_q("name") == ~Q(name__regex="zelda")
|
||||
|
||||
def test_is_null(self):
|
||||
c = StringCriterion(value="", modifier=Modifier.IS_NULL)
|
||||
assert c.to_q("name") == Q(name__isnull=True)
|
||||
|
||||
def test_not_null(self):
|
||||
c = StringCriterion(value="", modifier=Modifier.NOT_NULL)
|
||||
assert c.to_q("name") == Q(name__isnull=False)
|
||||
|
||||
|
||||
class TestIntCriterion:
|
||||
def test_between(self):
|
||||
@@ -535,7 +560,14 @@ class TestFilterBarRendering:
|
||||
|
||||
def test_mastered_not_checked_by_default(self):
|
||||
html = str(FilterBar(filter_json=""))
|
||||
assert 'checked="true"' not in html
|
||||
assert (
|
||||
'name="filter-mastered" value="true" class="rounded-full border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand" checked="true"'
|
||||
not in html
|
||||
)
|
||||
assert (
|
||||
'name="filter-mastered" value="false" class="rounded-full border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand" checked="true"'
|
||||
not in html
|
||||
)
|
||||
|
||||
def test_mastered_checked_when_filtered(self):
|
||||
html = str(
|
||||
@@ -657,3 +689,555 @@ class TestPurchaseNumPurchasesAgainstDB:
|
||||
)
|
||||
result = set(Purchase.objects.filter(pf.to_q()))
|
||||
assert result == {seeded["single"]}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestExpandedFiltersAgainstDB:
|
||||
def _setup_entities(self):
|
||||
from games.models import Game, Platform, Device, Session, Purchase, PlayEvent
|
||||
import datetime
|
||||
from datetime import timedelta
|
||||
|
||||
# 1. Platform & Game
|
||||
plat, _ = Platform.objects.get_or_create(
|
||||
name="Retro Console", group="Nintendo", icon="retro"
|
||||
)
|
||||
game, _ = Game.objects.get_or_create(
|
||||
name="Super Mario World", defaults={"platform": plat, "status": "f"}
|
||||
)
|
||||
game2, _ = Game.objects.get_or_create(
|
||||
name="Zelda", defaults={"platform": plat, "status": "u"}
|
||||
)
|
||||
|
||||
# 2. Device & Session
|
||||
dev, _ = Device.objects.get_or_create(name="Super Famicom", type="Console")
|
||||
|
||||
# Session 1: total 4 hours (3 hours calc, 1 hour manual)
|
||||
s1 = Session.objects.create(
|
||||
game=game,
|
||||
device=dev,
|
||||
timestamp_start=datetime.datetime(
|
||||
2026, 6, 1, 12, 0, 0, tzinfo=datetime.timezone.utc
|
||||
),
|
||||
timestamp_end=datetime.datetime(
|
||||
2026, 6, 1, 15, 0, 0, tzinfo=datetime.timezone.utc
|
||||
),
|
||||
duration_manual=timedelta(hours=1),
|
||||
)
|
||||
|
||||
# 3. Purchase
|
||||
pur = Purchase.objects.create(
|
||||
platform=plat,
|
||||
date_purchased=datetime.date(2026, 1, 1),
|
||||
infinite=True,
|
||||
price=49.99,
|
||||
price_currency="JPY",
|
||||
converted_price=45.00,
|
||||
converted_currency="USD",
|
||||
needs_price_update=False,
|
||||
)
|
||||
pur.games.add(game)
|
||||
|
||||
# 4. PlayEvent
|
||||
pe = PlayEvent.objects.create(
|
||||
game=game,
|
||||
started=datetime.date(2026, 6, 1),
|
||||
ended=datetime.date(2026, 6, 2),
|
||||
note="Completed 100%",
|
||||
)
|
||||
|
||||
return {
|
||||
"plat": plat,
|
||||
"game": game,
|
||||
"game2": game2,
|
||||
"dev": dev,
|
||||
"s1": s1,
|
||||
"pur": pur,
|
||||
"pe": pe,
|
||||
}
|
||||
|
||||
def test_device_filter_and_cross_entity(self):
|
||||
from games.filters import DeviceFilter
|
||||
from games.models import Device
|
||||
|
||||
data = self._setup_entities()
|
||||
# Find devices that have sessions on "Super Mario World"
|
||||
df = DeviceFilter.from_json(
|
||||
{
|
||||
"session_filter": {
|
||||
"game_filter": {
|
||||
"name": {"value": "Super Mario World", "modifier": "EQUALS"}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
results = list(Device.objects.filter(df.to_q()))
|
||||
assert data["dev"] in results
|
||||
|
||||
def test_platform_filter_and_cross_entity(self):
|
||||
from games.filters import PlatformFilter
|
||||
from games.models import Platform
|
||||
|
||||
data = self._setup_entities()
|
||||
# Find platforms with games that are finished
|
||||
pf = PlatformFilter.from_json(
|
||||
{"game_filter": {"status": {"value": ["f"], "modifier": "INCLUDES"}}}
|
||||
)
|
||||
results = list(Platform.objects.filter(pf.to_q()))
|
||||
assert data["plat"] in results
|
||||
|
||||
def test_session_filter_duration_splits(self):
|
||||
from games.filters import SessionFilter
|
||||
from games.models import Session
|
||||
|
||||
self._setup_entities()
|
||||
|
||||
# Test duration_total_hours equals 4
|
||||
sf_tot = SessionFilter.from_json(
|
||||
{"duration_total_hours": {"value": 4, "modifier": "EQUALS"}}
|
||||
)
|
||||
assert Session.objects.filter(sf_tot.to_q()).count() == 1
|
||||
|
||||
# Test duration_manual_hours equals 1
|
||||
sf_man = SessionFilter.from_json(
|
||||
{"duration_manual_hours": {"value": 1, "modifier": "EQUALS"}}
|
||||
)
|
||||
assert Session.objects.filter(sf_man.to_q()).count() == 1
|
||||
|
||||
# Test duration_calculated_hours equals 3
|
||||
sf_calc = SessionFilter.from_json(
|
||||
{"duration_calculated_hours": {"value": 3, "modifier": "EQUALS"}}
|
||||
)
|
||||
assert Session.objects.filter(sf_calc.to_q()).count() == 1
|
||||
|
||||
def test_purchase_filter_new_fields(self):
|
||||
from games.filters import PurchaseFilter
|
||||
from games.models import Purchase
|
||||
|
||||
self._setup_entities()
|
||||
|
||||
pf = PurchaseFilter.from_json(
|
||||
{
|
||||
"infinite": {"value": True, "modifier": "EQUALS"},
|
||||
"needs_price_update": {"value": False, "modifier": "EQUALS"},
|
||||
"converted_currency": {"value": "USD", "modifier": "EQUALS"},
|
||||
}
|
||||
)
|
||||
assert Purchase.objects.filter(pf.to_q()).count() == 1
|
||||
|
||||
def test_game_filter_stats_and_existence(self):
|
||||
from games.filters import GameFilter
|
||||
from games.models import Game
|
||||
|
||||
data = self._setup_entities()
|
||||
|
||||
# purchase_count == 1 (replaces removed has_purchases boolean)
|
||||
gf_pur = GameFilter.from_json(
|
||||
{"purchase_count": {"value": 1, "modifier": "EQUALS"}}
|
||||
)
|
||||
assert data["game"] in list(Game.objects.filter(gf_pur.to_q()))
|
||||
assert data["game2"] not in list(Game.objects.filter(gf_pur.to_q()))
|
||||
|
||||
# session_count = 1
|
||||
gf_cnt = GameFilter.from_json(
|
||||
{"session_count": {"value": 1, "modifier": "EQUALS"}}
|
||||
)
|
||||
assert data["game"] in list(Game.objects.filter(gf_cnt.to_q()))
|
||||
|
||||
def test_game_filter_purchase_count_range(self):
|
||||
from games.filters import GameFilter
|
||||
from games.models import Game
|
||||
|
||||
data = self._setup_entities()
|
||||
|
||||
# game has 1 purchase, game2 has 0
|
||||
gf = GameFilter.from_json(
|
||||
{"purchase_count": {"value": 1, "modifier": "EQUALS"}}
|
||||
)
|
||||
results = set(Game.objects.filter(gf.to_q()))
|
||||
assert data["game"] in results
|
||||
assert data["game2"] not in results
|
||||
|
||||
def test_game_filter_playevent_count(self):
|
||||
from games.filters import GameFilter
|
||||
from games.models import Game
|
||||
|
||||
data = self._setup_entities()
|
||||
gf = GameFilter.from_json(
|
||||
{"playevent_count": {"value": 1, "modifier": "EQUALS"}}
|
||||
)
|
||||
results = set(Game.objects.filter(gf.to_q()))
|
||||
assert data["game"] in results
|
||||
assert data["game2"] not in results
|
||||
|
||||
def test_game_filter_device(self):
|
||||
from games.filters import GameFilter
|
||||
from games.models import Game
|
||||
|
||||
data = self._setup_entities()
|
||||
gf = GameFilter.from_json(
|
||||
{"device": {"value": [data["dev"].id], "modifier": "INCLUDES"}}
|
||||
)
|
||||
results = set(Game.objects.filter(gf.to_q()))
|
||||
assert data["game"] in results
|
||||
assert data["game2"] not in results
|
||||
|
||||
def test_game_filter_platform_group(self):
|
||||
from games.filters import GameFilter
|
||||
from games.models import Game
|
||||
|
||||
data = self._setup_entities()
|
||||
gf = GameFilter.from_json(
|
||||
{"platform_group": {"value": ["Nintendo"], "modifier": "INCLUDES"}}
|
||||
)
|
||||
results = set(Game.objects.filter(gf.to_q()))
|
||||
# both games are on the same Nintendo platform
|
||||
assert data["game"] in results
|
||||
assert data["game2"] in results
|
||||
|
||||
def test_game_filter_session_emulated(self):
|
||||
from games.filters import GameFilter
|
||||
from games.models import Game, Session
|
||||
import datetime
|
||||
from datetime import timedelta
|
||||
|
||||
data = self._setup_entities()
|
||||
Session.objects.create(
|
||||
game=data["game2"],
|
||||
device=data["dev"],
|
||||
timestamp_start=datetime.datetime(
|
||||
2026, 6, 2, 12, 0, 0, tzinfo=datetime.timezone.utc
|
||||
),
|
||||
timestamp_end=datetime.datetime(
|
||||
2026, 6, 2, 12, 30, 0, tzinfo=datetime.timezone.utc
|
||||
),
|
||||
duration_manual=timedelta(0),
|
||||
emulated=True,
|
||||
)
|
||||
gf = GameFilter.from_json(
|
||||
{"session_emulated": {"value": True, "modifier": "EQUALS"}}
|
||||
)
|
||||
results = set(Game.objects.filter(gf.to_q()))
|
||||
assert data["game2"] in results
|
||||
assert data["game"] not in results
|
||||
|
||||
def test_game_filter_purchase_refunded_and_infinite(self):
|
||||
from games.filters import GameFilter
|
||||
from games.models import Game, Purchase
|
||||
import datetime
|
||||
|
||||
data = self._setup_entities()
|
||||
# data["pur"] is infinite=True, non-refunded.
|
||||
gf_inf = GameFilter.from_json(
|
||||
{"purchase_infinite": {"value": True, "modifier": "EQUALS"}}
|
||||
)
|
||||
assert data["game"] in set(Game.objects.filter(gf_inf.to_q()))
|
||||
assert data["game2"] not in set(Game.objects.filter(gf_inf.to_q()))
|
||||
|
||||
# Add a refunded purchase for game2.
|
||||
refunded = Purchase.objects.create(
|
||||
platform=data["plat"],
|
||||
date_purchased=datetime.date(2026, 1, 1),
|
||||
date_refunded=datetime.date(2026, 2, 1),
|
||||
price=10.0,
|
||||
price_currency="USD",
|
||||
converted_price=10.0,
|
||||
converted_currency="USD",
|
||||
)
|
||||
refunded.games.add(data["game2"])
|
||||
gf_ref = GameFilter.from_json(
|
||||
{"purchase_refunded": {"value": True, "modifier": "EQUALS"}}
|
||||
)
|
||||
results = set(Game.objects.filter(gf_ref.to_q()))
|
||||
assert data["game2"] in results
|
||||
assert data["game"] not in results
|
||||
|
||||
def test_game_filter_purchase_type_and_ownership(self):
|
||||
from games.filters import GameFilter
|
||||
from games.models import Game
|
||||
|
||||
data = self._setup_entities()
|
||||
# data["pur"] defaults to type=game, ownership_type=digital
|
||||
gf = GameFilter.from_json(
|
||||
{"purchase_type": {"value": ["game"], "modifier": "INCLUDES"}}
|
||||
)
|
||||
assert data["game"] in set(Game.objects.filter(gf.to_q()))
|
||||
|
||||
gf = GameFilter.from_json(
|
||||
{"purchase_ownership_type": {"value": ["di"], "modifier": "INCLUDES"}}
|
||||
)
|
||||
assert data["game"] in set(Game.objects.filter(gf.to_q()))
|
||||
|
||||
def test_game_filter_purchase_price_any_and_total(self):
|
||||
from games.filters import GameFilter
|
||||
from games.models import Game
|
||||
|
||||
data = self._setup_entities()
|
||||
# data["pur"] has converted_price=45.00 linked to data["game"]
|
||||
gf_any = GameFilter.from_json(
|
||||
{
|
||||
"purchase_price_any": {
|
||||
"value": 40.0,
|
||||
"value2": 50.0,
|
||||
"modifier": "BETWEEN",
|
||||
}
|
||||
}
|
||||
)
|
||||
results = set(Game.objects.filter(gf_any.to_q()))
|
||||
assert data["game"] in results
|
||||
assert data["game2"] not in results
|
||||
|
||||
gf_total = GameFilter.from_json(
|
||||
{
|
||||
"purchase_price_total": {
|
||||
"value": 40.0,
|
||||
"value2": 50.0,
|
||||
"modifier": "BETWEEN",
|
||||
}
|
||||
}
|
||||
)
|
||||
results = set(Game.objects.filter(gf_total.to_q()))
|
||||
assert data["game"] in results
|
||||
assert data["game2"] not in results
|
||||
|
||||
def test_game_filter_playevent_note_includes(self):
|
||||
from games.filters import GameFilter
|
||||
from games.models import Game
|
||||
|
||||
data = self._setup_entities()
|
||||
# data["pe"] has note="Completed 100%" on data["game"]
|
||||
gf = GameFilter.from_json(
|
||||
{
|
||||
"playevent_note": {
|
||||
"value": "Completed",
|
||||
"modifier": "INCLUDES",
|
||||
}
|
||||
}
|
||||
)
|
||||
results = set(Game.objects.filter(gf.to_q()))
|
||||
assert data["game"] in results
|
||||
assert data["game2"] not in results
|
||||
|
||||
def test_game_filter_manual_and_calculated_playtime(self):
|
||||
from games.filters import GameFilter
|
||||
from games.models import Game
|
||||
|
||||
data = self._setup_entities()
|
||||
# data["s1"] has 1 hour manual + 3 hours calculated
|
||||
gf_manual = GameFilter.from_json(
|
||||
{"manual_playtime_hours": {"value": 1, "modifier": "EQUALS"}}
|
||||
)
|
||||
assert data["game"] in set(Game.objects.filter(gf_manual.to_q()))
|
||||
|
||||
gf_calc = GameFilter.from_json(
|
||||
{"calculated_playtime_hours": {"value": 3, "modifier": "EQUALS"}}
|
||||
)
|
||||
assert data["game"] in set(Game.objects.filter(gf_calc.to_q()))
|
||||
|
||||
|
||||
class TestDateCriterion:
|
||||
def test_equals(self):
|
||||
c = DateCriterion(value="2025-06-01", modifier=Modifier.EQUALS)
|
||||
assert c.to_q("date_purchased") == Q(date_purchased="2025-06-01")
|
||||
|
||||
def test_not_equals(self):
|
||||
c = DateCriterion(value="2025-06-01", modifier=Modifier.NOT_EQUALS)
|
||||
assert c.to_q("date_purchased") == ~Q(date_purchased="2025-06-01")
|
||||
|
||||
def test_greater_than(self):
|
||||
c = DateCriterion(value="2025-06-01", modifier=Modifier.GREATER_THAN)
|
||||
assert c.to_q("date_purchased") == Q(date_purchased__gt="2025-06-01")
|
||||
|
||||
def test_less_than(self):
|
||||
c = DateCriterion(value="2025-06-01", modifier=Modifier.LESS_THAN)
|
||||
assert c.to_q("date_purchased") == Q(date_purchased__lt="2025-06-01")
|
||||
|
||||
def test_between(self):
|
||||
c = DateCriterion(
|
||||
value="2025-01-01", value2="2025-12-31", modifier=Modifier.BETWEEN
|
||||
)
|
||||
assert c.to_q("date_purchased") == Q(
|
||||
date_purchased__gte="2025-01-01", date_purchased__lte="2025-12-31"
|
||||
)
|
||||
|
||||
def test_between_missing_value2_raises(self):
|
||||
c = DateCriterion(value="2025-01-01", modifier=Modifier.BETWEEN)
|
||||
with pytest.raises(ValueError, match="BETWEEN requires value2"):
|
||||
c.to_q("date_purchased")
|
||||
|
||||
def test_not_between(self):
|
||||
c = DateCriterion(
|
||||
value="2025-01-01", value2="2025-12-31", modifier=Modifier.NOT_BETWEEN
|
||||
)
|
||||
assert c.to_q("date_purchased") == Q(date_purchased__lt="2025-01-01") | Q(
|
||||
date_purchased__gt="2025-12-31"
|
||||
)
|
||||
|
||||
def test_not_between_missing_value2_raises(self):
|
||||
c = DateCriterion(value="2025-01-01", modifier=Modifier.NOT_BETWEEN)
|
||||
with pytest.raises(ValueError, match="NOT_BETWEEN requires value2"):
|
||||
c.to_q("date_purchased")
|
||||
|
||||
def test_is_null(self):
|
||||
c = DateCriterion(value="", modifier=Modifier.IS_NULL)
|
||||
assert c.to_q("date_refunded") == Q(date_refunded__isnull=True)
|
||||
|
||||
def test_not_null(self):
|
||||
c = DateCriterion(value="", modifier=Modifier.NOT_NULL)
|
||||
assert c.to_q("date_refunded") == Q(date_refunded__isnull=False)
|
||||
|
||||
def test_unsupported_modifier_raises(self):
|
||||
c = DateCriterion(value="2025-06-01", modifier=Modifier.INCLUDES)
|
||||
with pytest.raises(ValueError, match="Unsupported modifier"):
|
||||
c.to_q("date_purchased")
|
||||
|
||||
def test_round_trip_json(self):
|
||||
"""Dataclass → dict → dataclass survives unchanged for a full BETWEEN."""
|
||||
original = DateCriterion(
|
||||
value="2025-06-01", value2="2025-12-31", modifier=Modifier.BETWEEN
|
||||
)
|
||||
as_dict = original.to_json()
|
||||
assert as_dict == {
|
||||
"value": "2025-06-01",
|
||||
"value2": "2025-12-31",
|
||||
"modifier": Modifier.BETWEEN,
|
||||
}
|
||||
restored = DateCriterion.from_json(
|
||||
{
|
||||
"value": "2025-06-01",
|
||||
"value2": "2025-12-31",
|
||||
"modifier": "BETWEEN",
|
||||
}
|
||||
)
|
||||
assert restored == original
|
||||
|
||||
|
||||
class TestPurchaseFilterDates:
|
||||
"""End-to-end: a PurchaseFilter built from JSON narrows the queryset
|
||||
correctly across the two DateCriterion fields and composes with
|
||||
BoolCriterion (is_refunded)."""
|
||||
|
||||
def _seed(self):
|
||||
import datetime
|
||||
|
||||
from games.models import Platform, Purchase
|
||||
|
||||
platform, _ = Platform.objects.get_or_create(name="Test", icon="test")
|
||||
early = Purchase.objects.create(
|
||||
platform=platform, date_purchased=datetime.date(2024, 1, 15)
|
||||
)
|
||||
mid = Purchase.objects.create(
|
||||
platform=platform,
|
||||
date_purchased=datetime.date(2024, 6, 15),
|
||||
date_refunded=datetime.date(2024, 7, 1),
|
||||
)
|
||||
late = Purchase.objects.create(
|
||||
platform=platform, date_purchased=datetime.date(2025, 1, 15)
|
||||
)
|
||||
return {"early": early, "mid": mid, "late": late}
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_date_purchased_between(self):
|
||||
from games.filters import PurchaseFilter
|
||||
from games.models import Purchase
|
||||
|
||||
seeded = self._seed()
|
||||
pf = PurchaseFilter.from_json(
|
||||
{
|
||||
"date_purchased": {
|
||||
"value": "2024-01-01",
|
||||
"value2": "2024-12-31",
|
||||
"modifier": "BETWEEN",
|
||||
}
|
||||
}
|
||||
)
|
||||
results = set(Purchase.objects.filter(pf.to_q()))
|
||||
assert results == {seeded["early"], seeded["mid"]}
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_date_purchased_greater_than(self):
|
||||
from games.filters import PurchaseFilter
|
||||
from games.models import Purchase
|
||||
|
||||
seeded = self._seed()
|
||||
pf = PurchaseFilter.from_json(
|
||||
{
|
||||
"date_purchased": {
|
||||
"value": "2024-06-15",
|
||||
"modifier": "GREATER_THAN",
|
||||
}
|
||||
}
|
||||
)
|
||||
results = set(Purchase.objects.filter(pf.to_q()))
|
||||
assert results == {seeded["late"]}
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_date_refunded_is_null(self):
|
||||
from games.filters import PurchaseFilter
|
||||
from games.models import Purchase
|
||||
|
||||
seeded = self._seed()
|
||||
pf = PurchaseFilter.from_json(
|
||||
{"date_refunded": {"value": "", "modifier": "IS_NULL"}}
|
||||
)
|
||||
results = set(Purchase.objects.filter(pf.to_q()))
|
||||
assert results == {seeded["early"], seeded["late"]}
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_date_refunded_not_null(self):
|
||||
from games.filters import PurchaseFilter
|
||||
from games.models import Purchase
|
||||
|
||||
seeded = self._seed()
|
||||
pf = PurchaseFilter.from_json(
|
||||
{"date_refunded": {"value": "", "modifier": "NOT_NULL"}}
|
||||
)
|
||||
results = set(Purchase.objects.filter(pf.to_q()))
|
||||
assert results == {seeded["mid"]}
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_purchased_between_and_refunded_not_null(self):
|
||||
"""AND-composition: only the mid purchase satisfies both."""
|
||||
from games.filters import PurchaseFilter
|
||||
from games.models import Purchase
|
||||
|
||||
seeded = self._seed()
|
||||
pf = PurchaseFilter.from_json(
|
||||
{
|
||||
"date_purchased": {
|
||||
"value": "2024-01-01",
|
||||
"value2": "2024-12-31",
|
||||
"modifier": "BETWEEN",
|
||||
},
|
||||
"date_refunded": {"value": "", "modifier": "NOT_NULL"},
|
||||
}
|
||||
)
|
||||
results = set(Purchase.objects.filter(pf.to_q()))
|
||||
assert results == {seeded["mid"]}
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_purchase_filter_json_round_trip(self):
|
||||
"""PurchaseFilter with both DateCriterion fields and is_refunded
|
||||
survives a json → object → json round-trip — confirms
|
||||
DateCriterion is dispatched correctly by OperatorFilter.from_json
|
||||
via the criterion_types lookup."""
|
||||
from games.filters import PurchaseFilter
|
||||
|
||||
payload = {
|
||||
"date_purchased": {
|
||||
"value": "2024-01-01",
|
||||
"value2": "2024-12-31",
|
||||
"modifier": "BETWEEN",
|
||||
},
|
||||
"date_refunded": {"value": "", "modifier": "NOT_NULL"},
|
||||
"is_refunded": {"value": True, "modifier": "EQUALS"},
|
||||
}
|
||||
pf = PurchaseFilter.from_json(payload)
|
||||
assert isinstance(pf.date_purchased, DateCriterion)
|
||||
assert isinstance(pf.date_refunded, DateCriterion)
|
||||
# round-trip back out
|
||||
out = pf.to_json()
|
||||
assert out["date_purchased"]["value"] == "2024-01-01"
|
||||
assert out["date_purchased"]["value2"] == "2024-12-31"
|
||||
assert out["date_purchased"]["modifier"] == Modifier.BETWEEN
|
||||
assert out["date_refunded"]["modifier"] == Modifier.NOT_NULL
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
"""Phase 1: the lazy node layer (Node/Element/Safe/Fragment/BaseComponent/Media).
|
||||
|
||||
These cover the new machinery directly: rendering, escaping, media bubbling.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from common.components import (
|
||||
BaseComponent,
|
||||
Element,
|
||||
Fragment,
|
||||
Media,
|
||||
Node,
|
||||
Safe,
|
||||
collect_media,
|
||||
render,
|
||||
)
|
||||
|
||||
|
||||
class ElementRenderTest(unittest.TestCase):
|
||||
def test_renders_tag_attrs_children(self):
|
||||
element = Element("div", [("class", "test")], "hello")
|
||||
self.assertEqual(render(element), '<div class="test">hello</div>')
|
||||
|
||||
def test_plain_string_children_escaped(self):
|
||||
self.assertEqual(
|
||||
render(Element("span", children=["<b>"])), "<span><b></span>"
|
||||
)
|
||||
|
||||
def test_safe_node_child_passes_through(self):
|
||||
self.assertEqual(
|
||||
render(Element("span", children=[Safe("<b>x</b>")])),
|
||||
"<span><b>x</b></span>",
|
||||
)
|
||||
|
||||
def test_safetext_child_is_escaped(self):
|
||||
# A string child is always escaped — even a mark_safe/SafeText one.
|
||||
# Trusted markup must be a Safe node, not a safe string.
|
||||
self.assertEqual(
|
||||
render(Element("span", children=[mark_safe("<b>x</b>")])),
|
||||
"<span><b>x</b></span>",
|
||||
)
|
||||
|
||||
def test_node_children_render_safely(self):
|
||||
inner = Element("b", children=["x"])
|
||||
self.assertEqual(
|
||||
render(Element("span", children=[inner])), "<span><b>x</b></span>"
|
||||
)
|
||||
|
||||
|
||||
class SafeAndFragmentTest(unittest.TestCase):
|
||||
def test_safe_passes_html_through(self):
|
||||
self.assertEqual(render(Safe("<i>raw</i>")), "<i>raw</i>")
|
||||
|
||||
def test_fragment_concatenates(self):
|
||||
frag = Fragment(
|
||||
Element("span", children=["a"]), Element("span", children=["b"])
|
||||
)
|
||||
self.assertEqual(render(frag), "<span>a</span><span>b</span>")
|
||||
|
||||
def test_fragment_skips_empty_children(self):
|
||||
frag = Fragment("", None, Element("span", children=["a"]))
|
||||
self.assertEqual(render(frag), "<span>a</span>")
|
||||
|
||||
def test_fragment_escapes_plain_strings(self):
|
||||
self.assertEqual(render(Fragment("<x>", Safe("<y>"))), "<x><y>")
|
||||
|
||||
|
||||
class MediaTest(unittest.TestCase):
|
||||
def test_merge_dedups_preserving_order(self):
|
||||
merged = Media(js=["a.js", "b.js"]) + Media(js=["b.js", "c.js"])
|
||||
self.assertEqual(merged.js, ("a.js", "b.js", "c.js"))
|
||||
|
||||
def test_external_kept_separate(self):
|
||||
merged = Media(js=["a.js"]) + Media(js_external=["umd.js"])
|
||||
self.assertEqual(merged.js, ("a.js",))
|
||||
self.assertEqual(merged.js_external, ("umd.js",))
|
||||
|
||||
def test_sum_with_radd(self):
|
||||
merged = sum([Media(js=["a.js"]), Media(js=["b.js"])], Media())
|
||||
self.assertEqual(merged.js, ("a.js", "b.js"))
|
||||
|
||||
def test_falsy_when_empty(self):
|
||||
self.assertFalse(Media())
|
||||
self.assertTrue(Media(js=["a.js"]))
|
||||
|
||||
|
||||
class MediaCollectionTest(unittest.TestCase):
|
||||
def test_bubbles_through_element_children(self):
|
||||
class Widget(BaseComponent):
|
||||
media = Media(js=["widget.js"])
|
||||
|
||||
def render(self) -> Node:
|
||||
return Element("div", children=["x"])
|
||||
|
||||
tree = Element("section", children=[Element("div", children=[Widget()])])
|
||||
self.assertEqual(collect_media(tree).js, ("widget.js",))
|
||||
|
||||
def test_bubbles_through_fragment(self):
|
||||
class Widget(BaseComponent):
|
||||
media = Media(js=["w.js"])
|
||||
|
||||
def render(self) -> Node:
|
||||
return Element("div")
|
||||
|
||||
self.assertEqual(collect_media(Fragment(Widget(), Element("p"))).js, ("w.js",))
|
||||
|
||||
def test_component_merges_own_and_subtree_media(self):
|
||||
class Inner(BaseComponent):
|
||||
media = Media(js=["inner.js"])
|
||||
|
||||
def render(self) -> Node:
|
||||
return Element("span")
|
||||
|
||||
class Outer(BaseComponent):
|
||||
media = Media(js=["outer.js"])
|
||||
|
||||
def render(self) -> Node:
|
||||
return Element("div", children=[Inner()])
|
||||
|
||||
self.assertEqual(collect_media(Outer()).js, ("outer.js", "inner.js"))
|
||||
|
||||
def test_bare_string_has_no_media(self):
|
||||
self.assertFalse(collect_media("just a string"))
|
||||
|
||||
|
||||
class RealComponentMediaTest(unittest.TestCase):
|
||||
"""Phase 3: JS-bearing components declare media that bubbles up the tree."""
|
||||
|
||||
def test_search_select_declares_its_script(self):
|
||||
from common.components import SearchSelect
|
||||
|
||||
self.assertEqual(
|
||||
collect_media(SearchSelect(name="games")).js, ("search_select.js",)
|
||||
)
|
||||
|
||||
def test_filter_select_declares_its_script(self):
|
||||
from common.components import FilterSelect
|
||||
|
||||
self.assertIn(
|
||||
"search_select.js", collect_media(FilterSelect(field_name="type")).js
|
||||
)
|
||||
|
||||
def test_date_range_picker_declares_its_script(self):
|
||||
from common.components import DateRangePicker
|
||||
|
||||
media = collect_media(
|
||||
DateRangePicker(label="Played", input_name_prefix="played")
|
||||
)
|
||||
self.assertEqual(media.js, ("date_range_picker.js",))
|
||||
|
||||
def test_range_slider_declares_its_script(self):
|
||||
from common.components.filters import RangeSlider
|
||||
|
||||
media = collect_media(
|
||||
RangeSlider(
|
||||
label="Year", input_name_prefix="year", range_min=2000, range_max=2025
|
||||
)
|
||||
)
|
||||
self.assertEqual(media.js, ("range_slider.js",))
|
||||
|
||||
def test_filter_bar_collects_chrome_and_widget_media(self):
|
||||
"""A FilterBar's media merges its own chrome script with the scripts that
|
||||
bubble up from the FilterSelect and RangeSlider widgets it contains —
|
||||
exactly the set the view used to thread by hand. (FilterBar wraps its DB
|
||||
aggregates in try/except, so it builds without a database.)"""
|
||||
from common.components import FilterBar
|
||||
|
||||
media = collect_media(FilterBar())
|
||||
self.assertIn("filter_bar.js", media.js)
|
||||
self.assertIn("search_select.js", media.js)
|
||||
self.assertIn("range_slider.js", media.js)
|
||||
|
||||
|
||||
class HtpyStyleSugarTest(unittest.TestCase):
|
||||
def test_getitem_sets_children(self):
|
||||
from common.components import Div, Span
|
||||
|
||||
self.assertEqual(
|
||||
render(Div(class_="card")[Span()["hi"]]),
|
||||
'<div class="card"><span>hi</span></div>',
|
||||
)
|
||||
|
||||
def test_getitem_multiple_children(self):
|
||||
from common.components import Div
|
||||
|
||||
self.assertEqual(render(Div()["a", "b"]), "<div>a\nb</div>")
|
||||
|
||||
def test_kwargs_class_underscore_becomes_class(self):
|
||||
from common.components import Div
|
||||
|
||||
self.assertIn('class="x"', render(Div(class_="x")))
|
||||
|
||||
def test_kwargs_inner_underscore_becomes_hyphen(self):
|
||||
from common.components import Div
|
||||
|
||||
self.assertIn('hx-get="/y"', render(Div(hx_get="/y")))
|
||||
|
||||
def test_kwargs_true_renders_bare_attr(self):
|
||||
from common.components import Div
|
||||
|
||||
self.assertIn('hidden="hidden"', render(Div(hidden=True)))
|
||||
|
||||
def test_kwargs_false_and_none_omitted(self):
|
||||
from common.components import Div
|
||||
|
||||
html = render(Div(hidden=False, title=None))
|
||||
self.assertNotIn("hidden", html)
|
||||
self.assertNotIn("title", html)
|
||||
|
||||
def test_getitem_preserves_media(self):
|
||||
from common.components import Div, Media, collect_media
|
||||
|
||||
node = Div(class_="x").with_media(Media(js=("a.js",)))["child"]
|
||||
self.assertEqual(collect_media(node).js, ("a.js",))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -60,3 +60,16 @@ class PathWorksTest(TestCase):
|
||||
def test_list_purchases_returns_200(self):
|
||||
response = self.client.get(reverse("games:list_purchases"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_platform_groups_api_returns_200(self):
|
||||
# Distinct platform groups are returned as string-valued options.
|
||||
Platform.objects.create(name="Switch", icon="switch", group="Nintendo")
|
||||
response = self.client.get("/api/platforms/groups")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = response.json()
|
||||
groups = {item["value"] for item in body}
|
||||
self.assertIn("Nintendo", groups)
|
||||
|
||||
filtered = self.client.get("/api/platforms/groups?q=nin")
|
||||
self.assertEqual(filtered.status_code, 200)
|
||||
self.assertEqual({item["value"] for item in filtered.json()}, {"Nintendo"})
|
||||
|
||||
@@ -57,6 +57,22 @@ class RenderedPagesTest(TestCase):
|
||||
marker, html, f"Found double-escaped markup ({marker!r}) in output"
|
||||
)
|
||||
|
||||
# --- scripts auto-collected from component media (Phase 4) ---------------
|
||||
|
||||
def test_list_page_auto_loads_widget_scripts(self):
|
||||
"""The games list view passes no scripts= argument; the filter bar's
|
||||
components declare their JS and Page() collects it."""
|
||||
html = self.get("games:list_games").content.decode()
|
||||
self.assertIn("js/filter_bar.js", html)
|
||||
self.assertIn("js/search_select.js", html)
|
||||
self.assertIn("js/range_slider.js", html)
|
||||
|
||||
def test_stats_page_auto_loads_datepicker(self):
|
||||
"""YearPicker declares the datepicker UMD bundle as media; the stats
|
||||
view no longer hoists it by hand."""
|
||||
html = self.get("games:stats_alltime").content.decode()
|
||||
self.assertIn("js/datepicker.umd.js", html)
|
||||
|
||||
# --- layout wrapper ------------------------------------------------------
|
||||
|
||||
def test_page_layout_wrapper(self):
|
||||
@@ -123,7 +139,7 @@ class RenderedPagesTest(TestCase):
|
||||
|
||||
def test_add_session_form_has_timestamp_helpers(self):
|
||||
html = self.get("games:add_session").content.decode()
|
||||
self.assertIn("add_session.js", html)
|
||||
self.assertIn("session-timestamp-buttons", html)
|
||||
for marker in [
|
||||
"Set to now",
|
||||
"Toggle text",
|
||||
@@ -152,7 +168,7 @@ class RenderedPagesTest(TestCase):
|
||||
"Platform",
|
||||
'id="history-container"',
|
||||
"status-changed from:body",
|
||||
"createPlayEvent", # the played-row Alpine dropdown script
|
||||
"<play-event-row", # the played-row custom element
|
||||
'hx-target="#global-modal-container"', # delete trigger
|
||||
"Purchases",
|
||||
"Sessions",
|
||||
@@ -163,6 +179,14 @@ class RenderedPagesTest(TestCase):
|
||||
self.assertNoEscapedTags(html)
|
||||
self.assertEqual(html.count("<div"), html.count("</div>"))
|
||||
|
||||
def test_view_game_uses_play_event_row_element(self):
|
||||
game = Game.objects.create(name="Played Game", platform=self.platform)
|
||||
html = self.get("games:view_game", game.id).content.decode()
|
||||
self.assertIn("<play-event-row", html)
|
||||
self.assertIn('game-id="', html)
|
||||
self.assertNotIn("@@", html) # token-replace hack gone
|
||||
self.assertNotIn("createPlayEvent", html) # the old Alpine fn is gone
|
||||
|
||||
def test_view_game_empty_sections(self):
|
||||
"""A game with no sessions/purchases/etc shows the empty messages."""
|
||||
lonely = Game.objects.create(name="Lonely Game", platform=self.platform)
|
||||
@@ -290,3 +314,149 @@ class RenderedPagesTest(TestCase):
|
||||
self.assertNoEscapedTags(html)
|
||||
# The Python builder emits well-formed, balanced markup.
|
||||
self.assertEqual(html.count("<div"), html.count("</div>"))
|
||||
|
||||
|
||||
class PurchaseListDateFilterTest(TestCase):
|
||||
"""End-to-end: GET /tracker/purchase/list?filter=… narrows the rendered
|
||||
list and pre-fills the date inputs from the URL filter.
|
||||
|
||||
Replaces the manual curl smoke that earlier verified the same path.
|
||||
"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
import datetime
|
||||
|
||||
self.user = User.objects.create_superuser(
|
||||
username="datetester", email="dt@example.com", password="testpass"
|
||||
)
|
||||
self.client.force_login(self.user)
|
||||
self.platform = Platform.objects.create(name="DateP", icon="datep")
|
||||
# Markers are placed on the Game name because LinkedPurchase renders
|
||||
# the linked game's name (purchase.name doesn't surface in the list row).
|
||||
early_game = Game.objects.create(name="EARLY-MARKER", platform=self.platform)
|
||||
mid_game = Game.objects.create(name="MID-MARKER", platform=self.platform)
|
||||
late_game = Game.objects.create(name="LATE-MARKER", platform=self.platform)
|
||||
self.early = Purchase.objects.create(
|
||||
platform=self.platform, date_purchased=datetime.date(2024, 1, 15)
|
||||
)
|
||||
self.early.games.add(early_game)
|
||||
self.mid = Purchase.objects.create(
|
||||
platform=self.platform,
|
||||
date_purchased=datetime.date(2024, 6, 15),
|
||||
date_refunded=datetime.date(2024, 7, 1),
|
||||
)
|
||||
self.mid.games.add(mid_game)
|
||||
self.late = Purchase.objects.create(
|
||||
platform=self.platform, date_purchased=datetime.date(2025, 1, 15)
|
||||
)
|
||||
self.late.games.add(late_game)
|
||||
|
||||
def _get(self, filter_obj=None, raw_filter=None):
|
||||
import json
|
||||
|
||||
from django.urls import reverse
|
||||
|
||||
url = reverse("games:list_purchases")
|
||||
if raw_filter is not None:
|
||||
return self.client.get(url, {"filter": raw_filter})
|
||||
if filter_obj is not None:
|
||||
return self.client.get(url, {"filter": json.dumps(filter_obj)})
|
||||
return self.client.get(url)
|
||||
|
||||
def test_unfiltered_lists_all_three(self):
|
||||
html = self._get().content.decode()
|
||||
self.assertEqual(html.count("EARLY-MARKER"), 1)
|
||||
self.assertEqual(html.count("MID-MARKER"), 1)
|
||||
self.assertEqual(html.count("LATE-MARKER"), 1)
|
||||
|
||||
def test_date_purchased_between_narrows_and_prepopulates(self):
|
||||
"""BETWEEN 2024-01-01..2024-12-31 → only early + mid; both date
|
||||
inputs pre-filled with the filter bounds."""
|
||||
response = self._get(
|
||||
{
|
||||
"date_purchased": {
|
||||
"value": "2024-01-01",
|
||||
"value2": "2024-12-31",
|
||||
"modifier": "BETWEEN",
|
||||
}
|
||||
}
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
html = response.content.decode()
|
||||
self.assertIn("EARLY-MARKER", html)
|
||||
self.assertIn("MID-MARKER", html)
|
||||
self.assertNotIn("LATE-MARKER", html)
|
||||
# Pre-populated date inputs round-trip the filter bounds.
|
||||
self.assertIn(
|
||||
'name="filter-date-purchased-min" id="filter-date-purchased-min" '
|
||||
'value="2024-01-01"',
|
||||
html,
|
||||
)
|
||||
self.assertIn(
|
||||
'name="filter-date-purchased-max" id="filter-date-purchased-max" '
|
||||
'value="2024-12-31"',
|
||||
html,
|
||||
)
|
||||
|
||||
def test_date_purchased_greater_than_single_bound(self):
|
||||
"""GREATER_THAN populates min only, leaves max blank."""
|
||||
response = self._get(
|
||||
{
|
||||
"date_purchased": {
|
||||
"value": "2024-06-15",
|
||||
"modifier": "GREATER_THAN",
|
||||
}
|
||||
}
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
html = response.content.decode()
|
||||
self.assertNotIn("EARLY-MARKER", html)
|
||||
self.assertNotIn("MID-MARKER", html)
|
||||
self.assertIn("LATE-MARKER", html)
|
||||
self.assertIn(
|
||||
'name="filter-date-purchased-min" id="filter-date-purchased-min" '
|
||||
'value="2024-06-15"',
|
||||
html,
|
||||
)
|
||||
self.assertIn(
|
||||
'name="filter-date-purchased-max" id="filter-date-purchased-max" value=""',
|
||||
html,
|
||||
)
|
||||
|
||||
def test_date_refunded_not_null(self):
|
||||
response = self._get({"date_refunded": {"value": "", "modifier": "NOT_NULL"}})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
html = response.content.decode()
|
||||
self.assertNotIn("EARLY-MARKER", html)
|
||||
self.assertIn("MID-MARKER", html)
|
||||
self.assertNotIn("LATE-MARKER", html)
|
||||
|
||||
def test_combined_dates_and_is_refunded(self):
|
||||
"""date_purchased BETWEEN 2024 AND date_refunded NOT_NULL → only the
|
||||
mid purchase. Confirms AND-composition through the view layer."""
|
||||
response = self._get(
|
||||
{
|
||||
"date_purchased": {
|
||||
"value": "2024-01-01",
|
||||
"value2": "2024-12-31",
|
||||
"modifier": "BETWEEN",
|
||||
},
|
||||
"date_refunded": {"value": "", "modifier": "NOT_NULL"},
|
||||
}
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
html = response.content.decode()
|
||||
self.assertNotIn("EARLY-MARKER", html)
|
||||
self.assertIn("MID-MARKER", html)
|
||||
self.assertNotIn("LATE-MARKER", html)
|
||||
|
||||
def test_malformed_json_filter_falls_back_to_unfiltered(self):
|
||||
"""parse_purchase_filter returns None on bad JSON → view ignores
|
||||
the filter and renders the full list (no 500)."""
|
||||
response = self._get(raw_filter="this is not json")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
html = response.content.decode()
|
||||
# All three purchases are present, same as the unfiltered baseline.
|
||||
self.assertIn("EARLY-MARKER", html)
|
||||
self.assertIn("MID-MARKER", html)
|
||||
self.assertIn("LATE-MARKER", html)
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from django.contrib.sessions.models import Session as DjangoSession
|
||||
from django.core.management import call_command
|
||||
from django.test import TransactionTestCase
|
||||
from django.utils.timezone import now
|
||||
from django_q.models import Schedule
|
||||
|
||||
|
||||
class ScrubStagingTest(TransactionTestCase):
|
||||
# TransactionTestCase flushes the DB before each test instead of wrapping
|
||||
# in a savepoint. Required here because scrub_staging deletes all sessions
|
||||
# — a TestCase savepoint rollback would restore any sessions committed by
|
||||
# earlier tests (e.g. force_login in test_paths_return_200) and leak state
|
||||
# into the e2e live-server tests that follow.
|
||||
|
||||
def test_scrub_removes_sessions_and_schedules(self):
|
||||
DjangoSession.objects.create(
|
||||
session_key="copied-from-prod",
|
||||
session_data="",
|
||||
expire_date=now() + timedelta(days=1),
|
||||
)
|
||||
Schedule.objects.create(
|
||||
func="games.tasks.convert_prices",
|
||||
name="Update converted prices",
|
||||
schedule_type=Schedule.MINUTES,
|
||||
)
|
||||
|
||||
self.assertEqual(DjangoSession.objects.count(), 1)
|
||||
self.assertEqual(Schedule.objects.count(), 1)
|
||||
|
||||
call_command("scrub_staging")
|
||||
|
||||
self.assertEqual(DjangoSession.objects.count(), 0)
|
||||
self.assertEqual(Schedule.objects.count(), 0)
|
||||
|
||||
def test_scrub_is_safe_on_empty_database(self):
|
||||
call_command("scrub_staging")
|
||||
|
||||
self.assertEqual(DjangoSession.objects.count(), 0)
|
||||
self.assertEqual(Schedule.objects.count(), 0)
|
||||
+113
-85
@@ -7,57 +7,62 @@ import django.test
|
||||
from django.utils.safestring import SafeText
|
||||
|
||||
from common.components import (
|
||||
FilterSelect,
|
||||
Pill,
|
||||
SearchSelect,
|
||||
searchselect_selected,
|
||||
)
|
||||
from common.components import FilterSelect, Pill, SearchSelect
|
||||
from games.models import Game, Platform
|
||||
|
||||
# These components are lazy nodes; the tests below assert on rendered HTML, so
|
||||
# each call is wrapped in ``str(...)`` (``Node.__str__`` returns a ``SafeText``,
|
||||
# which keeps the ``assertIsInstance(..., SafeText)`` checks meaningful and the
|
||||
# string assertions working).
|
||||
|
||||
|
||||
class PillTest(unittest.TestCase):
|
||||
def test_returns_safetext(self):
|
||||
self.assertIsInstance(Pill("hi"), SafeText)
|
||||
self.assertIsInstance(str(Pill("hi")), SafeText)
|
||||
|
||||
def test_plain_pill_has_data_pill_no_remove(self):
|
||||
html = Pill("hi")
|
||||
html = str(Pill("hi"))
|
||||
self.assertIn("data-pill", html)
|
||||
self.assertNotIn("data-pill-remove", html)
|
||||
|
||||
def test_removable_adds_remove_button(self):
|
||||
html = Pill("hi", removable=True)
|
||||
html = str(Pill("hi", removable=True))
|
||||
self.assertIn("data-pill-remove", html)
|
||||
self.assertIn('aria-label="Remove"', html)
|
||||
|
||||
def test_value_becomes_data_value(self):
|
||||
html = Pill("hi", value="42")
|
||||
html = str(Pill("hi", value="42"))
|
||||
self.assertIn('data-value="42"', html)
|
||||
|
||||
def test_no_value_omits_data_value(self):
|
||||
self.assertNotIn("data-value", Pill("hi"))
|
||||
self.assertNotIn("data-value", str(Pill("hi")))
|
||||
|
||||
def test_label_is_escaped(self):
|
||||
html = Pill("<b>x</b>")
|
||||
html = str(Pill("<b>x</b>"))
|
||||
self.assertIn("<b>", html)
|
||||
self.assertNotIn("<b>x</b>", html)
|
||||
|
||||
def test_extra_data_attributes(self):
|
||||
html = Pill("hi", attributes=[("data-platform", "3")])
|
||||
html = str(Pill("hi", attributes=[("data-platform", "3")]))
|
||||
self.assertIn('data-platform="3"', html)
|
||||
|
||||
|
||||
class SearchSelectComponentTest(unittest.TestCase):
|
||||
def test_returns_safetext(self):
|
||||
self.assertIsInstance(SearchSelect(name="games"), SafeText)
|
||||
self.assertIsInstance(str(SearchSelect(name="games")), SafeText)
|
||||
|
||||
def test_empty_options_renders_no_results_scaffold(self):
|
||||
html = SearchSelect(name="games")
|
||||
html = str(SearchSelect(name="games"))
|
||||
self.assertIn("data-search-select-no-results", html)
|
||||
self.assertIn("No results", html)
|
||||
|
||||
def test_outer_container_carries_config(self):
|
||||
html = SearchSelect(
|
||||
name="games", search_url="/api/games/search", multi_select=True
|
||||
html = str(
|
||||
SearchSelect(
|
||||
name="games", search_url="/api/games/search", multi_select=True
|
||||
)
|
||||
)
|
||||
self.assertIn("data-search-select", html)
|
||||
self.assertIn('data-name="games"', html)
|
||||
@@ -65,10 +70,12 @@ class SearchSelectComponentTest(unittest.TestCase):
|
||||
self.assertIn('data-multi="true"', html)
|
||||
|
||||
def test_multi_selected_renders_pills_and_hidden_inputs(self):
|
||||
html = SearchSelect(
|
||||
name="games",
|
||||
multi_select=True,
|
||||
selected=[{"value": 7, "label": "Game A", "data": {"platform": "2"}}],
|
||||
html = str(
|
||||
SearchSelect(
|
||||
name="games",
|
||||
multi_select=True,
|
||||
selected=[{"value": 7, "label": "Game A", "data": {"platform": "2"}}],
|
||||
)
|
||||
)
|
||||
self.assertIn("data-pill", html)
|
||||
self.assertIn('<input name="games" value="7" type="hidden">', html)
|
||||
@@ -78,9 +85,11 @@ class SearchSelectComponentTest(unittest.TestCase):
|
||||
self.assertEqual(html.count(' name="games"'), 1)
|
||||
|
||||
def test_single_selected_has_no_pill_and_value_in_search_box(self):
|
||||
html = SearchSelect(
|
||||
name="games",
|
||||
selected=[{"value": 7, "label": "Game A", "data": {"platform": "2"}}],
|
||||
html = str(
|
||||
SearchSelect(
|
||||
name="games",
|
||||
selected=[{"value": 7, "label": "Game A", "data": {"platform": "2"}}],
|
||||
)
|
||||
)
|
||||
# single-select renders no pill — the label lives in the search box
|
||||
self.assertNotIn("data-pill", html)
|
||||
@@ -90,20 +99,22 @@ class SearchSelectComponentTest(unittest.TestCase):
|
||||
self.assertEqual(html.count(' name="games"'), 1)
|
||||
|
||||
def test_search_box_has_no_name(self):
|
||||
html = SearchSelect(name="games")
|
||||
html = str(SearchSelect(name="games"))
|
||||
self.assertIn("data-search-select-search", html)
|
||||
# container exposes data-name, never a submittable name on the search box
|
||||
self.assertEqual(html.count(' name="games"'), 0)
|
||||
|
||||
def test_tuple_options_are_normalized(self):
|
||||
html = SearchSelect(name="t", options=[("1", "One")])
|
||||
html = str(SearchSelect(name="t", options=[("1", "One")]))
|
||||
self.assertIn('data-search-select-option=""', html)
|
||||
self.assertIn('data-value="1"', html)
|
||||
self.assertIn("One", html)
|
||||
|
||||
def test_options_omitted_when_search_url_set(self):
|
||||
html = SearchSelect(
|
||||
name="t", options=[("1", "One")], search_url="/api/games/search"
|
||||
html = str(
|
||||
SearchSelect(
|
||||
name="t", options=[("1", "One")], search_url="/api/games/search"
|
||||
)
|
||||
)
|
||||
# No pre-rendered rows in the live panel; the row prototype lives only in
|
||||
# the cloneable <template>.
|
||||
@@ -114,7 +125,9 @@ class SearchSelectComponentTest(unittest.TestCase):
|
||||
def test_templates_carry_label_slot_for_js_cloning(self):
|
||||
# The dynamic shapes the JS clones expose a [data-search-select-label] slot so the JS
|
||||
# only fills text — classes/structure stay server-side.
|
||||
html = SearchSelect(name="t", search_url="/api/games/search", multi_select=True)
|
||||
html = str(
|
||||
SearchSelect(name="t", search_url="/api/games/search", multi_select=True)
|
||||
)
|
||||
self.assertIn('data-search-select-template="row"', html)
|
||||
self.assertIn('data-search-select-template="pill"', html)
|
||||
self.assertIn("data-search-select-label", html)
|
||||
@@ -122,7 +135,7 @@ class SearchSelectComponentTest(unittest.TestCase):
|
||||
def test_shell_region_order_pills_search_options(self):
|
||||
# The shared shell assembles the three regions in a fixed order; option
|
||||
# rows precede the trailing no-results node inside the options panel.
|
||||
html = SearchSelect(name="t", options=[("1", "One")])
|
||||
html = str(SearchSelect(name="t", options=[("1", "One")]))
|
||||
pills = html.index("data-search-select-pills")
|
||||
search = html.index("data-search-select-search")
|
||||
options = html.index("data-search-select-options")
|
||||
@@ -133,15 +146,24 @@ class SearchSelectComponentTest(unittest.TestCase):
|
||||
self.assertLess(options, option_row)
|
||||
self.assertLess(option_row, no_results)
|
||||
|
||||
def test_prefetch_attribute_and_defaults(self):
|
||||
# Default prefetch is 0 in SearchSelect
|
||||
html_default = str(SearchSelect(name="t"))
|
||||
self.assertIn('data-prefetch="0"', html_default)
|
||||
|
||||
# Custom prefetch is rendered
|
||||
html_custom = str(SearchSelect(name="t", prefetch=42))
|
||||
self.assertIn('data-prefetch="42"', html_custom)
|
||||
|
||||
|
||||
class FilterSelectComponentTest(unittest.TestCase):
|
||||
MODIFIERS = [("NOT_NULL", "(Any)"), ("IS_NULL", "(None)")]
|
||||
|
||||
def test_returns_safetext(self):
|
||||
self.assertIsInstance(FilterSelect(field_name="type"), SafeText)
|
||||
self.assertIsInstance(str(FilterSelect(field_name="type")), SafeText)
|
||||
|
||||
def test_is_filter_mode_on_shared_shell(self):
|
||||
html = FilterSelect(field_name="type")
|
||||
html = str(FilterSelect(field_name="type"))
|
||||
# Reuses the SearchSelect shell (data-search-select) but flags filter mode.
|
||||
self.assertIn("data-search-select", html)
|
||||
self.assertIn('data-search-select-mode="filter"', html)
|
||||
@@ -150,17 +172,19 @@ class FilterSelectComponentTest(unittest.TestCase):
|
||||
self.assertEqual(html.count(' name="type"'), 0)
|
||||
|
||||
def test_value_rows_have_include_exclude_buttons(self):
|
||||
html = FilterSelect(field_name="type", options=[("g", "Game")])
|
||||
html = str(FilterSelect(field_name="type", options=[("g", "Game")]))
|
||||
self.assertIn('data-search-select-action="include"', html)
|
||||
self.assertIn('data-search-select-action="exclude"', html)
|
||||
self.assertIn('data-value="g"', html)
|
||||
|
||||
def test_included_renders_check_pill_excluded_renders_cross_pill(self):
|
||||
html = FilterSelect(
|
||||
field_name="platform",
|
||||
options=[("1", "Steam"), ("2", "GOG")],
|
||||
included=[("1", "Steam")],
|
||||
excluded=[("2", "GOG")],
|
||||
html = str(
|
||||
FilterSelect(
|
||||
field_name="platform",
|
||||
options=[("1", "Steam"), ("2", "GOG")],
|
||||
included=[("1", "Steam")],
|
||||
excluded=[("2", "GOG")],
|
||||
)
|
||||
)
|
||||
# Labels live in a [data-search-select-label] slot (so JS can fill clones); the ✓/✗
|
||||
# symbol is a sibling text node.
|
||||
@@ -173,7 +197,7 @@ class FilterSelectComponentTest(unittest.TestCase):
|
||||
self.assertIn("line-through", html) # excluded pill styling
|
||||
|
||||
def test_modifier_options_render_pinned_rows(self):
|
||||
html = FilterSelect(field_name="platform", modifier_options=self.MODIFIERS)
|
||||
html = str(FilterSelect(field_name="platform", modifier_options=self.MODIFIERS))
|
||||
# Pinned pseudo-options carry data-search-select-modifier-option, never data-search-select-option,
|
||||
# so the text filter leaves them visible.
|
||||
self.assertIn('data-search-select-modifier-option="NOT_NULL"', html)
|
||||
@@ -182,27 +206,29 @@ class FilterSelectComponentTest(unittest.TestCase):
|
||||
def test_modifier_pill_coexists_with_value_pills(self):
|
||||
"""Modifier and value pills both render server-side; the JS handles
|
||||
mutual exclusivity for presence modifiers (PRESENCE_MODIFIERS)."""
|
||||
html = FilterSelect(
|
||||
field_name="platform",
|
||||
options=[("1", "Steam")],
|
||||
included=[("1", "Steam")],
|
||||
modifier="IS_NULL",
|
||||
modifier_options=self.MODIFIERS,
|
||||
html = str(
|
||||
FilterSelect(
|
||||
field_name="platform",
|
||||
options=[("1", "Steam")],
|
||||
included=[("1", "Steam")],
|
||||
modifier="IS_NULL",
|
||||
modifier_options=self.MODIFIERS,
|
||||
)
|
||||
)
|
||||
# Both the modifier pill and the value pill render.
|
||||
self.assertIn('data-search-select-modifier="IS_NULL"', html)
|
||||
self.assertIn("(None)", html)
|
||||
self.assertIn(
|
||||
'data-search-select-type="include"', html
|
||||
) # value pill present
|
||||
self.assertIn('data-search-select-type="include"', html) # value pill present
|
||||
self.assertIn('data-modifier="IS_NULL"', html) # container carries it too
|
||||
|
||||
def test_search_url_omits_value_rows_but_keeps_modifiers(self):
|
||||
html = FilterSelect(
|
||||
field_name="game",
|
||||
search_url="/api/games/search",
|
||||
prefetch=20,
|
||||
modifier_options=self.MODIFIERS,
|
||||
html = str(
|
||||
FilterSelect(
|
||||
field_name="game",
|
||||
search_url="/api/games/search",
|
||||
prefetch=20,
|
||||
modifier_options=self.MODIFIERS,
|
||||
)
|
||||
)
|
||||
# No value rows in the live panel (they're fetched); the row prototype
|
||||
# lives only in a <template>.
|
||||
@@ -216,10 +242,12 @@ class FilterSelectComponentTest(unittest.TestCase):
|
||||
|
||||
def test_search_url_pills_use_resolved_labels(self):
|
||||
# A selected value outside the fetched window still shows its label.
|
||||
html = FilterSelect(
|
||||
field_name="game",
|
||||
search_url="/api/games/search",
|
||||
excluded=[{"value": 4172, "label": "Obscure Game", "data": {}}],
|
||||
html = str(
|
||||
FilterSelect(
|
||||
field_name="game",
|
||||
search_url="/api/games/search",
|
||||
excluded=[{"value": 4172, "label": "Obscure Game", "data": {}}],
|
||||
)
|
||||
)
|
||||
self.assertIn(">Obscure Game</span>", html)
|
||||
self.assertIn('data-value="4172"', html)
|
||||
@@ -232,40 +260,38 @@ class FilterSelectComponentTest(unittest.TestCase):
|
||||
def test_m2m_modifiers_render_as_option_rows(self):
|
||||
"""M2M modifiers (All)/(Only) render as modifier-option rows in the
|
||||
dropdown, not as a separate <select>."""
|
||||
html = FilterSelect(
|
||||
field_name="games",
|
||||
modifier_options=[
|
||||
("NOT_NULL", "(Any)"),
|
||||
("IS_NULL", "(None)"),
|
||||
("INCLUDES_ALL", "(All)"),
|
||||
("INCLUDES_ONLY", "(Only)"),
|
||||
],
|
||||
)
|
||||
self.assertIn(
|
||||
'data-search-select-modifier-option="INCLUDES_ALL"', html
|
||||
)
|
||||
self.assertIn(
|
||||
'data-search-select-modifier-option="INCLUDES_ONLY"', html
|
||||
)
|
||||
self.assertIn(
|
||||
'data-search-select-modifier-option="NOT_NULL"', html
|
||||
html = str(
|
||||
FilterSelect(
|
||||
field_name="games",
|
||||
modifier_options=[
|
||||
("NOT_NULL", "(Any)"),
|
||||
("IS_NULL", "(None)"),
|
||||
("INCLUDES_ALL", "(All)"),
|
||||
("INCLUDES_ONLY", "(Only)"),
|
||||
],
|
||||
)
|
||||
)
|
||||
self.assertIn('data-search-select-modifier-option="INCLUDES_ALL"', html)
|
||||
self.assertIn('data-search-select-modifier-option="INCLUDES_ONLY"', html)
|
||||
self.assertIn('data-search-select-modifier-option="NOT_NULL"', html)
|
||||
# No legacy match-mode <select>.
|
||||
self.assertNotIn("data-search-select-match", html)
|
||||
|
||||
def test_active_modifier_renders_pill(self):
|
||||
"""When modifier is INCLUDES_ALL, the modifier pill renders with the
|
||||
(All) label alongside any value pills."""
|
||||
html = FilterSelect(
|
||||
field_name="games",
|
||||
modifier="INCLUDES_ALL",
|
||||
modifier_options=[
|
||||
("NOT_NULL", "(Any)"),
|
||||
("IS_NULL", "(None)"),
|
||||
("INCLUDES_ALL", "(All)"),
|
||||
("INCLUDES_ONLY", "(Only)"),
|
||||
],
|
||||
included=[{"value": 5, "label": "Hollow Knight", "data": {}}],
|
||||
html = str(
|
||||
FilterSelect(
|
||||
field_name="games",
|
||||
modifier="INCLUDES_ALL",
|
||||
modifier_options=[
|
||||
("NOT_NULL", "(Any)"),
|
||||
("IS_NULL", "(None)"),
|
||||
("INCLUDES_ALL", "(All)"),
|
||||
("INCLUDES_ONLY", "(Only)"),
|
||||
],
|
||||
included=[{"value": 5, "label": "Hollow Knight", "data": {}}],
|
||||
)
|
||||
)
|
||||
self.assertIn('data-modifier="INCLUDES_ALL"', html)
|
||||
self.assertIn("(All)", html)
|
||||
@@ -274,10 +300,12 @@ class FilterSelectComponentTest(unittest.TestCase):
|
||||
|
||||
def test_presence_only_modifiers_no_m2m_rows(self):
|
||||
"""When modifier_options only has presence entries, no M2M rows appear."""
|
||||
html = FilterSelect(
|
||||
field_name="status",
|
||||
modifier_options=[("NOT_NULL", "(Any)"), ("IS_NULL", "(None)")],
|
||||
options=[("f", "Finished")],
|
||||
html = str(
|
||||
FilterSelect(
|
||||
field_name="status",
|
||||
modifier_options=[("NOT_NULL", "(Any)"), ("IS_NULL", "(None)")],
|
||||
options=[("f", "Finished")],
|
||||
)
|
||||
)
|
||||
self.assertNotIn("INCLUDES_ALL", html)
|
||||
self.assertNotIn("INCLUDES_ONLY", html)
|
||||
|
||||
@@ -21,7 +21,12 @@ BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
# See https://docs.djangoproject.com/en/4.1/howto/deployment/checklist/
|
||||
|
||||
# SECURITY WARNING: keep the secret key used in production secret!
|
||||
SECRET_KEY = "django-insecure-x0_t$gei=_o_p(%%!-db$jezka@y+d67$a8tvw13nl^8$l*t@="
|
||||
# Read from the environment so each deployment (prod, staging) can supply its
|
||||
# own key; falls back to an insecure default for local development and tests.
|
||||
SECRET_KEY = os.environ.get(
|
||||
"SECRET_KEY",
|
||||
"django-insecure-x0_t$gei=_o_p(%%!-db$jezka@y+d67$a8tvw13nl^8$l*t@=",
|
||||
)
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = False if os.environ.get("PROD") else True
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
export interface DropdownConfig {
|
||||
patchUrl: string;
|
||||
bodyKey: string; // server field name, e.g. "status" or "device_id"
|
||||
event: string; // dispatched on document.body after a successful PATCH
|
||||
csrf: string;
|
||||
numericValue?: boolean; // parse the option value as a number
|
||||
}
|
||||
|
||||
// Wires a light-DOM value-selector dropdown that lives inside `host`.
|
||||
// Markup hooks (rendered server-side): [data-toggle], [data-menu],
|
||||
// [data-label], and one or more [data-option][data-value].
|
||||
export function initDropdown(host: HTMLElement, config: DropdownConfig): void {
|
||||
const toggle = host.querySelector<HTMLElement>("[data-toggle]");
|
||||
const menu = host.querySelector<HTMLElement>("[data-menu]");
|
||||
const label = host.querySelector<HTMLElement>("[data-label]");
|
||||
if (!toggle || !menu || !label) return;
|
||||
|
||||
const close = () => {
|
||||
menu.hidden = true;
|
||||
};
|
||||
|
||||
toggle.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
menu.hidden = !menu.hidden;
|
||||
});
|
||||
document.addEventListener("click", (event) => {
|
||||
if (!host.contains(event.target as Node)) close();
|
||||
});
|
||||
|
||||
host.querySelectorAll<HTMLElement>("[data-option]").forEach((option) => {
|
||||
option.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const raw = option.dataset.value ?? "";
|
||||
label.innerHTML = option.innerHTML;
|
||||
close();
|
||||
const body: Record<string, unknown> = {
|
||||
[config.bodyKey]: config.numericValue ? Number(raw) : raw,
|
||||
};
|
||||
window
|
||||
.fetchWithHtmxTriggers(config.patchUrl, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json", "X-CSRFToken": config.csrf },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
.then(() => document.body.dispatchEvent(new CustomEvent(config.event)))
|
||||
.catch(() => console.error("Failed to update", config.patchUrl));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { readGameStatusSelectorProps } from "../generated/props.js";
|
||||
import { initDropdown } from "./dropdown.js";
|
||||
|
||||
class GameStatusSelectorElement extends HTMLElement {
|
||||
connectedCallback(): void {
|
||||
const props = readGameStatusSelectorProps(this);
|
||||
initDropdown(this, {
|
||||
patchUrl: `/api/games/${props.gameId}/status`,
|
||||
bodyKey: "status",
|
||||
event: "status-changed",
|
||||
csrf: props.csrf,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("game-status-selector", GameStatusSelectorElement);
|
||||
@@ -0,0 +1,42 @@
|
||||
import { readPlayEventRowProps } from "../generated/props.js";
|
||||
|
||||
class PlayEventRowElement extends HTMLElement {
|
||||
connectedCallback(): void {
|
||||
const props = readPlayEventRowProps(this);
|
||||
const toggle = this.querySelector<HTMLElement>("[data-toggle]");
|
||||
const menu = this.querySelector<HTMLElement>("[data-menu]");
|
||||
const count = this.querySelector<HTMLElement>("[data-count]");
|
||||
const addPlay = this.querySelector<HTMLElement>("[data-add-play]");
|
||||
if (!toggle || !menu) return;
|
||||
|
||||
const close = () => {
|
||||
menu.hidden = true;
|
||||
};
|
||||
toggle.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
menu.hidden = !menu.hidden;
|
||||
});
|
||||
document.addEventListener("click", (event) => {
|
||||
if (!this.contains(event.target as Node)) close();
|
||||
});
|
||||
|
||||
addPlay?.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (count) count.textContent = String(Number(count.textContent) + 1);
|
||||
close();
|
||||
window
|
||||
.fetchWithHtmxTriggers(props.apiCreateUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "X-CSRFToken": props.csrf },
|
||||
body: JSON.stringify({ game_id: props.gameId }),
|
||||
})
|
||||
.catch(() => {
|
||||
if (count) count.textContent = String(Number(count.textContent) - 1);
|
||||
console.error("Failed to record play");
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("play-event-row", PlayEventRowElement);
|
||||
@@ -0,0 +1,17 @@
|
||||
import { readSessionDeviceSelectorProps } from "../generated/props.js";
|
||||
import { initDropdown } from "./dropdown.js";
|
||||
|
||||
class SessionDeviceSelectorElement extends HTMLElement {
|
||||
connectedCallback(): void {
|
||||
const props = readSessionDeviceSelectorProps(this);
|
||||
initDropdown(this, {
|
||||
patchUrl: `/api/session/${props.sessionId}/device`,
|
||||
bodyKey: "device_id",
|
||||
event: "device-changed",
|
||||
csrf: props.csrf,
|
||||
numericValue: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("session-device-selector", SessionDeviceSelectorElement);
|
||||
@@ -0,0 +1,49 @@
|
||||
// import { toISOUTCString } from "../../games/static/js/utils.js";
|
||||
|
||||
/**
|
||||
* @description Formats Date to a UTC string accepted by the datetime-local input field.
|
||||
* @param {Date} date
|
||||
* @returns {string}
|
||||
*/
|
||||
function toISOUTCString(date: Date): string {
|
||||
function stringAndPad(number: number): string {
|
||||
return number.toString().padStart(2, "0");
|
||||
}
|
||||
const year = date.getFullYear();
|
||||
const month = stringAndPad(date.getMonth() + 1);
|
||||
const day = stringAndPad(date.getDate());
|
||||
const hours = stringAndPad(date.getHours());
|
||||
const minutes = stringAndPad(date.getMinutes());
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
class SessionTimestampButtonsElement extends HTMLElement {
|
||||
connectedCallback(): void {
|
||||
for (const button of this.querySelectorAll("[data-target]")) {
|
||||
const target = button.getAttribute("data-target");
|
||||
const type = button.getAttribute("data-type");
|
||||
if (!target || !type) continue;
|
||||
const targetElement = document.querySelector(`#id_${target}`);
|
||||
if (!(targetElement instanceof HTMLInputElement)) return;
|
||||
button.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
if (type == "now") {
|
||||
targetElement.value = toISOUTCString(new Date());
|
||||
} else if (type == "copy") {
|
||||
const oppositeName =
|
||||
targetElement.name == "timestamp_start"
|
||||
? "timestamp_end"
|
||||
: "timestamp_start";
|
||||
const opposite = document.querySelector(`[name='${oppositeName}']`);
|
||||
if (!(opposite instanceof HTMLInputElement)) return;
|
||||
opposite.value = targetElement.value;
|
||||
} else if (type == "toggle") {
|
||||
if (targetElement.type == "datetime-local") targetElement.type = "text";
|
||||
else targetElement.type = "datetime-local";
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("session-timestamp-buttons", SessionTimestampButtonsElement);
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
export {};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
fetchWithHtmxTriggers(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"strict": true,
|
||||
"noEmitOnError": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"rootDir": "ts",
|
||||
"outDir": "games/static/js/dist"
|
||||
},
|
||||
"include": ["ts/**/*.ts"]
|
||||
}
|
||||
@@ -6,6 +6,10 @@ resolution-markers = [
|
||||
"python_full_version < '3.15'",
|
||||
]
|
||||
|
||||
[options]
|
||||
exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values.
|
||||
exclude-newer-span = "P7D"
|
||||
|
||||
[[package]]
|
||||
name = "annotated-types"
|
||||
version = "0.7.0"
|
||||
@@ -149,25 +153,25 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "distlib"
|
||||
version = "0.4.2"
|
||||
version = "0.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/46/8d/873e9252ea2c0e0c857884e0a2899ec43ade132345df1925ef24cbe64f18/distlib-0.4.2.tar.gz", hash = "sha256:baeb401c90f27acd15c4861ae0847d1e731c27ac3dbf4210643ba61fa1e813db", size = 614914, upload-time = "2026-06-08T16:24:15.439Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/60/aa891c893821d4d127292ed66c6940d1d715894bd5a0ce048056bc641773/distlib-0.4.2-py2.py3-none-any.whl", hash = "sha256:ca4cb11e5d746b5ec13c199cbf19ae27a241f89702b54e153a74332955446067", size = 470510, upload-time = "2026-06-08T16:24:13.208Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "django"
|
||||
version = "6.0.6"
|
||||
version = "6.0.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "asgiref" },
|
||||
{ name = "sqlparse" },
|
||||
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/78/29/ac41e16097af67066d97a7d5775c5d8e7efc5d0284f6b0a159e07b9adb92/django-6.0.6.tar.gz", hash = "sha256:ad03916ba59523d781ae5c3f631960c23d69a9d9c43cecda52fc23b47e953713", size = 10905525, upload-time = "2026-06-03T13:02:46.503Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5e/f1/bf85f0d29ef76abf901f193fe8fef4769d3da7794197832bc30151c071d8/django-6.0.5.tar.gz", hash = "sha256:bc6d6872e98a2864c836e42edd644b362db311147dd5aa8d5b82ba7a032f5269", size = 10924131, upload-time = "2026-05-05T13:54:39.329Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/50/23f9dc45483419a3cc2085b498b25adfbf10642b2941c73e6d2dfaffc9ab/django-6.0.6-py3-none-any.whl", hash = "sha256:25148b1194c47c2e685e5f5e9c5d59c78b075dfd282cb9618861ba6c1708f4d2", size = 8373354, upload-time = "2026-06-03T13:02:41.72Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/5b/1328f8b84fce040c404f76822bf8c57d254e368e8cbd8bd67ec2b26d75f5/django-6.0.5-py3-none-any.whl", hash = "sha256:9d58a7cb49244e74c8e161d5e403a46d6209f1009ba40f5a66d6aa0d0786a8f0", size = 8368680, upload-time = "2026-05-05T13:54:33.532Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -269,7 +273,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "djlint"
|
||||
version = "1.39.0"
|
||||
version = "1.36.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
@@ -282,36 +286,13 @@ dependencies = [
|
||||
{ name = "regex" },
|
||||
{ name = "tqdm" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/aa/a7/5ba1032d01ceba641b92b1c76c758a0a06959585c6d36608371526809a08/djlint-1.39.0.tar.gz", hash = "sha256:75e7e1a0c592121751c48360104b3c402f4d6406ea862ba76f8867b3eb51ba97", size = 55174, upload-time = "2026-06-05T19:22:37.296Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/74/89/ecf5be9f5c59a0c53bcaa29671742c5e269cc7d0e2622e3f65f41df251bf/djlint-1.36.4.tar.gz", hash = "sha256:17254f218b46fe5a714b224c85074c099bcb74e3b2e1f15c2ddc2cf415a408a1", size = 47849, upload-time = "2024-12-24T13:06:36.36Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/d0/6055cebb538718e46b3874d3a1c0c768aaf744a1354f342b1932985c882b/djlint-1.39.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fb2948211eb369bd28175f2007cc924bff7e2403ec1f42f22f6d4381c32bad31", size = 517087, upload-time = "2026-06-05T19:22:40.617Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/be/726afcd62b9ce6382d2c10a9122a45daf4a47b6e2af4a7536c82b8b5f4fc/djlint-1.39.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e1476f077af638ba21813cc17d8e7d31b1d5473e707d98c659e6ac2bdf5210e6", size = 489869, upload-time = "2026-06-05T19:22:47.081Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/a0/f26dc11c62111f6d80550e9188b2d207691f0664ed3b7dbd62ed5d418e32/djlint-1.39.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19dbef7852fabe445ce4ea2b05da888df0513e1798c4ae7cd8f0c68cf0bc8cbb", size = 513551, upload-time = "2026-06-05T19:23:13.49Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/5a/2ffe28c44d27aa006314c1b352a0b6039ab05dd4b7b3dbac494315b912ab/djlint-1.39.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c8c7bba68633f6a4a211dd35ded9337ec52a7a2991afc816f928f741296c1b3", size = 537832, upload-time = "2026-06-05T19:22:30.67Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/46/2cb7966a7a93b4758a380500c9a18fa22688b071dba5b52106107b48de4e/djlint-1.39.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e5564bc51531332ba67bc8d952825ac2a42a7ec1618413a4da15bf957257c0d6", size = 520497, upload-time = "2026-06-05T19:23:19.497Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/d0/b32648761b1529b030897b931998a6dabe6a15473c4724e1080c2ca737ae/djlint-1.39.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b836e79f690d83aa429cfa3240045e086f9e0764afbc88654004f455e2a9835a", size = 547304, upload-time = "2026-06-05T19:23:21.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/6d/c0e7c61fdeee741ee7eec85a14dd40c8d2e1ee9efeb96a8a7302a8daef47/djlint-1.39.0-cp313-cp313-win32.whl", hash = "sha256:f18c148fc6cfb32dd8a0af7c80067f02d3faa83f5aea16a7c7fd5111d303ee69", size = 406746, upload-time = "2026-06-05T19:22:57.969Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/c5/7ea676211bbb85665b2f82f2cc64925a4f54d866d57887ab943e97016fcf/djlint-1.39.0-cp313-cp313-win_amd64.whl", hash = "sha256:7c38a8e90f8a73adf08b6852ee34bf3c734873f2ff1df58e56206308272cb275", size = 453441, upload-time = "2026-06-05T19:22:41.662Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/49/3056c368937e98d6cb7d1ac662e64e93bc9b5ddf5a2afcd01839c0095a51/djlint-1.39.0-cp313-cp313-win_arm64.whl", hash = "sha256:e95095623cf5d6e84161c9a08e81f29ea5f7f1c804107ccf7cd2fe27a750a3bc", size = 388639, upload-time = "2026-06-05T19:22:53.201Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/c2/76fa9ffa5b88784a2704b64f08d902bc8071a99bdd79a983f56b3e2dfcdf/djlint-1.39.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a092b0beb93d9a6fe5e1e28934e4f933c483ce791aae9aec47e3f07a29511a61", size = 515957, upload-time = "2026-06-05T19:23:09.12Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/44/638b92e40ad5b473df6728c3c6c7ebd9d50823d4cf8dd5bdf22073bd1d57/djlint-1.39.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7ca3cd2c1ca610ad6e6357abba51e8153dc19f1d34764bcf453084199a4732a2", size = 488676, upload-time = "2026-06-05T19:22:43.787Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/b6/50e91d06554b74dc558a6af6349643c0165ff6dcc5142908ae2db012acca/djlint-1.39.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0011c2b78fa26752e3373129965dcbe80253af7fd2807e394fdfd4ea6281d99", size = 517217, upload-time = "2026-06-05T19:22:48.533Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/2d/f9f900ae26b44b3b79090667148eeb016464cfe70d0211e2afe0fda9ab4c/djlint-1.39.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:683ec039c2864670f1806fc96e4650f3f7e310222acb5d602608aeb24ca352e9", size = 537472, upload-time = "2026-06-05T19:22:51.868Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/ad/28ef34f629e728042341c397261fc2593a2eec489e44a7863cf646edc628/djlint-1.39.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:326a5ec019b084eb2d837f39d0bea6727806867e9d1e26d3f4bf0cd6bc67bf8f", size = 523546, upload-time = "2026-06-05T19:23:29.143Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/6a/7ce68fdf319d9abda560fe3509d60abefe25ef118ae21d03399b1dfc84e7/djlint-1.39.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e655ac4e4346b3f5a61b53a9351104d33e4a7376f1c22acf4fadf1183f90128a", size = 546627, upload-time = "2026-06-05T19:22:31.67Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/89/3e5bfaeb7b39a078a9a8d4fc7331e60f12f0e5c1251bc6c622be8c592ad4/djlint-1.39.0-cp314-cp314-win32.whl", hash = "sha256:0b5e30ab98c4de74698211ce6a60a502307d176015bf98269f74a39d862fc694", size = 412745, upload-time = "2026-06-05T19:22:35.955Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/bd/b891316176513c233507dbf2f82747552e401079e3f917c46fbf84c5ef05/djlint-1.39.0-cp314-cp314-win_amd64.whl", hash = "sha256:9d4927b1bf65445e3c8dda8d1b96ab3019dbce1eaa88850760df78962bf2724e", size = 462295, upload-time = "2026-06-05T19:23:05.893Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/44/ba3bf57ee70e969407e96d7accfb13d00c776674dbce95f8b07e1c7f731f/djlint-1.39.0-cp314-cp314-win_arm64.whl", hash = "sha256:4b6a684f5cd8fc71ad55cd3c1acffa0cd4108bc63ad1524f9ca1d76b1b354e47", size = 396557, upload-time = "2026-06-05T19:22:54.276Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/c0/bdb3eb96bd8e5d65546fe63063b787e302b981ec2f1436b1a0027404c311/djlint-1.39.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:afa4ba49d6b67f3c0145d78448c292e75d5822e76c189ef681399ead8492c599", size = 561022, upload-time = "2026-06-05T19:23:23.09Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/98/e35b87ebc8f2a6985aed5ea7b85145d9e6e5d5b67fc3b612396a84604791/djlint-1.39.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1fee96af514bd1cb6b62d1107bb177d4d2f49361e5e9cd14f56f9650cdc2b5ad", size = 534450, upload-time = "2026-06-05T19:22:33.683Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/f4/3ff2615cc2826c91ec3c7c26e8abedb35b3a546a068bc70ef385b2079c17/djlint-1.39.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ef06848e1ed5d987bb1aaf950ffe3a87b14e5937d9d42dbb1d0469ebe7a74dc", size = 552149, upload-time = "2026-06-05T19:22:27.861Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/fc/6fea3ea0075d06d1d5444a7ad72bf51c612795339e95d4b281599c61b9ee/djlint-1.39.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ffcbca30ad41bc054c7c7ed5341ea651b034a60d4eff0aa2ab0bb8cb40f2b9b0", size = 570693, upload-time = "2026-06-05T19:22:55.293Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/6a/af8a4012652a33208b3e0ca04c23446711fa5ecf8936809c04c6213c47b8/djlint-1.39.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8aace5a239e5f317b030a5c05d22d55edac5142366ffa1a15e5e5c8675044e44", size = 557296, upload-time = "2026-06-05T19:23:24.545Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/13/bf86a4f5d140ab6052a3aca8742cb446ec851946c7dcb625eb18a2564893/djlint-1.39.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9912c361968a3c881fd3eaff5a5dc56a0a409a7904355d998d430ff294550744", size = 579052, upload-time = "2026-06-05T19:23:10.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/e8/5d2850606e321f8d6e56fe74fcb283c12493d179279bb52f347d0338aa6e/djlint-1.39.0-cp314-cp314t-win32.whl", hash = "sha256:12d3175f48317ec692da693a15ce7b939b3114f16b8d644bb037784bcef0bd52", size = 457432, upload-time = "2026-06-05T19:23:04.728Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/9f/6dc179c101d30c1aa4269e0cada79667c043d15392e515fb7e4e36e8a8df/djlint-1.39.0-cp314-cp314t-win_amd64.whl", hash = "sha256:a3077dc9a4b3bb2724cd0231f008d309fe4ef4048af06b7edd1adba723356248", size = 513546, upload-time = "2026-06-05T19:23:11.375Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/0d/e3acb7da4ce3df5d699412b9442b885286df7e45647c205d65e593d02711/djlint-1.39.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f7228e01d5ceaf74fb5270d7bdfbd30dffe65e88216a70824765bca6acb2a4fb", size = 412286, upload-time = "2026-06-05T19:22:29.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/45/50bddcbcee9566c213f14db5b154ade285c4842b88cdcdcc8d536d515147/djlint-1.39.0-py3-none-any.whl", hash = "sha256:3ef41f7bbf7761978e86e24ebdaf58704b17d847e9d0b5d9cb9f761ce976cff0", size = 60750, upload-time = "2026-06-05T19:23:02.846Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/83/88b4c885812921739f5529a29085c3762705154d41caf7eb9a8886a3380c/djlint-1.36.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ead475013bcac46095b1bbc8cf97ed2f06e83422335734363f8a76b4ba7e47c2", size = 354384, upload-time = "2024-12-24T13:06:20.809Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/38/67695f7a150b3d9d62fadb65242213d96024151570c3cf5d966effa68b0e/djlint-1.36.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6c601dfa68ea253311deb4a29a7362b7a64933bdfcfb5a06618f3e70ad1fa835", size = 322971, upload-time = "2024-12-24T13:06:22.185Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/7a/cd851393291b12e7fe17cf5d4d8874b8ea133aebbe9235f5314aabc96a52/djlint-1.36.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bda5014f295002363381969864addeb2db13955f1b26e772657c3b273ed7809f", size = 410972, upload-time = "2024-12-24T13:06:24.077Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/31/56469120394b970d4f079a552fde21ed27702ca729595ab0ed459eb6d240/djlint-1.36.4-cp313-cp313-win_amd64.whl", hash = "sha256:16ce37e085afe5a30953b2bd87cbe34c37843d94c701fc68a2dda06c1e428ff4", size = 362053, upload-time = "2024-12-24T13:06:25.432Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/67/f7aeea9be6fb3bd984487af8d0d80225a0b1e5f6f7126e3332d349fb13fe/djlint-1.36.4-py3-none-any.whl", hash = "sha256:e9699b8ac3057a6ed04fb90835b89bee954ed1959c01541ce4f8f729c938afdd", size = 52290, upload-time = "2024-12-24T13:06:33.76Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -325,11 +306,68 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "filelock"
|
||||
version = "3.29.1"
|
||||
version = "3.29.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1f/f9/f38573ed5844586db374d085911740a501ccfa373b455fc9413f09f85237/filelock-3.29.1.tar.gz", hash = "sha256:d97e6b1b9757569626c58caa07dc4beb1613f4a2938b1e8cc81afca398906c9e", size = 59335, upload-time = "2026-06-03T15:19:04.053Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b5/fe/997687a931ab51049acce6fa1f23e8f01216374ea81374ddee763c493db5/filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90", size = 57571, upload-time = "2026-04-19T15:39:10.068Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/a0/614c5fe402fd88951df45f4dda2fa3b4e17a99ecd92340771929169b3b95/filelock-3.29.1-py3-none-any.whl", hash = "sha256:85199dfd706869641b72b2e8955d5416a4b2b7dc4b0e8e6d97b4cc1299a6983b", size = 40750, upload-time = "2026-06-03T15:19:02.959Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "greenlet"
|
||||
version = "3.5.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6d/6e/802acd792aebb2256fbbee8cacf2727faaeb6f240ac11008f09eae4414bc/greenlet-3.5.1.tar.gz", hash = "sha256:5a56aeb7d5d9cc4b3a735efb5095bd4b4f6f0e4f93e5ca876d0e2315137b7829", size = 197356, upload-time = "2026-05-20T15:05:03.917Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/27/69/7f7e5372d998b81001899b1c0823c957aa413ba0f2662e65821611cc31e4/greenlet-3.5.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:51518ff74664078fc51bffcc6fc529b0df5ae58da192691cee765d45ce944a2b", size = 285060, upload-time = "2026-05-20T13:08:51.899Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/bf/387f9b6b865fd2ae0d0be09e0004827295a01b71be76ed350dd1e28a91a4/greenlet-3.5.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ffdb3c0bb002c99cd8f298957e046c3dbf6006b5b7cdf11a4e19194624a0a0a", size = 604370, upload-time = "2026-05-20T14:00:07.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/f5/169ce3d4e4c67291bd18f8cbe0299c9f3e45102c7f1fb3c14780c93e4532/greenlet-3.5.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7715a5a2c3378ba602c3a440558261e13a820bb53a82693aacd7b7f6d964e283", size = 616987, upload-time = "2026-05-20T14:05:44.237Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/ba/c24110c55dffa55aa6e1d98b45310da33801aeba7686ff0190fe5d46fd32/greenlet-3.5.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d40a890035c0058cadbdc4af7569800fd28a0e527a0fdbb7b5f9418f176846ce", size = 622911, upload-time = "2026-05-20T14:09:10.598Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/e5/7f2e41d5273be07e77560d61ea4e56485b4d6c316d2a84518c62d1364061/greenlet-3.5.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc71ff466927a201b08305acac451ebe1aedfcea002f62f1f2f2ac2ac1e6a135", size = 613911, upload-time = "2026-05-20T13:14:27.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/7b/d20db2e8a5ad6c038702f3179b136f93f0a3d1a21a0c0777f3e470cdf4b2/greenlet-3.5.1-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:67821bb03e4e98664490edb787ff6af501194c29bbee0f5c1dfdcf1dc3d9d436", size = 425228, upload-time = "2026-05-20T14:01:40.837Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/a4/fbdc67579b73615a1f91615e814303cc71e06128f7baaba87be79b8fb90c/greenlet-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cd443683db272ebaaca03af98c0b063ab30db70ea8a31a1559f35e3f7b744ccd", size = 1570689, upload-time = "2026-05-20T14:02:27.225Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/b4/77abbe35078be39718a46cd49caf16bceb35662f97a34101dca28aa98e47/greenlet-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:089fff7a6ce8d9316d1f65ebc00273a56be258c1725b32b94de90a3a979557e1", size = 1635602, upload-time = "2026-05-20T13:14:36.344Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/f7/129f27ca700845b8ee8ca88ce7f43435a1239c2eddb7677fc938822762cf/greenlet-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:110a1ca7b49b014b097f6078272c3f4ed31af45b254de5228b79adba879f6af9", size = 238683, upload-time = "2026-05-20T13:11:50.57Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/5c/a485a36e87df8d8fd0632ee01511244f5156a20ed3746cc6599340326395/greenlet-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f16ba1efc0715b680a18b8123d90dad887c6112ae3555b4b5c32c149540c6b4e", size = 235499, upload-time = "2026-05-20T13:12:42.028Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/cb/c62454606daf5640369c94d8a9dd540599b1bfc090e2d2180cb77f4038d2/greenlet-3.5.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8ab31c9de8651a2facdd5c5bb0011f2380dd1a7af78ce2adf4b56095294fc07", size = 285579, upload-time = "2026-05-20T13:08:56.396Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/71/c4270398c2eba968a6071af1dfbdcaeee6ec1c24bc8b435b8cc452700da6/greenlet-3.5.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e300185139abc337ade480c327183adf42a875ac7181bfe66d7d4efea31fbea", size = 651106, upload-time = "2026-05-20T14:00:09.448Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/ab/71e34b78a44ec271fb5f550c17bc46d301ddc5953890d935f270b0dcdb5a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7ffdb990dcaa0234cf9845aead5df2e3c3a8b6507d409274dd87e0d5ab05ffc2", size = 663478, upload-time = "2026-05-20T14:05:45.88Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/2d/2d80842910da44f78c286532d084b8a5c3717c844ae80ceb3858738ae89a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c09df69dc1712d131332054a858a3e5cca400967fa3a672e2324fbb0971448c", size = 667767, upload-time = "2026-05-20T14:09:12.15Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/96/4efd6fa5c62c85426a0c19077a586258ebc3a2a146ff2493e4312a697a22/greenlet-3.5.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f82b3597e9d83b63408affed0b48fd0f54935edac4302237b9a837be0dae33c", size = 660800, upload-time = "2026-05-20T13:14:29.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/d3/dad2eecedfbb1ed7050a20dcfae40c1442b74bc7423608be2c7e03ee7133/greenlet-3.5.1-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:a4764e0bfc6a4d114c865b32520805c16a990ef5f286a514413b05d5ecd6a23d", size = 470786, upload-time = "2026-05-20T14:01:42.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/e0/6c71401a25cac7000261304e866a2f2cc04dc74810d40e2f118aa4799495/greenlet-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c0141e37414c10164e702b8fb1473304221ad98f71600850c6ef7ff4880feba0", size = 1617518, upload-time = "2026-05-20T14:02:28.662Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/26/c5c06643e8c0af9e7bf18e16cb51d0ab7625155f0392e1c9015d66d556cd/greenlet-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:50ae25a67bea74ea41fb14b960bc532df73eb713417b2d61892dced82fe8d3bc", size = 1681593, upload-time = "2026-05-20T13:14:39.417Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/bd/e11a108317485075e68af9d23039619b86b28130c3b50d227d42edece64b/greenlet-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:8a17c42330e261299766b75ac1ea32caa437a9453c8f65d16a13140db378ecd3", size = 239800, upload-time = "2026-05-20T13:09:30.128Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/f8/8e8e8417b7bf28639a5a56356ef934d0375e1d0c70a57e04d7701e870ffe/greenlet-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:7b5f5fae05b8ac6d176a61b60c394a8cbdc2b5b91b81793066e68745cf165e54", size = 236862, upload-time = "2026-05-20T13:09:10.498Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/12/41bf27fde4d3605d3773ae57751eda182b8be2f5398011c041173b1d9534/greenlet-3.5.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:ea8da1e900d758d078810d4255d8c6aa572181896a31ec79d779eb79c3adc9ad", size = 293637, upload-time = "2026-05-20T13:12:35.529Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/44/ba14b23e9757707050c2f397d305bbcae62e5d7cad122f8b6baec5ae4a1f/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a19570c52a21420dcbc94e661994bc325c0b5b11304540fed514586da5dc8f2e", size = 650840, upload-time = "2026-05-20T14:00:11.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/37/5ddc2b686a6844f91abecef43411842426da2e1573f60b49ecf2547f4ae1/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3d955c89b75eeca4723d7cc14135f393cd47c32e2a6cb4a8e4c6e760a26b0986", size = 656416, upload-time = "2026-05-20T14:05:47.118Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/46/5987dcd1a2570ba84f3b187536b2ca3ae97613387e57f5cfa99df068fe5e/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea37d5a157eb9493820d3792ac4ece28619a394391d2b9f2f78057d396ff0f0f", size = 656607, upload-time = "2026-05-20T14:09:13.949Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/f0/d17510297c35a2992712f0bf84de3779749999f7d3d63aa1f09db7c62dbe/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2daaaebd1a5aa88c49045b6baf9310b3263796bd88db713edf37cf53e7bb4e", size = 654397, upload-time = "2026-05-20T13:14:30.696Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/c1/6da0a9ddcc29d7e51ef14883fa3dc1e53b3f4ffba00582106c7bf55da1d8/greenlet-3.5.1-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:8d8a23250ea3ec7b36de8fa4b541e9e2db3ee82915cc060ab0631609ad8b28de", size = 488287, upload-time = "2026-05-20T14:01:43.143Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/eb/147387705bb89092645b012586e7273cb5ed3c90ef7eaf3a69173eaf0209/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bfbd69cc349e43bf3a8ae1c85548ff0718efc887615c2db16c3833d7b0b072d", size = 1614469, upload-time = "2026-05-20T14:02:30.192Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/4e/37ee0da7732b7aa9896f17e15579a9df34b9fcb9dd494f0adfa749af6623/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4378720dd888136c27215a0214d32a4d37c3852765d45bc37aad0623423cfd78", size = 1675115, upload-time = "2026-05-20T13:14:40.972Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/f3/97dfcf4a6eb5077f8a672234216fb5923eb89f2cab7081cb10b2cf75b605/greenlet-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:45718441607f9325d948db98cbc691276059316d0358c188c246da4e1d4d23d2", size = 245246, upload-time = "2026-05-20T13:12:22.646Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/73/d7f72e34b582f694f4a9b248162db7b09cc458a259ba8f0c0bfa1a34ea7d/greenlet-3.5.1-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:2baee5ca02031757ffe8cc3d69f0cc0aec7065ce362622da74f32d3bcab1c541", size = 285575, upload-time = "2026-05-20T13:12:07.043Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/59/fa9c6e87dc8ad27a95dabe2f29f372b733d05a8a67470f6c901ed9975655/greenlet-3.5.1-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b1ec3274918a81d3ea778b9e75b56b72b33f300edb6cf7f3a7fe1dae56683de", size = 656428, upload-time = "2026-05-20T14:00:12.556Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/f9/e753408871eaa61dfe35e619cfc67512b036fde99893685d50eea9e07146/greenlet-3.5.1-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:111e2390ffffc47d5840b01711dd7fac07d4c09283d0283e7f3264b14e284c64", size = 667064, upload-time = "2026-05-20T14:05:48.662Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/74/807a047255bf1e09303627c46dc043dca596b6958a354d904f32ab382005/greenlet-3.5.1-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:10a9a1c0bfbc93d41156ffcb90c75fbc05544054faf15dcc1fdf9765f8b607f0", size = 672962, upload-time = "2026-05-20T14:09:15.532Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/27/5565b5b40389f1c7753003a07e21892fda8660926787036d5bc0308b8113/greenlet-3.5.1-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e630136e905fe5ff43e86945ae41220b6d1470956a39220e708110ac48d01ea5", size = 665697, upload-time = "2026-05-20T13:14:32.943Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/32/19d4e13225193c29b13e308015223f7d75fd3d8623d49dd19040d2ce8ec1/greenlet-3.5.1-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:ef08c1567c78074b22d1a200183d52d04a14df447bf70bcbb6a3507a48e776fc", size = 476047, upload-time = "2026-05-20T14:01:44.39Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/82/e7de4178c0c2d1c9a5a3be3cc0b33e46a85b3ee4a77c071bf7ad8600e079/greenlet-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:975eac34b44a7077ca4d421348455b94f0f518246a7f14bc6d2fdcfe5b584368", size = 1621256, upload-time = "2026-05-20T14:02:31.91Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/10/f2dddcf7dacac17dfc68691809589adad06135eb28930429cf58a6467a2f/greenlet-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:9ab3c3a0b2ae6198e67c898dad5215a49f9ae0d0081b3c3ec59f333e39eeca26", size = 1685956, upload-time = "2026-05-20T13:14:42.55Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/17/4a232b32133230ada52f70e9d7f5b65b0caef8772f01849bd8d149e7e4ca/greenlet-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:cbfc69be86e10dcfef5b1e6269d1d6926552aa89ee39e1de3353360c1b6989ab", size = 239802, upload-time = "2026-05-20T13:13:15.481Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/ae/4e623a7e6d4d2a5f4cb8e4c82de4169fc637942caae68d6e676b8a128ac5/greenlet-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:92fd6d44ac5e5a887c8a5dc4a8ba0ba908527c31c12f78c6bc7dcfe8aab279f6", size = 236853, upload-time = "2026-05-20T13:15:37.301Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/57/816d9cff29119da3505b3d6a5e14a8af89006ac36f47f891ff293ee05af1/greenlet-3.5.1-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:a6fdf2433a5441ef9a95464f7c3e674775da1c8c1177fff311cee1acad4626ed", size = 293877, upload-time = "2026-05-20T13:10:19.078Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/a1/59b0a7c7d140ff1a75626680b9a9899b79a9176cab298b394968fb023295/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7546556f0d649f99f6a361098a55f761181bb2ea12ff150bb16d26092ad88244", size = 655333, upload-time = "2026-05-20T14:00:14.758Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/1b/5efe127597625042218939d01855109f352779050768b670b52edcc16a6c/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5ee3ea898009fa898f85f9982255d35278c477bebe185beca249cab42d4526c", size = 659443, upload-time = "2026-05-20T14:05:50.159Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/9d/1dcdf7b95ab3cf8c7b6d7277c18a5e167312f2b362ddfcc5d5e6d8d84b43/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a57b0d05a0448eed231d59c0ceb287dde984551e54cbc51ac2d4865712838e9c", size = 659998, upload-time = "2026-05-20T14:09:16.912Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/6d/c404246ea4d22d097a7426d0efb5b781bd7eb67715f09e79001bd552ab18/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5c81f74d204d3edd136ebfd50dce53acbb776995d721a0fe801626cfc93b8cd", size = 658356, upload-time = "2026-05-20T13:14:35.091Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/7e/c4959664fc231d587d66d8e81f2095e98056ba1954beafdcbe635e251052/greenlet-3.5.1-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:b0703c2cef53e01baec47f7a3868009913ad71ec678bbecb42a6f40895e4ce62", size = 494470, upload-time = "2026-05-20T14:01:45.611Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/02/f8ee37fb6d2219329f350af241c27fcf12df57e723d11f6fc6d3bacdadaa/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:2c18ef16bf6d4dd410e4dd52996888ea1497be26892fe5bbc73580aba4287b8e", size = 1619216, upload-time = "2026-05-20T14:02:33.403Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/c5/3dc9475ace2c7a3680da12372cddd7f1ac874eb410a1ac48d3e9dab83782/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:17d86354f0ae6b61bf9be5148d0dd34e06c3cb7c602c671f79f29ac3b150e659", size = 1678427, upload-time = "2026-05-20T13:14:43.71Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/4e/750c15c317a41ffb36f0bf40b933e3d744a7dede61889f74443ea69690cf/greenlet-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:e7516cf6ae6b8a582c2770a0caed47b8a48373ed732c33d69a72913ae6ac923e", size = 245225, upload-time = "2026-05-20T13:13:59.366Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/fd/d3baea2eeb7b617efd47e87ca06e2ec2c6118d303aa9e918e0ce16eadc10/greenlet-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:5028648bf2253ec4745add746129d3904121fa7fe871a76bed23c5720573ce0a", size = 239590, upload-time = "2026-05-20T13:13:37.382Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -364,11 +402,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.18"
|
||||
version = "3.17"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b9/28/99c51f664567218d824af024c0251650fb27e4ca066df188dab0769c5b91/idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f", size = 196048, upload-time = "2026-05-28T14:32:38.55Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/a7/f76514cc40ad6234098ecdebda08732d75964776c51a42845b7da10649e2/idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c", size = 65316, upload-time = "2026-05-28T14:32:37.035Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -548,6 +586,25 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "playwright"
|
||||
version = "1.60.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "greenlet" },
|
||||
{ name = "pyee" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/21/f0/832bd9677194908da118064eef20082f2791e3d18215cc6d9391ee2c5a67/playwright-1.60.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:6a8cd0fec171fb3089e95e898c8bc8a6f35dea0b78b399e12fcc19427e91b1d7", size = 43474635, upload-time = "2026-05-18T12:00:31.969Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/7b/e1d32ae8a3ed937ec2be3721c5f728b13d731a0b7c6442e0b3bec5094ac0/playwright-1.60.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:39b5420ba6145045b69ced4c5c47d4d9fe5bddfc8ff816c518913afcb25ec7a5", size = 42261327, upload-time = "2026-05-18T12:00:35.638Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/bc/23de499ded6411c188a20c5a0dea6f0cd4ed5d2b3cc6042a5dbd3ed609aa/playwright-1.60.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:2581d0e6a3392c71f91b27460c7fd093356818dc430f48153896c8aeeaef7705", size = 43474636, upload-time = "2026-05-18T12:00:39.294Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/7b/1d679f4fced4ea94efadd17103856d8c565384f68382a1681264e46f5925/playwright-1.60.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:1c2bfae7884fb3fb05b853290eab8f343d524e5016f2f1def702acbbdf14c93e", size = 47467220, upload-time = "2026-05-18T12:00:43.179Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/c2/1528d267d4442bd2c6b8eaeab819dd52c2030bf80e89293f0ba1f687473b/playwright-1.60.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:43e66564125ee31b07a58cefb21e256d62d67d8d1713e6858df7a3019d8ed353", size = 47154856, upload-time = "2026-05-18T12:00:46.715Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/4e/b008b6440a7a1624378041da94829956d4b8f7ab9ef5aad22d0dc3f2e26d/playwright-1.60.0-py3-none-win32.whl", hash = "sha256:ec94e416ea320711e0ad4bf185dcbf41833672961e90773e1885255d7db7b7e7", size = 37902157, upload-time = "2026-05-18T12:00:50.374Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/f0/0541524133104f9cc20bf900870ff4a736b76a23483f3a55295ddfa58409/playwright-1.60.0-py3-none-win_amd64.whl", hash = "sha256:9566821ce6030a1f9e7146a24e19355ab0d98805fd0f9be50bb3d8fef1750c02", size = 37902159, upload-time = "2026-05-18T12:00:53.728Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/c8/210f282d278e4709cdd71b12a31af45a30a22ab3207b387e29b37e478713/playwright-1.60.0-py3-none-win_arm64.whl", hash = "sha256:6e4f6700a4c2250efff8e690a81d66e3855754fb587b6b87cf5c784014f91537", size = 34037981, upload-time = "2026-05-18T12:00:57.584Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
@@ -644,6 +701,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyee"
|
||||
version = "13.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.20.0"
|
||||
@@ -669,6 +738,19 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-base-url"
|
||||
version = "2.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pytest" },
|
||||
{ name = "requests" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ae/1a/b64ac368de6b993135cb70ca4e5d958a5c268094a3a2a4cac6f0021b6c4f/pytest_base_url-2.1.0.tar.gz", hash = "sha256:02748589a54f9e63fcbe62301d6b0496da0d10231b753e950c63e03aee745d45", size = 6702, upload-time = "2024-01-31T22:43:00.81Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/98/1c/b00940ab9eb8ede7897443b771987f2f4a76f06be02f1b3f01eb7567e24a/pytest_base_url-2.1.0-py3-none-any.whl", hash = "sha256:3ad15611778764d451927b2a53240c1a7a591b521ea44cebfe45849d2d2812e6", size = 5302, upload-time = "2024-01-31T22:42:58.897Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-django"
|
||||
version = "4.12.0"
|
||||
@@ -681,6 +763,21 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/83/a5/41d091f697c09609e7ef1d5d61925494e0454ebf51de7de05f0f0a728f1d/pytest_django-4.12.0-py3-none-any.whl", hash = "sha256:3ff300c49f8350ba2953b90297d23bf5f589db69545f56f1ec5f8cff5da83e85", size = 26123, upload-time = "2026-02-14T18:40:47.381Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-playwright"
|
||||
version = "0.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "playwright" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-base-url" },
|
||||
{ name = "python-slugify" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/58/ef/172eb8e23c80491fc72f1401c72f9305663873649351306a38b18406b0c9/pytest_playwright-0.8.0.tar.gz", hash = "sha256:7888d4a2443160c82e0c506c437076679f86b36d1910427250d90fbf1843a981", size = 17132, upload-time = "2026-05-18T10:16:15.919Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/71/1c545fac6a9054b52b3771238fb2dc6e8f1d0ccec116e1c7786ec191887c/pytest_playwright-0.8.0-py3-none-any.whl", hash = "sha256:856aae6efd4bc055f2ef229c647768760bcaad5cd3a5983c314ac260a974a933", size = 17143, upload-time = "2026-05-18T10:16:18.226Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dateutil"
|
||||
version = "2.9.0.post0"
|
||||
@@ -706,6 +803,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/8d/3d316429f65029532bb1e28ff77b797d86b5ac3915bb44ca4e19aa283d43/python_discovery-1.4.0-py3-none-any.whl", hash = "sha256:26ed78d703e234879a66244c7d4114563fb13ec5cd30a2d1357e5fb4850782da", size = 33217, upload-time = "2026-05-28T01:15:36.573Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-slugify"
|
||||
version = "8.0.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "text-unidecode" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/87/c7/5e1547c44e31da50a460df93af11a535ace568ef89d7a811069ead340c4a/python-slugify-8.0.4.tar.gz", hash = "sha256:59202371d1d05b54a9e7720c5e038f928f45daaffe41dd10822f3907b937c856", size = 10921, upload-time = "2024-02-08T18:32:45.488Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/62/02da182e544a51a5c3ccf4b03ab79df279f9c60c5e82d5e8bec7ca26ac11/python_slugify-8.0.4-py2.py3-none-any.whl", hash = "sha256:276540b79961052b66b7d116620b36518847f52d5fd9e3a70164fc8c50faa6b8", size = 10051, upload-time = "2024-02-08T18:32:43.911Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytz"
|
||||
version = "2026.2"
|
||||
@@ -840,27 +949,27 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.16"
|
||||
version = "0.15.15"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a6/bd/5f7ec371001337d8fa61701c186ff8b613ecac1651848c5950f4c4d5f2e9/ruff-0.15.16.tar.gz", hash = "sha256:d05e78d38c78caf020b03789e25106c93017db5a0cb6e2819885018c61343b78", size = 4714267, upload-time = "2026-06-04T16:33:09.974Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/84/6f/a76f7d96e5c962f5b69cee865e49c15c1116897c01990faa8a57edb62e7f/ruff-0.15.15.tar.gz", hash = "sha256:b8dff018130b46d8e5bf0f926ef6b60cf871d6d5ae45fc9334e09632daa741d6", size = 4706985, upload-time = "2026-05-28T14:16:57.784Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/42/53ef1c3953f157956db9bf7861e3bc50b9b887ce93300aa48cdba8336fe6/ruff-0.15.16-py3-none-linux_armv6l.whl", hash = "sha256:6ac3c0b3969cc6cf6b158c4e2f8f682acb58e7d700d8a44b65ecdc72d66ab0b2", size = 10709025, upload-time = "2026-06-04T16:32:51.935Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/9a/a79159346f19134a956607754e57d8d128f7a4c00f4ad2f7514d224c172c/ruff-0.15.16-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:197c207ed75ffba54a0dec23db4aa939a27a3053073e085e0042433cbdc58e4a", size = 11063550, upload-time = "2026-06-04T16:32:42.24Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/72/3ce2ac000a5299ec238e01f51397b3b653c93b077d9b1bfe8715bb895f20/ruff-0.15.16-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3a39fec45ab316cc23e7558f23fea4a70403ddb5648ea9a4a3854a16973d0071", size = 10421345, upload-time = "2026-06-04T16:32:37.251Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/c2/cc7fad3ec9169373f5b6a18f1917b91080feec40c3f9658334a1d28e2f03/ruff-0.15.16-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba93191d79003116b95128c9d306e045200fdbd0bccb782b110f3cd1d4abc5cf", size = 10757217, upload-time = "2026-06-04T16:32:54.722Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/d2/3474009eaa0a65b31fa7152a2fad5e2f050c640ceb1e6b02ee6922e94c82/ruff-0.15.16-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c6ee4b90520630120ef032aa5cc10db483852dff950e78b1d717e2993a61ac8d", size = 10507035, upload-time = "2026-06-04T16:33:05.343Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/81/b7ae6ccbd11f0c8dc3d5d67fc4be9b57ff57ca86ba56152021378e1277f2/ruff-0.15.16-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4e4215bc938bc3c8215c1472c1aa437e310fee20cd427335fec9d7e609563628", size = 11255291, upload-time = "2026-06-04T16:32:49.49Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/e1/46e526f1a7cc90857ce6ddf25fbb77eb6568651ac38d71b033af07076dd5/ruff-0.15.16-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7c8d26be963b090f10e29abc8b3e74a2a321f6fa34e02424e30b5af89350ecbb", size = 12124922, upload-time = "2026-06-04T16:33:07.821Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/da/5c791b088b596b24d0deb967fa28ae02ad751a140c0b9ea81c5ab915d6c0/ruff-0.15.16-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f198cf4123602a2280ed46c307bcbafe41758d6fee5b456b6b6058ca1514b3b4", size = 11332186, upload-time = "2026-06-04T16:33:02.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/11/5da87abe20047c8962361473923ebb2f62b595250126aadfad8c20649c1e/ruff-0.15.16-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb27515fa6240fb586ae82b901a59e67d24acff86f2190b433dc542fe0435aeb", size = 11373541, upload-time = "2026-06-04T16:32:47.007Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/2a/8554754c23a854ae3fd6b507e36ad61ddb121e298c6d5d617dec94ed0f14/ruff-0.15.16-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a267c46ba1593fc26b8eecbea050b39d40c0b6bb7781ee11c90a02cd10032951", size = 11353014, upload-time = "2026-06-04T16:32:34.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/25/62ea41529ec89f742ea3fed9cb1059c72877ec7cf9b9e99ac9cf3294d1d9/ruff-0.15.16-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:528c68f39a91498a8d50e91ff5985df3d105782bab49cc378e73ac26bff083e8", size = 10737467, upload-time = "2026-06-04T16:32:26.348Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/17/334d3ad9de4d40f9dd58fdd09e35ce64553bb501e2f19a839e2fb6be14fc/ruff-0.15.16-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7ed55c58950df60589a9a7a5d2f8fa5f54ebd287163be805adfe6ee95a9de123", size = 10521910, upload-time = "2026-06-04T16:32:32.54Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/bd/3ac7c6ae77a885c1004b3dda2446ea401768d24f851c14b4ad4b24f6639c/ruff-0.15.16-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d482feaf51512b50f9790ceb417a56a61dd1e9d9bf967662b9ed27c01b34f53a", size = 10979190, upload-time = "2026-06-04T16:32:57.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/d7/609546e6a413c3f216fbf2a50c928f97c80939154f6a0503114094a86191/ruff-0.15.16-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1e15bc8c94513dae2a40cc9ef07c94fdd4ecc9e29dabebeebe170f952322c9e3", size = 11477014, upload-time = "2026-06-04T16:32:44.687Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/0d/f2cd247ad32633a5c36e97141a2c21b11c6279f7957bc2ff360b1e08fddd/ruff-0.15.16-py3-none-win32.whl", hash = "sha256:580378f7bd4aa25f72e74aa54948a9622f142b1e509521dd10902e886681cc1e", size = 10735541, upload-time = "2026-06-04T16:32:30.145Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/9e/02e845ef151b1dee585e55c4739f8e1734ae1d9f1221dff65761c162208b/ruff-0.15.16-py3-none-win_amd64.whl", hash = "sha256:408256017284eddf98fff77b29aa4fb30f586042d535b2d9befc6512f400aaec", size = 11843403, upload-time = "2026-06-04T16:32:39.76Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/19/016553f86f207450aebebc2b2b5088d086b901cc8186c02ac4284db3bd88/ruff-0.15.16-py3-none-win_arm64.whl", hash = "sha256:8cd61783afb39638a7133ef0d2dfb1e91277593962f81b5a8423eb0b888a6121", size = 11134555, upload-time = "2026-06-04T16:33:00.136Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/9d/3a45c05b8ab04b4705989de70a79008e27c8003296a0feaee9edc18dd7e9/ruff-0.15.15-py3-none-linux_armv6l.whl", hash = "sha256:cf93e5388f412e1b108b1f8b34a6e036b70fe8aff89393befad96fe48670311b", size = 10710652, upload-time = "2026-05-28T14:16:06.701Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/66/da974431624bf3b49f6ee1f9543c02d929ff1cba78b0d5a79c38cf21f744/ruff-0.15.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ac5a646d1f6a7dadd5d50842dae2c1f9862ac887ef5d1b1375e02def791fde6e", size = 11096615, upload-time = "2026-05-28T14:16:23.313Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/09/7443452e5d290230a712103f2fdceeef7184f3ec99a2bd01c8be78aaceb5/ruff-0.15.15-py3-none-macosx_11_0_arm64.whl", hash = "sha256:77d955a431430c66f72dd94e379ad38a16daea3d25094872ac4edf9e797be530", size = 10436683, upload-time = "2026-05-28T14:16:40.974Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/01/d330c26a57fa4f3943a14424904027428315b700fe4d14a84bb123a649e5/ruff-0.15.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7614ee79c69788cf6cedd568069ade9cecc22a1ad20494efe8d0c9ebb4b622d4", size = 10769064, upload-time = "2026-05-28T14:16:28.905Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/85/cc8770f8bdff541b1da8392d1634141fe4a0e3f4ee596605959b7906c27f/ruff-0.15.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3cdb1679e06a1f6b47bc384714ae96f6e2fb65ca441eb78c43d2ca554176ce1f", size = 10511987, upload-time = "2026-05-28T14:16:43.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/29/8c190c1472b63013583ba391f3342036e02010544c1270455ed8e519bdf3/ruff-0.15.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2728b93d7b23a603ea2c0ac6eb73d760bd38ec9de35f35fb41e18f7a3fee7622", size = 11275100, upload-time = "2026-05-28T14:16:55.244Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/6b/7e145ce2cc8e63d6834eca03d83a0e18d121def5c69f91b4cf4011ed4879/ruff-0.15.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be582fcc0db438902c7792b08d6ddf6c9b9e21addaa10092c2c741cfb09e5a45", size = 12176903, upload-time = "2026-05-28T14:16:14.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/a3/d5974637f68e451f7fadf015cf3101d1cd7d8ba5027cffe0b9e3826ebe6b/ruff-0.15.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7aa77465b8ecaf1a27bea098d696f7fed5e1eccbd10b321b682d6de586ae5627", size = 11404550, upload-time = "2026-05-28T14:16:20.138Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/1c/e6e5e568f22be4fb05d6244234aba384c06b451252453b821e1a529263cf/ruff-0.15.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48decfa11d740de4889de623be1463308346312f2409a56e24aa280c86162dc4", size = 11382027, upload-time = "2026-05-28T14:16:46.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/01/170921b49fcd2e8858825593f91cf7146c3e40a5c3e6df763e4bb0484dde/ruff-0.15.15-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a5015088452ca0081387063649ec67f06d3d1d6b8b936a1f836b5e9657ecd48c", size = 11366041, upload-time = "2026-05-28T14:16:26.247Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/54/a7bad711d7de93254e15e06a4c375b89a03d18de45d3e5dcc86a4472fb1a/ruff-0.15.15-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f5294aab6356c81600fcdea3a62bb1b924dfd5e91767c12318d3f68f86af57cd", size = 10741795, upload-time = "2026-05-28T14:16:17.11Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/31/38c075963668f8b41c6914ee0f6f318727fbe30ab9145cb29e6df464c5fa/ruff-0.15.15-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:db5bd4d802415cca656dc1616070b725952d6ae95eb5d4831e49fbd94a38f75f", size = 10511117, upload-time = "2026-05-28T14:16:31.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/96/6ff689e1f7e375d1d97075eca022f74c2bab59554a432fe4d2e6f091986a/ruff-0.15.15-py3-none-musllinux_1_2_i686.whl", hash = "sha256:587a6278ed42059191c1a466e490bd7930fb50bd2e255398bc29616c895a61cb", size = 10994867, upload-time = "2026-05-28T14:16:35.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/c2/5dce0ab9f92a8d534fa62b9bf9caca3eddb8c1a81b616f5e195ada4f0d6e/ruff-0.15.15-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:df0c1c084f5f4be9812f61518a45c440d3c30d69ce4bf6c5270e66d38338f02a", size = 11482101, upload-time = "2026-05-28T14:16:49.598Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/c0/1003b60edd697c649faf61f1a34094b1abb38fb3d1181e3f895781250a08/ruff-0.15.15-py3-none-win32.whl", hash = "sha256:29428ea79694afbe756d45fd59b36f22b6b020dc0443cf7de0173046236964b9", size = 10716774, upload-time = "2026-05-28T14:16:52.337Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/a8/1269eddd6945a06c23f055ef7848886e37cf9d6a8bebb386a3115f01470c/ruff-0.15.15-py3-none-win_amd64.whl", hash = "sha256:8df0323902e15e24bc4bf246da830573d3cf3352bd0b9a164eab335d111ff4a4", size = 11868463, upload-time = "2026-05-28T14:16:11.333Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/b2/920464c907b191e37469d477a1aa8bc048b8f36c4c1610dfa4ab87b39e18/ruff-0.15.15-py3-none-win_arm64.whl", hash = "sha256:3c8ceca6792f38196b8f589bc92eccd03eef286602da92e5dc05cc42ef6441b7", size = 11138498, upload-time = "2026-05-28T14:16:38.425Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -881,6 +990,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "text-unidecode"
|
||||
version = "1.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ab/e2/e9a00f0ccb71718418230718b3d900e71a5d16e701a3dae079a21e9cd8f8/text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93", size = 76885, upload-time = "2019-08-30T21:36:45.405Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/a5/c0b6468d3824fe3fde30dbb5e1f687b291608f9473681bbf7dabbf5a87d7/text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8", size = 78154, upload-time = "2019-08-30T21:37:03.543Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "timetracker"
|
||||
version = "1.7.0"
|
||||
@@ -910,6 +1028,7 @@ dev = [
|
||||
{ name = "pre-commit" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-django" },
|
||||
{ name = "pytest-playwright" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "ruff" },
|
||||
]
|
||||
@@ -940,20 +1059,21 @@ dev = [
|
||||
{ name = "pre-commit", specifier = ">=3.7.1,<4" },
|
||||
{ name = "pytest", specifier = ">=9.0.3,<10" },
|
||||
{ name = "pytest-django", specifier = ">=4.12.0" },
|
||||
{ name = "pytest-playwright", specifier = ">=0.8.0" },
|
||||
{ name = "pyyaml", specifier = ">=6.0.1,<7" },
|
||||
{ name = "ruff" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tqdm"
|
||||
version = "4.68.1"
|
||||
version = "4.67.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/b3/36c8ecf72e8925200671613332db156d84b99b3aee742a41c1938ebb0808/tqdm-4.68.1.tar.gz", hash = "sha256:fc163d96b287bd031e1aa24421ce4411b25559bd0a1be4fe649bdaa4d2c02bf5", size = 171236, upload-time = "2026-06-05T17:23:15.267Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/47/aa/218a0eb34de1f753c83e4d0d1c8e7c4cef27f20dcb8342e024f63a80dc86/tqdm-4.68.1-py3-none-any.whl", hash = "sha256:fea4a90e4023f764914569f7802a297277c5ab1a66be5144143e142e1a4031d8", size = 78354, upload-time = "2026-06-05T17:23:13.654Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user