Skip to content

Commit 099f60f

Browse files
Proposal H, Step 2 PR 2b: requested-printing badge + Confirm affordance in the display rail (#102)
Wires EditorSearchResponse.degradedQueries end to end for the first time on the frontend (previously captured by the API but discarded before reaching Redux): APIEditorSearch now returns {results, degradedQueries} instead of discarding the latter, searchResultsSlice accumulates degradedQueryHashKeys across paginated search requests, and a new selectIsSearchQueryDegraded selector answers "did this printing-filtered query get retried unfiltered" for a given slot's query. The display rail's always-visible header consumes that selector: the requested- printing badge switches from bg-secondary to a bg-warning degraded style (plus a warning icon and explanatory title) when the backend reports the filter as degraded, per the design doc's §2/§5. Bootswatch's Superhero theme is known to hardcode some component colors past the CSS-variable layer (see PR #91), so the new Playwright test verifies actual computed background-color, not just the class name, to confirm the degraded state really renders distinctly. The header also mounts the real DeckbuilderConfirmAffordance - the same component CardSlot.tsx already mounts, adapted only via its onOpenGridSelector prop: the rail has no modal to open, so N expands (or keeps expanded) the Choose Image accordion section instead of opening GridSelectorModal. Claude-Session: https://claude.ai/code/session_01MHapYojTkT5wenrQwGbGYk Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7019c67 commit 099f60f

8 files changed

Lines changed: 474 additions & 26 deletions

File tree

frontend/src/common/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,11 @@ export interface SearchResults {
111111

112112
export interface SearchResultsState extends ThunkStateBase {
113113
searchResults: SearchResults;
114+
// Hash keys (matching `searchResults`' own keys) of queries whose printing-specific filter
115+
// (expansionCode/collectorNumber) found nothing and were retried unfiltered by the backend -
116+
// mirrors EditorSearchResponse.degradedQueries (schema_types.ts). Client-side search results
117+
// never populate this - only the remote backend can report a degraded search.
118+
degradedQueryHashKeys: Array<string>;
114119
}
115120

116121
export interface BackendState {

frontend/src/features/display/DisplayPage.tsx

Lines changed: 70 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,14 @@
55
* + accordion (AutofillCollapse, per the owner's accordion amendment). Choose Image is wired to
66
* the real candidate/version picker (Step 2 PR 2a - see ChooseImageSection below, and
77
* useGridSelectorSearch.ts/GridSelectorResults.tsx, extracted from GridSelectorModal.tsx so both
8-
* surfaces share one real search implementation). Every other accordion section still renders a
9-
* labeled stub - see each section's own comment for which later PR fills it in, per the design
10-
* doc's §6 migration/sequencing plan.
8+
* surfaces share one real search implementation). The always-visible header now carries the real
9+
* requested-printing badge (Step 2 PR 2b - degraded-style variant keyed off
10+
* EditorSearchResponse.degradedQueries, wired end to end through searchResultsSlice's
11+
* selectIsSearchQueryDegraded) and the real DeckbuilderConfirmAffordance (same component
12+
* CardSlot.tsx mounts, adapted only via its onOpenGridSelector prop - the rail has no modal to
13+
* open, so N expands the Choose Image section instead). Every other accordion section still
14+
* renders a labeled stub - see each section's own comment for which later PR fills it in, per
15+
* the design doc's §6 migration/sequencing plan.
1116
*
1217
* Deliberately NOT built here (see the design doc + this task's relay reports for the full
1318
* reasoning): the tablet off-canvas drawer and mobile bottom-sheet overlay interaction patterns
@@ -38,6 +43,7 @@ import {
3843
useAppSelector,
3944
} from "@/common/types";
4045
import { AutofillCollapse } from "@/components/AutofillCollapse";
46+
import { DeckbuilderConfirmAffordance } from "@/features/card/DeckbuilderConfirmAffordance";
4147
import { paginateSlotsForDisplay } from "@/features/display/displayPagination";
4248
import { GridSelectorResults } from "@/features/gridSelector/GridSelectorResults";
4349
import { useGridSelectorSearch } from "@/features/gridSelector/useGridSelectorSearch";
@@ -55,7 +61,10 @@ import {
5561
selectProjectMembers,
5662
setSelectedImages,
5763
} from "@/store/slices/projectSlice";
58-
import { selectSearchResultsForQueryOrDefault } from "@/store/slices/searchResultsSlice";
64+
import {
65+
selectIsSearchQueryDegraded,
66+
selectSearchResultsForQueryOrDefault,
67+
} from "@/store/slices/searchResultsSlice";
5968
import {
6069
selectFrontsVisible,
6170
toggleFaces,
@@ -140,13 +149,25 @@ interface RailHeaderProps {
140149
slot: number;
141150
cardName: string | undefined;
142151
printingBadge: string | undefined;
152+
// Whether this slot's printing-specific search (expansionCode/collectorNumber) found nothing
153+
// and the backend retried it unfiltered - EditorSearchResponse.degradedQueries, wired end to
154+
// end in Step 2's second instrument PR (see selectIsSearchQueryDegraded). Meaningless when
155+
// printingBadge is undefined (no printing filter to have degraded in the first place).
156+
isDegraded: boolean;
157+
cardIdentifier: string | undefined;
158+
searchQuery: SearchQuery | undefined;
159+
onOpenChooseImage: () => void;
143160
}
144161

145162
const RailHeader = ({
146163
face,
147164
slot,
148165
cardName,
149166
printingBadge,
167+
isDegraded,
168+
cardIdentifier,
169+
searchQuery,
170+
onOpenChooseImage,
150171
}: RailHeaderProps) => (
151172
<div className="p-2 border-bottom" data-testid="display-rail-header">
152173
<div className="fw-bold">
@@ -160,16 +181,34 @@ const RailHeader = ({
160181
</div>
161182
{printingBadge != null && (
162183
<span
163-
className="badge bg-secondary mt-1"
184+
className={`badge mt-1 ${
185+
isDegraded ? "bg-warning text-dark" : "bg-secondary"
186+
}`}
164187
style={{ fontFamily: "monospace" }}
165188
data-testid="display-printing-badge"
189+
data-degraded={isDegraded}
190+
title={
191+
isDegraded
192+
? "This printing wasn't found - showing the closest available match instead."
193+
: undefined
194+
}
166195
>
196+
{isDegraded && <i className="bi bi-exclamation-triangle-fill me-1" />}
167197
{printingBadge}
168198
</span>
169199
)}
170-
{/* Confirm? affordance (DeckbuilderConfirmAffordance) wires in here for real in Step 2's
171-
second instrument PR, alongside the printing badge's full degraded-state treatment
172-
(degradedQueries) - see the design doc's §6. */}
200+
{/* Adapts CardSlot.tsx's own mount of this component (same props, same gating logic inside
201+
DeckbuilderConfirmAffordance itself - not forked) for the rail's status header: N's
202+
"open the grid selector" becomes "expand (or keep expanded, if already open) the Choose
203+
Image accordion section" here instead of opening GridSelectorModal, since the rail has
204+
no modal to open - see the design doc's §4.3/§4.4. */}
205+
{cardIdentifier != null && (
206+
<DeckbuilderConfirmAffordance
207+
cardIdentifier={cardIdentifier}
208+
searchQuery={searchQuery}
209+
onOpenGridSelector={onOpenChooseImage}
210+
/>
211+
)}
173212
</div>
174213
);
175214

@@ -308,6 +347,20 @@ const Rail = ({ selectedSlotRef, cardDocumentsByIdentifier }: RailProps) => {
308347
? selectProjectMember(state, selectedSlotRef.face, selectedSlotRef.slot)
309348
: undefined
310349
);
350+
const query = projectMember?.query;
351+
// Hooks must run unconditionally on every render (same order regardless of selectedSlotRef),
352+
// so this - like the projectMember selector above - is called before the idle-state early
353+
// return below, with the "nothing selected yet" case handled inside the selector itself
354+
// rather than by skipping the call.
355+
const isDegraded = useAppSelector((state) =>
356+
selectIsSearchQueryDegraded(
357+
state,
358+
query?.query,
359+
query?.cardType,
360+
query?.expansionCode,
361+
query?.collectorNumber
362+
)
363+
);
311364

312365
if (selectedSlotRef == null) {
313366
return (
@@ -320,7 +373,6 @@ const Rail = ({ selectedSlotRef, cardDocumentsByIdentifier }: RailProps) => {
320373
);
321374
}
322375

323-
const query = projectMember?.query;
324376
const selectedImage = projectMember?.selectedImage;
325377
const cardName =
326378
selectedImage != null
@@ -338,6 +390,11 @@ const Rail = ({ selectedSlotRef, cardDocumentsByIdentifier }: RailProps) => {
338390
...previous,
339391
[key]: !previous[key],
340392
}));
393+
// "focus, if already open" (design doc §4.3.4) - always force-open, never toggle-closed, so
394+
// the Confirm affordance's N path can't accidentally collapse a section the user already had
395+
// open.
396+
const onOpenChooseImage = () =>
397+
setExpandedSections((previous) => ({ ...previous, chooseImage: true }));
341398

342399
return (
343400
<div data-testid="display-rail-content">
@@ -346,6 +403,10 @@ const Rail = ({ selectedSlotRef, cardDocumentsByIdentifier }: RailProps) => {
346403
slot={selectedSlotRef.slot}
347404
cardName={cardName}
348405
printingBadge={printingBadge}
406+
isDegraded={isDegraded}
407+
cardIdentifier={selectedImage}
408+
searchQuery={query}
409+
onOpenChooseImage={onOpenChooseImage}
349410
/>
350411
<RailSection
351412
sectionKey="chooseImage"

frontend/src/mocks/handlers.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -513,6 +513,30 @@ export const searchResultsForDFCMatchedCards1And4 = http.post(
513513
)
514514
);
515515

516+
// A printing-specific search whose filter found nothing and was retried unfiltered - the backend
517+
// reports this via degradedQueries (schema_types.ts), which the requested-printing badge's
518+
// degraded-style variant is keyed off (Proposal H, Step 2 PR 2b). cardDocument1 carries no
519+
// canonicalCard data, so this is deliberately independent of the printing-confirmation affordance
520+
// fixtures above - the two instruments are tested in isolation from each other.
521+
export const searchResultsDegradedPrinting = http.post(
522+
buildRoute("3/editorSearch/"),
523+
() => {
524+
const hashKey = computeSearchQueryHashKey({
525+
query: "my search query",
526+
cardType: CardType.Card,
527+
expansionCode: "XYZ",
528+
collectorNumber: "999",
529+
});
530+
return HttpResponse.json(
531+
{
532+
results: { [hashKey]: [cardDocument1.identifier] },
533+
degradedQueries: [hashKey],
534+
},
535+
{ status: 200 }
536+
);
537+
}
538+
);
539+
516540
export const searchResultsServerError = http.post(
517541
buildRoute("3/editorSearch/"),
518542
() => HttpResponse.json(createError("3/editorSearch"), { status: 200 })

frontend/src/store/api.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -924,11 +924,19 @@ export async function APIGetVoteQueue(
924924
});
925925
}
926926

927+
// The 2/editorSearch/ (legacy, pre-E-2) endpoint predates degradedQueries entirely - there is no
928+
// equivalent signal to report, so callers always see an empty array from this path, never a
929+
// false positive/negative about degraded status.
930+
export interface EditorSearchResult {
931+
results: SearchResults;
932+
degradedQueries: Array<string>;
933+
}
934+
927935
async function APIEditorSearchLegacy(
928936
backendURL: string,
929937
searchSettings: SearchSettings,
930938
queriesToSearch: Array<SearchQuery>
931-
): Promise<SearchResults> {
939+
): Promise<EditorSearchResult> {
932940
const rawResponse = await fetch(formatURL(backendURL, "/2/editorSearch/"), {
933941
method: "POST",
934942
body: JSON.stringify({
@@ -956,7 +964,7 @@ async function APIEditorSearchLegacy(
956964
)
957965
)
958966
);
959-
return transformedResults;
967+
return { results: transformedResults, degradedQueries: [] };
960968
}
961969
throw { name: content.name, message: content.message };
962970
});
@@ -966,7 +974,7 @@ export async function APIEditorSearch(
966974
backendURL: string,
967975
searchSettings: SearchSettings,
968976
queriesToSearch: Array<SearchQuery>
969-
): Promise<SearchResults> {
977+
): Promise<EditorSearchResult> {
970978
try {
971979
const rawResponse = await fetch(formatURL(backendURL, "/3/editorSearch/"), {
972980
method: "POST",
@@ -988,7 +996,11 @@ export async function APIEditorSearch(
988996
}
989997
return rawResponse.json().then((content) => {
990998
if (rawResponse.status === 200 && content.results != null) {
991-
return content.results as EditorSearchResponse["results"];
999+
return {
1000+
results: content.results as EditorSearchResponse["results"],
1001+
degradedQueries: (content.degradedQueries ??
1002+
[]) as EditorSearchResponse["degradedQueries"],
1003+
};
9921004
}
9931005
throw { name: content.name, message: content.message };
9941006
});

frontend/src/store/slices/projectSlice.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ describe("selectQueriesWithoutSearchResults tests", () => {
5858
},
5959
searchResults: {
6060
searchResults: {},
61+
degradedQueryHashKeys: [],
6162
status: "idle" as ThunkStatus,
6263
error: null,
6364
},
@@ -103,6 +104,7 @@ describe("selectQueriesWithoutSearchResults tests", () => {
103104
},
104105
searchResults: {
105106
searchResults: {},
107+
degradedQueryHashKeys: [],
106108
status: "idle" as ThunkStatus,
107109
error: null,
108110
},
@@ -158,6 +160,7 @@ describe("selectQueriesWithoutSearchResults tests", () => {
158160
},
159161
searchResults: {
160162
searchResults: {},
163+
degradedQueryHashKeys: [],
161164
status: "idle" as ThunkStatus,
162165
error: null,
163166
},
@@ -212,6 +215,7 @@ describe("selectQueriesWithoutSearchResults tests", () => {
212215
cardType: "CARD" as CardType,
213216
})]: [],
214217
},
218+
degradedQueryHashKeys: [],
215219
status: "idle" as ThunkStatus,
216220
error: null,
217221
},
@@ -257,6 +261,7 @@ describe("selectQueriesWithoutSearchResults tests", () => {
257261
},
258262
searchResults: {
259263
searchResults: {},
264+
degradedQueryHashKeys: [],
260265
status: "idle" as ThunkStatus,
261266
error: null,
262267
},
@@ -302,6 +307,7 @@ describe("selectQueriesWithoutSearchResults tests", () => {
302307
},
303308
searchResults: {
304309
searchResults: {},
310+
degradedQueryHashKeys: [],
305311
status: "idle" as ThunkStatus,
306312
error: null,
307313
},

0 commit comments

Comments
 (0)