Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/admin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"clean": "rimraf .next out *.tsbuildinfo"
},
"dependencies": {
"@civfix/shared": "^0.57.0",
"@civfix/shared": "0.58.0-b-feat-admin-user-hours-contract.g47d8a38",
"@tanstack/react-query": "^5.62.7",
"clsx": "^2.1.1",
"leaflet": "^1.9.4",
Expand Down
22 changes: 22 additions & 0 deletions apps/admin/src/components/shared/dialog.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { readFileSync } from "node:fs"

import { describe, expect, it } from "vitest"

const source = readFileSync(new URL("./dialog.tsx", import.meta.url), "utf8")

describe("dialog host", () => {
it("bounds the prompt textarea by the request's maxLength", () => {
const textarea = source.match(/<textarea[\s\S]*?\/>/)?.[0] ?? ""
expect(textarea).toContain("maxLength={current.maxLength}")
})

it("drops the cancel button only for an acknowledge-only confirm", () => {
expect(source).toMatch(
/\{!\(current\.kind === "confirm" && current\.acknowledgeOnly\) && \(\s*<button className="btn ghost" onClick=\{cancel\}>/,
)
})

it("renders confirm details as a list under the body", () => {
expect(source).toMatch(/current\.kind === "confirm" && current\.details[\s\S]*?<ul className="dialog-list">/)
})
})
21 changes: 18 additions & 3 deletions apps/admin/src/components/shared/dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ interface ConfirmRequest {
confirmLabel?: string
cancelLabel?: string
danger?: boolean
details?: string[]
acknowledgeOnly?: boolean
resolve: (ok: boolean) => void
}

Expand All @@ -25,6 +27,7 @@ interface PromptRequest {
defaultValue?: string
confirmLabel?: string
required?: boolean
maxLength?: number
danger?: boolean
resolve: (value: string | null) => void
}
Expand Down Expand Up @@ -121,6 +124,15 @@ export function DialogHost() {
</div>
<div className="dialog-body">
{current.body && <p className="dialog-text">{current.body}</p>}
{current.kind === "confirm" && current.details && current.details.length > 0 && (
<ul className="dialog-list">
{current.details.map((line) => (
<li key={line} className="mono">
{line}
</li>
))}
</ul>
)}
{current.kind === "prompt" && (
<>
{current.label && <label className="dialog-label">{current.label}</label>}
Expand All @@ -130,15 +142,18 @@ export function DialogHost() {
rows={3}
value={value}
placeholder={current.placeholder}
maxLength={current.maxLength}
onChange={(e) => setValue(e.target.value)}
/>
</>
)}
</div>
<div className="modal-foot dialog-foot">
<button className="btn ghost" onClick={cancel}>
{current.kind === "confirm" ? (current.cancelLabel ?? "Cancel") : "Cancel"}
</button>
{!(current.kind === "confirm" && current.acknowledgeOnly) && (

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Transcript codes can disappear

When a void affects issued transcripts, acknowledgeOnly removes Cancel but still lets Escape, an outside click, or Close dismiss the notice. The caller does not handle dismissal, so the operator can lose the only displayed codes needed to revoke transcripts that still verify with the old total. Keep the notice open until Done or preserve its information after dismissal.

Artifacts

Chromium transcript-notice validation script

  • The authored script bundles the dialog and void-success callback, runs both comparison conditions, and asserts the observed dismissal behavior.

Chromium validation command and observed results

  • The executed command exited 0 and recorded that all three dismissal routes stayed blocked in the control but dismissed the current-source notice.

▶ Guarded comparison keeps the transcript notice open

  • Chromium exercised Escape, an outside click, and header Close against an in-memory guarded control; the notice remained until Done was selected.

Guarded comparison with affected transcript codes visible

  • A frame captured during the guarded-control run shows the notice and its two affected codes.

▶ Current source dismisses the transcript notice without Done

  • Chromium exercised the unchanged source and showed Escape, an outside click, and header Close each dismissing the notice without Done.

Current-source notice with affected transcript codes visible

  • A frame captured during the current-source run shows the notice before its dismissal routes were exercised.

View artifacts

T-Rex Ran code and verified through T-Rex

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/admin/src/components/shared/dialog.tsx
Line: 152

Comment:
**Transcript codes can disappear**

When a void affects issued transcripts, `acknowledgeOnly` removes Cancel but still lets Escape, an outside click, or Close dismiss the notice. The caller does not handle dismissal, so the operator can lose the only displayed codes needed to revoke transcripts that still verify with the old total. Keep the notice open until Done or preserve its information after dismissal.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

<button className="btn ghost" onClick={cancel}>
{current.kind === "confirm" ? (current.cancelLabel ?? "Cancel") : "Cancel"}
</button>
)}
<button
className={`btn ${current.danger ? "danger" : "primary"}`}
onClick={accept}
Expand Down
13 changes: 10 additions & 3 deletions apps/admin/src/features/events/use-events.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
"use client"

import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import {
keepPreviousData,
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query"
import type {
AdminEventListQuery,
AdminEventListResponse,
Expand All @@ -16,10 +22,11 @@ import { api } from "@/lib/api"
import { queryKeys } from "@/lib/query"


export function useEventList(params: AdminEventListQuery) {
export function useEventList(params: AdminEventListQuery, opts: { keepPreviousData?: boolean } = {}) {
return useQuery<AdminEventListResponse>({
queryKey: queryKeys.events.list(params),
queryKey: queryKeys.events.page(params),
queryFn: () => api.listAdminEvents(params),
...(opts.keepPreviousData ? { placeholderData: keepPreviousData } : {}),
})
}

Expand Down
127 changes: 127 additions & 0 deletions apps/admin/src/features/users/event-picker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
"use client"

import * as React from "react"

import { Icons } from "@/components/icons"
import { LoadingState } from "@/components/shared/data-states"
import { useEventList } from "@/features/events/use-events"
import type { PickedEvent } from "@/features/users/user-hours"
import { useDebounced } from "@/hooks/use-debounced"
import { toAppError } from "@/lib/api"

const RESULT_LIMIT = 8

function EventPickIcon() {
return (
<span className="prow-ico hue-moss">
<Icons.Calendar size={14} />
</span>
)
}

/**
* Search-and-pick an event that can take hours. The `completed` facet is exactly the ended,
* non-cancelled events, so an event the server would refuse for not having ended never appears.
* Once picked, the control collapses to the chosen event with a "Change" affordance.
*/
export function EventPicker({
value,
onChange,
disabled,
}: {
value: PickedEvent | null
onChange: (event: PickedEvent | null) => void
disabled?: boolean
}) {
const [query, setQuery] = React.useState("")
const debounced = useDebounced(query, 200)
const q = debounced.trim()
const list = useEventList(
{ q: q === "" ? undefined : q, filter: "completed", limit: RESULT_LIMIT },
{ keepPreviousData: true },
)
const results = list.data?.items ?? []
Comment on lines +39 to +43

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Older matching events are unreachable

The picker displays only eight completed events and offers no next page. With nine events matching the same title and place, the ninth cannot be selected for a credit. The shared event response provides a cursor, and the backend list returns subsequent pages; expose those pages in the picker. This is a non-blocking limitation for operators crediting older events.

Knowledge Base Used: Event management

Artifacts

Executed Chromium event-picker repro script

  • The authored command bundles the real picker, serves nine matching events, and runs baseline and pagination-overlay browser checks, making the test reproducible.

Rendered event-picker harness source

  • The generated entry mounts EventPicker with a query client and displays the selected event ID, showing what the executed browser script rendered.

Successful picker validation run and observed API requests

  • Running `node trex-artifacts/event-picker-pagination-repro.cjs` from `/home/user/repo` exited 0 and recorded no baseline cursor request versus a successful cursor request and selection in the comparison.

▶ Unchanged picker with the ninth matching event unreachable

  • Chromium searched and scrolled the real picker with nine identical-search completed events; only the first eight rows were available.

Baseline picker after scrolling its eight results

  • Playwright captured the unchanged picker after scrolling its result list, with no pagination control present.

▶ Pagination-overlay picker selects the ninth event

  • Chromium used an in-memory pagination overlay, loaded the cursor page, and selected `event-9`, showing the comparison behavior without changing tracked code.

Comparison picker after selecting event nine

  • Playwright captured the pagination-overlay picker after selecting the oldest event, confirming the selection state.

View artifacts

T-Rex Ran code and verified through T-Rex

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/admin/src/features/users/event-picker.tsx
Line: 39-43

Comment:
**Older matching events are unreachable**

The picker displays only eight completed events and offers no next page. With nine events matching the same title and place, the ninth cannot be selected for a credit. The [shared event response](https://github.com/civfix/civfix-app/blob/HEAD/packages/shared/src/schemas/admin/events.ts) provides a cursor, and the [backend list](https://github.com/civfix/civfix-backend/blob/HEAD/services/api/src/services/admin/admin-event-service.ts) returns subsequent pages; expose those pages in the picker. This is a non-blocking limitation for operators crediting older events.

**Knowledge Base Used:** [Event management](https://app.greptile.com/civfix/-/custom-context/knowledge-base/civfix/civfix-admin/-/docs/event-management.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.


if (value) {
return (
<div className="user-pick picked">
<EventPickIcon />
<div className="user-pick-text">
<span className="user-pick-name">{value.title}</span>
<span className="user-pick-handle">
{value.place} · {value.when}
</span>
</div>
<button
type="button"
className="btn sm ghost"
disabled={disabled}
onClick={() => onChange(null)}
>
Change
</button>
</div>
)
}

return (
<div className="user-pick">
<div className="searchbox user-pick-search">
<Icons.Search size={14} />
<input
type="text"
aria-label="Search ended events by title or place"
autoComplete="off"
placeholder="Search ended events by title or place…"
value={query}
disabled={disabled}
onChange={(e) => setQuery(e.target.value)}
/>
</div>
{list.isLoading ? (
<div className="user-pick-results">
<LoadingState label="Searching…" />
</div>
) : list.isError ? (
<div className="user-pick-results">
<div className="user-pick-note tone-alert">{toAppError(list.error).message}</div>
</div>
) : results.length === 0 ? (
<div className="user-pick-results">
<div className="user-pick-note" role="status">
{q === "" ? "No ended events yet." : `No ended event matches “${q}”.`}
</div>
</div>
) : (
<ul className="user-pick-results" aria-label="Matching events">
{results.map((event) => (
<li key={event.id}>
<button
type="button"
className="user-pick-row"
disabled={disabled}
onClick={() =>
onChange({
id: event.id,
title: event.title,
place: event.place,
when: event.date.abs,
})
}
>
<EventPickIcon />
<span className="user-pick-text">
<span className="user-pick-name">{event.title}</span>
<span className="user-pick-handle">
{event.place} · {event.date.abs}
</span>
</span>
{event.flagged && <span className="pill status-flag tight">flagged</span>}
</button>
</li>
))}
</ul>
)}
</div>
)
}
Loading
Loading