Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
This commit was merged in pull request #109.
This commit is contained in:
@@ -194,7 +194,9 @@ automatic** (D-021).
|
|||||||
`GET /api/v1/dinners?status=regular|quest_log` returns
|
`GET /api/v1/dinners?status=regular|quest_log` returns
|
||||||
`{dinners: [...]}` ordered by normalized name, each with `photo_url`
|
`{dinners: [...]}` ordered by normalized name, each with `photo_url`
|
||||||
(`/media/photos/{photo_id}-400.jpg`, or null) and `grounding_status`;
|
(`/media/photos/{photo_id}-400.jpg`, or null) and `grounding_status`;
|
||||||
any other status is 400. `PATCH /api/v1/dinners/{id}` edits `name`
|
any other status is 400. The Quest log (`quest_log`) is the dinners
|
||||||
|
accepted and not yet scheduled: one on the plan for the household's
|
||||||
|
current week (by its timezone) or a later week is left out. `PATCH /api/v1/dinners/{id}` edits `name`
|
||||||
(deduplicated against the other unarchived dinners), `method`
|
(deduplicated against the other unarchived dinners), `method`
|
||||||
(`stovetop` / `oven` / `crockpot` / `grill` / `other`), `minutes_total`
|
(`stovetop` / `oven` / `crockpot` / `grill` / `other`), `minutes_total`
|
||||||
(1–1440) and `difficulty` (`easy` / `medium` / `hard`); invalid values
|
(1–1440) and `difficulty` (`easy` / `medium` / `hard`); invalid values
|
||||||
|
|||||||
@@ -501,6 +501,63 @@ func (q *Queries) ListUnarchivedDinners(ctx context.Context) ([]ListUnarchivedDi
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const listUnplannedQuestLogDinners = `-- name: ListUnplannedQuestLogDinners :many
|
||||||
|
SELECT id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error, grounding_revision FROM dinners
|
||||||
|
WHERE status = 'quest_log'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM plan_days
|
||||||
|
JOIN week_plans ON week_plans.id = plan_days.plan_id
|
||||||
|
WHERE plan_days.dinner_id = dinners.id
|
||||||
|
AND week_plans.week_start >= ?1
|
||||||
|
)
|
||||||
|
ORDER BY normalized_name, id
|
||||||
|
`
|
||||||
|
|
||||||
|
// The Quest log: dinners with status quest_log that no plan for the week
|
||||||
|
// starting from_week_start or later has on any day.
|
||||||
|
func (q *Queries) ListUnplannedQuestLogDinners(ctx context.Context, fromWeekStart string) ([]Dinner, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, listUnplannedQuestLogDinners, fromWeekStart)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []Dinner
|
||||||
|
for rows.Next() {
|
||||||
|
var i Dinner
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Name,
|
||||||
|
&i.NormalizedName,
|
||||||
|
&i.Status,
|
||||||
|
&i.Origin,
|
||||||
|
&i.Method,
|
||||||
|
&i.MinutesTotal,
|
||||||
|
&i.Difficulty,
|
||||||
|
&i.Cuisine,
|
||||||
|
&i.IsFavorite,
|
||||||
|
&i.RecipeID,
|
||||||
|
&i.PhotoID,
|
||||||
|
&i.GroundingStatus,
|
||||||
|
&i.UpdatedBy,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
&i.RecipeUrl,
|
||||||
|
&i.GroundingError,
|
||||||
|
&i.GroundingRevision,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
const setDinnerFavorite = `-- name: SetDinnerFavorite :one
|
const setDinnerFavorite = `-- name: SetDinnerFavorite :one
|
||||||
UPDATE dinners
|
UPDATE dinners
|
||||||
SET is_favorite = ?,
|
SET is_favorite = ?,
|
||||||
|
|||||||
@@ -124,6 +124,9 @@ type Querier interface {
|
|||||||
// Every dinner a new or renamed dinner must not repeat, with the
|
// Every dinner a new or renamed dinner must not repeat, with the
|
||||||
// source_url of the recipe it is grounded in (NULL while it has none).
|
// source_url of the recipe it is grounded in (NULL while it has none).
|
||||||
ListUnarchivedDinners(ctx context.Context) ([]ListUnarchivedDinnersRow, error)
|
ListUnarchivedDinners(ctx context.Context) ([]ListUnarchivedDinnersRow, error)
|
||||||
|
// The Quest log: dinners with status quest_log that no plan for the week
|
||||||
|
// starting from_week_start or later has on any day.
|
||||||
|
ListUnplannedQuestLogDinners(ctx context.Context, fromWeekStart string) ([]Dinner, error)
|
||||||
// Moves a dinner still suggested to status (quest_log or archived),
|
// Moves a dinner still suggested to status (quest_log or archived),
|
||||||
// recording updated_by; a dinner no longer suggested is left as it is
|
// recording updated_by; a dinner no longer suggested is left as it is
|
||||||
// (0 rows).
|
// (0 rows).
|
||||||
|
|||||||
@@ -16,10 +16,12 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||||
@@ -273,12 +275,20 @@ func (s *Service) createRegular(ctx context.Context, r NewRegular, updatedBy str
|
|||||||
}
|
}
|
||||||
|
|
||||||
// List returns the dinners with status, which must be regular or
|
// List returns the dinners with status, which must be regular or
|
||||||
// quest_log, ordered by normalized name.
|
// quest_log, ordered by normalized name. The Quest log is the dinners
|
||||||
|
// accepted and not yet scheduled, so a quest_log dinner on the plan for the
|
||||||
|
// household's current week or a later one is left out of it.
|
||||||
func (s *Service) List(ctx context.Context, status string) ([]Dinner, error) {
|
func (s *Service) List(ctx context.Context, status string) ([]Dinner, error) {
|
||||||
if !slices.Contains(listable, status) {
|
if !slices.Contains(listable, status) {
|
||||||
return nil, invalid("status", "must be one of "+strings.Join(listable, ", "))
|
return nil, invalid("status", "must be one of "+strings.Join(listable, ", "))
|
||||||
}
|
}
|
||||||
rows, err := db.New(s.st.Reader).ListDinnersByStatus(ctx, status)
|
var rows []db.Dinner
|
||||||
|
var err error
|
||||||
|
if status == StatusQuestLog {
|
||||||
|
rows, err = s.listQuestLog(ctx)
|
||||||
|
} else {
|
||||||
|
rows, err = db.New(s.st.Reader).ListDinnersByStatus(ctx, status)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("dinners: list %s: %w", status, err)
|
return nil, fmt.Errorf("dinners: list %s: %w", status, err)
|
||||||
}
|
}
|
||||||
@@ -289,6 +299,29 @@ func (s *Service) List(ctx context.Context, status string) ([]Dinner, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// listQuestLog reads the Quest log in one read transaction, so the
|
||||||
|
// household's current week and the plans come from the same snapshot.
|
||||||
|
func (s *Service) listQuestLog(ctx context.Context) (rows []db.Dinner, err error) {
|
||||||
|
tx, err := s.st.Reader.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("begin read: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
err = errors.Join(err, tx.Rollback())
|
||||||
|
}()
|
||||||
|
q := db.New(tx)
|
||||||
|
settings, err := q.GetHouseholdSettings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read household settings: %w", err)
|
||||||
|
}
|
||||||
|
loc, err := household.LoadZone(settings.Timezone)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("household settings: %w", err)
|
||||||
|
}
|
||||||
|
thisWeek := household.WeekStart(household.Today(time.Now(), loc))
|
||||||
|
return q.ListUnplannedQuestLogDinners(ctx, thisWeek.String())
|
||||||
|
}
|
||||||
|
|
||||||
// Update applies p to dinner id, recording updatedBy. An invalid patch is a
|
// Update applies p to dinner id, recording updatedBy. An invalid patch is a
|
||||||
// *ValidationError and a new name repeating another unarchived dinner a
|
// *ValidationError and a new name repeating another unarchived dinner a
|
||||||
// *DuplicateError; either changes nothing. Renaming a dinner still pending
|
// *DuplicateError; either changes nothing. Renaming a dinner still pending
|
||||||
|
|||||||
@@ -11,9 +11,11 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
|
"time"
|
||||||
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
|
||||||
@@ -66,6 +68,10 @@ func (b *logBuffer) String() string {
|
|||||||
func newDinnersEnv(t *testing.T) *dinnersEnv {
|
func newDinnersEnv(t *testing.T) *dinnersEnv {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
st := newTestStore(t)
|
st := newTestStore(t)
|
||||||
|
// The Quest log is read against the household's current week.
|
||||||
|
if err := household.SeedSettings(context.Background(), st, "America/New_York"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
e := &dinnersEnv{
|
e := &dinnersEnv{
|
||||||
st: st,
|
st: st,
|
||||||
search: &recipestest.Search{Results: map[string][]recipes.Candidate{}},
|
search: &recipestest.Search{Results: map[string][]recipes.Candidate{}},
|
||||||
@@ -414,6 +420,61 @@ func TestListDinnersFiltersByStatus(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The Quest log leaves out a dinner on the plan for the household's current
|
||||||
|
// week or a later one: it is scheduled already. A dinner on an earlier
|
||||||
|
// week's plan only, or on none, is listed.
|
||||||
|
func TestQuestLogLeavesOutPlannedDinners(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
router, st := newDinnersRouter(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
names := []string{"Bibimbap", "Gumbo", "Pho", "Ramen", "Tagine"}
|
||||||
|
ids := map[string]string{}
|
||||||
|
for _, name := range names {
|
||||||
|
id := createDinner(t, router, `{"name":"`+name+`"}`)
|
||||||
|
if _, err := st.Writer.ExecContext(ctx, `UPDATE dinners SET status = 'quest_log', origin = 'side_quest' WHERE id = ?`, id); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ids[name] = id
|
||||||
|
}
|
||||||
|
|
||||||
|
loc, err := time.LoadLocation("America/New_York")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
thisWeek := household.WeekStart(household.Today(time.Now(), loc))
|
||||||
|
// day is the date days after the Monday weeks after this week's.
|
||||||
|
day := func(weeks, days int) string {
|
||||||
|
y, m, d := thisWeek.Date()
|
||||||
|
return time.Date(y, m, d+7*weeks+days, 0, 0, 0, 0, time.UTC).Format(time.DateOnly)
|
||||||
|
}
|
||||||
|
// Gumbo is on last week's plan only; Pho on this week's; Ramen on the
|
||||||
|
// week after next; Tagine on last week's and this week's.
|
||||||
|
plan := func(weeks int, dinners ...string) {
|
||||||
|
t.Helper()
|
||||||
|
weekStart := day(weeks, 0)
|
||||||
|
planID := "plan-" + weekStart
|
||||||
|
if _, err := st.Writer.ExecContext(ctx,
|
||||||
|
`INSERT INTO week_plans (id, week_start, status, created_at, updated_at) VALUES (?, ?, 'draft', 't', 't')`,
|
||||||
|
planID, weekStart); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for i, name := range dinners {
|
||||||
|
if _, err := st.Writer.ExecContext(ctx,
|
||||||
|
`INSERT INTO plan_days (id, plan_id, date, dinner_id, is_new, created_at, updated_at) VALUES (?, ?, ?, ?, 1, 't', 't')`,
|
||||||
|
planID+"-"+name, planID, day(weeks, i), ids[name]); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
plan(-1, "Gumbo", "Tagine")
|
||||||
|
plan(0, "Pho", "Tagine")
|
||||||
|
plan(2, "Ramen")
|
||||||
|
|
||||||
|
if got := strings.Join(listNames(t, router, "quest_log"), ","); got != "Bibimbap,Gumbo" {
|
||||||
|
t.Errorf("quest_log = %s, want Bibimbap,Gumbo", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPatchDinner(t *testing.T) {
|
func TestPatchDinner(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
router, st := newDinnersRouter(t)
|
router, st := newDinnersRouter(t)
|
||||||
|
|||||||
@@ -19,6 +19,19 @@ SELECT * FROM dinners
|
|||||||
WHERE status = ?
|
WHERE status = ?
|
||||||
ORDER BY normalized_name, id;
|
ORDER BY normalized_name, id;
|
||||||
|
|
||||||
|
-- name: ListUnplannedQuestLogDinners :many
|
||||||
|
-- The Quest log: dinners with status quest_log that no plan for the week
|
||||||
|
-- starting from_week_start or later has on any day.
|
||||||
|
SELECT * FROM dinners
|
||||||
|
WHERE status = 'quest_log'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM plan_days
|
||||||
|
JOIN week_plans ON week_plans.id = plan_days.plan_id
|
||||||
|
WHERE plan_days.dinner_id = dinners.id
|
||||||
|
AND week_plans.week_start >= sqlc.arg(from_week_start)
|
||||||
|
)
|
||||||
|
ORDER BY normalized_name, id;
|
||||||
|
|
||||||
-- name: CountRegularDinners :one
|
-- name: CountRegularDinners :one
|
||||||
-- How many Regulars the household has; archived dinners are not counted.
|
-- How many Regulars the household has; archived dinners are not counted.
|
||||||
SELECT COUNT(*) FROM dinners
|
SELECT COUNT(*) FROM dinners
|
||||||
|
|||||||
@@ -106,7 +106,8 @@ export function photoId(where: string, value: unknown): string | undefined {
|
|||||||
return decodeURIComponent(match[1])
|
return decodeURIComponent(match[1])
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseDinner(where: string, body: unknown): Regular {
|
// parseDinner checks a dinner as the dinners API sends it.
|
||||||
|
export function parseDinner(where: string, body: unknown): Regular {
|
||||||
if (!isRecord(body) || typeof body.id !== 'string' || typeof body.name !== 'string') {
|
if (!isRecord(body) || typeof body.id !== 'string' || typeof body.name !== 'string') {
|
||||||
throw new Error(`${where}: response is not a dinner`)
|
throw new Error(`${where}: response is not a dinner`)
|
||||||
}
|
}
|
||||||
@@ -141,7 +142,7 @@ function parseCandidate(where: string, body: unknown): RecipeCandidate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function dinnerPath(id: string): string {
|
export function dinnerPath(id: string): string {
|
||||||
return `${DINNERS_PATH}/${encodeURIComponent(id)}`
|
return `${DINNERS_PATH}/${encodeURIComponent(id)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import styles from './DiscoverPage.module.css'
|
|||||||
// DECK_DEPTH is the depths the deck shows, top card first.
|
// DECK_DEPTH is the depths the deck shows, top card first.
|
||||||
export const DECK_DEPTH = [0, 1, 2] as const
|
export const DECK_DEPTH = [0, 1, 2] as const
|
||||||
|
|
||||||
const DIFFICULTY_LABEL: Record<Difficulty, string> = { easy: 'Easy', medium: 'Medium', hard: 'Hard' }
|
export const DIFFICULTY_LABEL: Record<Difficulty, string> = { easy: 'Easy', medium: 'Medium', hard: 'Hard' }
|
||||||
|
|
||||||
// SwipeHandlers are the pointer handlers that drag the top card.
|
// SwipeHandlers are the pointer handlers that drag the top card.
|
||||||
export type SwipeHandlers = Pick<
|
export type SwipeHandlers = Pick<
|
||||||
|
|||||||
@@ -59,26 +59,6 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The Quest log's dinner names, under the header while the pill is open. */
|
|
||||||
.questLogList {
|
|
||||||
flex: 1 0 100%;
|
|
||||||
font-family: var(--font-body);
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.questLogNames {
|
|
||||||
margin: 0;
|
|
||||||
padding-left: 20px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.questLogEmpty {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--color-neutral-700);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The deck: three cards stacked in the same box, the top one in front. */
|
/* The deck: three cards stacked in the same box, the top one in front. */
|
||||||
.deck {
|
.deck {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
|
import { createMemoryRouter, RouterProvider, useParams } from 'react-router'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi, type Mock } from 'vitest'
|
||||||
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
||||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||||
import { stubAnimate, type FakeAnimation } from '../test/animate.ts'
|
import { stubAnimate, type FakeAnimation } from '../test/animate.ts'
|
||||||
import { declaredStyle } from '../test/cssRules.ts'
|
import { declaredStyle } from '../test/cssRules.ts'
|
||||||
|
import { dinnerBody, type DinnerBody } from '../test/dinners.ts'
|
||||||
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
||||||
import { expectMinHitArea } from '../test/hitArea.ts'
|
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||||
import { mockViewport } from '../test/matchMedia.ts'
|
import { mockViewport } from '../test/matchMedia.ts'
|
||||||
@@ -14,6 +16,12 @@ import { DiscoverPage } from './DiscoverPage.tsx'
|
|||||||
const DECK = '/api/v1/suggestions/deck'
|
const DECK = '/api/v1/suggestions/deck'
|
||||||
const QUEST_LOG = '/api/v1/dinners?status=quest_log'
|
const QUEST_LOG = '/api/v1/dinners?status=quest_log'
|
||||||
|
|
||||||
|
// questLogBody is a Quest-log dinner as GET /api/v1/dinners?status=quest_log
|
||||||
|
// sends it.
|
||||||
|
function questLogBody(id: string, name: string, overrides: Partial<DinnerBody> = {}) {
|
||||||
|
return dinnerBody(id, name, { status: 'quest_log', origin: 'side_quest', ...overrides })
|
||||||
|
}
|
||||||
|
|
||||||
// SuggestionBody is a suggestion as GET /api/v1/suggestions/deck sends it.
|
// SuggestionBody is a suggestion as GET /api/v1/suggestions/deck sends it.
|
||||||
function suggestionBody(id: string, name: string, overrides: Record<string, unknown> = {}) {
|
function suggestionBody(id: string, name: string, overrides: Record<string, unknown> = {}) {
|
||||||
return {
|
return {
|
||||||
@@ -51,7 +59,7 @@ function stubServer(deck: unknown[], responders: Record<string, Responder> = {},
|
|||||||
'GET /api/v1/me': () => jsonResponse(200, ME),
|
'GET /api/v1/me': () => jsonResponse(200, ME),
|
||||||
[`GET ${DECK}`]: () => jsonResponse(200, { suggestions: server.deck }),
|
[`GET ${DECK}`]: () => jsonResponse(200, { suggestions: server.deck }),
|
||||||
[`GET ${QUEST_LOG}`]: () =>
|
[`GET ${QUEST_LOG}`]: () =>
|
||||||
jsonResponse(200, { dinners: Array.from({ length: server.questLog }, (_, i) => ({ id: `q${i}`, name: `Quest ${i}` })) }),
|
jsonResponse(200, { dinners: Array.from({ length: server.questLog }, (_, i) => questLogBody(`q${i}`, `Quest ${i}`)) }),
|
||||||
...responders,
|
...responders,
|
||||||
}
|
}
|
||||||
server.fetchMock = vi.fn<typeof fetch>((input, init) => {
|
server.fetchMock = vi.fn<typeof fetch>((input, init) => {
|
||||||
@@ -102,15 +110,27 @@ async function finishRunning(animations: FakeAnimation[]) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DinnerProbe stands in for the dinner screen, showing the id it opened.
|
||||||
|
function DinnerProbe() {
|
||||||
|
return <p>Dinner screen for {useParams().id}</p>
|
||||||
|
}
|
||||||
|
|
||||||
// renderPage loads /me (for the CSRF token every write needs) and renders
|
// renderPage loads /me (for the CSRF token every write needs) and renders
|
||||||
// the Discover page with queryClient.
|
// the Discover page with queryClient at /discover, with /dinner/:id beside it.
|
||||||
async function renderPage(queryClient = createQueryClient()) {
|
async function renderPage(queryClient = createQueryClient()) {
|
||||||
const api = createApiClient(fakeLocation('/discover'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
const api = createApiClient(fakeLocation('/discover'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||||
await api.loadMe()
|
await api.loadMe()
|
||||||
|
const router = createMemoryRouter(
|
||||||
|
[
|
||||||
|
{ path: '/discover', element: <DiscoverPage /> },
|
||||||
|
{ path: '/dinner/:id', element: <DinnerProbe /> },
|
||||||
|
],
|
||||||
|
{ initialEntries: ['/discover'] },
|
||||||
|
)
|
||||||
return render(
|
return render(
|
||||||
<ApiProvider client={api}>
|
<ApiProvider client={api}>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<DiscoverPage />
|
<RouterProvider router={router} />
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</ApiProvider>,
|
</ApiProvider>,
|
||||||
)
|
)
|
||||||
@@ -359,34 +379,6 @@ describe('DiscoverPage', () => {
|
|||||||
await screen.findByRole('article', { name: 'Korean beef bowls' })
|
await screen.findByRole('article', { name: 'Korean beef bowls' })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('opens and closes the Quest log from its pill, a keyboard-reachable button', async () => {
|
|
||||||
stubServer([BOWLS], {}, 2)
|
|
||||||
await renderPage()
|
|
||||||
const pill = await screen.findByRole('button', { name: 'Quest log · 2' })
|
|
||||||
expect(pill).toHaveAttribute('aria-expanded', 'false')
|
|
||||||
expect(screen.queryByRole('list', { name: 'Quest log' })).not.toBeInTheDocument()
|
|
||||||
expectMinHitArea(pill)
|
|
||||||
pill.focus()
|
|
||||||
expect(pill).toHaveFocus()
|
|
||||||
|
|
||||||
fireEvent.click(pill)
|
|
||||||
expect(pill).toHaveAttribute('aria-expanded', 'true')
|
|
||||||
const list = screen.getByRole('list', { name: 'Quest log' })
|
|
||||||
expect(pill).toHaveAttribute('aria-controls', list.parentElement!.id)
|
|
||||||
expect(within(list).getAllByRole('listitem').map((item) => item.textContent)).toEqual(['Quest 0', 'Quest 1'])
|
|
||||||
|
|
||||||
fireEvent.click(pill)
|
|
||||||
expect(pill).toHaveAttribute('aria-expanded', 'false')
|
|
||||||
expect(screen.queryByRole('list', { name: 'Quest log' })).not.toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('says so when the Quest log it opens is empty', async () => {
|
|
||||||
stubServer([BOWLS], {}, 0)
|
|
||||||
await renderPage()
|
|
||||||
fireEvent.click(await screen.findByRole('button', { name: 'Quest log · 0' }))
|
|
||||||
expect(screen.getByText('No dinners in the Quest log yet. Try it on a Side quest to add one.')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('sends one decision for presses made before the page re-renders', async () => {
|
it('sends one decision for presses made before the page re-renders', async () => {
|
||||||
const accept = deferred()
|
const accept = deferred()
|
||||||
const server = stubServer([BOWLS, TIKKA], { 'POST /api/v1/suggestions/s1/accept': () => accept.promise })
|
const server = stubServer([BOWLS, TIKKA], { 'POST /api/v1/suggestions/s1/accept': () => accept.promise })
|
||||||
@@ -511,6 +503,254 @@ describe('DiscoverPage', () => {
|
|||||||
expect(screen.getByRole('heading', { name: 'All caught up' })).toBeInTheDocument()
|
expect(screen.getByRole('heading', { name: 'All caught up' })).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('Quest log sheet', () => {
|
||||||
|
const PAD_THAI = questLogBody('q1', 'Pad thai', { photo_url: '/media/photos/p9-400.jpg', difficulty: 'medium', minutes_total: 35 })
|
||||||
|
const GUMBO = questLogBody('q2', 'Shrimp gumbo', { difficulty: 'hard', minutes_total: null })
|
||||||
|
const RISOTTO = questLogBody('q3', 'Mushroom risotto')
|
||||||
|
|
||||||
|
// stubQuestLog serves the deck and server.dinners as the Quest log.
|
||||||
|
function stubQuestLog(dinners: unknown[], responders: Record<string, Responder> = {}) {
|
||||||
|
const questLog = { dinners }
|
||||||
|
const server = stubServer([BOWLS], { [`GET ${QUEST_LOG}`]: () => jsonResponse(200, { dinners: questLog.dinners }), ...responders })
|
||||||
|
return Object.assign(server, { questLogDinners: questLog })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openSheet(count: number) {
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: `Quest log · ${count}` }))
|
||||||
|
return screen.getByRole('dialog', { name: 'Quest log' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function rows(sheet: HTMLElement) {
|
||||||
|
return within(within(sheet).getByRole('list', { name: 'Quest log' })).getAllByRole('listitem')
|
||||||
|
}
|
||||||
|
|
||||||
|
// names is the dinners the sheet lists, read off their remove buttons.
|
||||||
|
function names(sheet: HTMLElement) {
|
||||||
|
return rows(sheet).map((row) => within(row).getByRole('button', { name: /^Remove / }).getAttribute('aria-label')!.slice('Remove '.length))
|
||||||
|
}
|
||||||
|
|
||||||
|
it('opens from the "Quest log · N" pill and lists each dinner: 48 px washed photo, name, difficulty and minutes', async () => {
|
||||||
|
stubQuestLog([PAD_THAI, GUMBO, RISOTTO])
|
||||||
|
await renderPage()
|
||||||
|
const pill = await screen.findByRole('button', { name: 'Quest log · 3' })
|
||||||
|
expect(pill).toHaveAttribute('aria-haspopup', 'dialog')
|
||||||
|
expect(pill).toHaveAttribute('aria-expanded', 'false')
|
||||||
|
expectMinHitArea(pill)
|
||||||
|
pill.focus()
|
||||||
|
|
||||||
|
fireEvent.click(pill)
|
||||||
|
const sheet = screen.getByRole('dialog', { name: 'Quest log' })
|
||||||
|
expect(pill).toHaveAttribute('aria-expanded', 'true')
|
||||||
|
const [padThai, gumbo, risotto] = rows(sheet)
|
||||||
|
expect(names(sheet)).toEqual(['Pad thai', 'Shrimp gumbo', 'Mushroom risotto'])
|
||||||
|
expect(within(padThai!).getByText('Medium · 35 min')).toBeInTheDocument()
|
||||||
|
expect(within(gumbo!).getByText('Hard')).toBeInTheDocument()
|
||||||
|
// Nothing known, no meta line.
|
||||||
|
expect(within(risotto!).getByRole('button', { name: /^Mushroom risotto/ })).toHaveTextContent(/^dish photoMushroom risotto$/)
|
||||||
|
|
||||||
|
// The photo is the stored one, through the washed treatment, at 48 px.
|
||||||
|
const photo = padThai!.querySelector('img')!
|
||||||
|
expect(photo).toHaveAttribute('src', '/media/photos/p9-400.jpg')
|
||||||
|
expect(declaredStyle(photo).filter).toBe('saturate(0.6) contrast(0.85) brightness(1.1) opacity(0.94)')
|
||||||
|
expect(declaredStyle(photo).width).toBe('48px')
|
||||||
|
expect(declaredStyle(photo).height).toBe('48px')
|
||||||
|
// Without a photo, the striped placeholder stands in.
|
||||||
|
expect(gumbo!.querySelector('img')).toBeNull()
|
||||||
|
expect(within(gumbo!).getByText('dish photo')).toBeInTheDocument()
|
||||||
|
|
||||||
|
for (const row of [padThai!, gumbo!, risotto!]) {
|
||||||
|
for (const button of within(row).getAllByRole('button')) expectMinHitArea(button)
|
||||||
|
}
|
||||||
|
|
||||||
|
fireEvent.keyDown(sheet, { key: 'Escape' })
|
||||||
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||||
|
expect(pill).toHaveAttribute('aria-expanded', 'false')
|
||||||
|
expect(pill).toHaveFocus()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lists only the dinners the Quest log read returns, so ones already on the current or a future plan stay out', async () => {
|
||||||
|
// The server leaves out dinners on the current or a later week's plan;
|
||||||
|
// the sheet adds none and works out nothing of its own.
|
||||||
|
const server = stubQuestLog([RISOTTO])
|
||||||
|
await renderPage()
|
||||||
|
const sheet = await openSheet(1)
|
||||||
|
expect(names(sheet)).toEqual(['Mushroom risotto'])
|
||||||
|
expect(server.fetchMock.mock.calls.map(([input]) => String(input)).filter((path) => path.startsWith('/api/v1/plans'))).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens /dinner/:id from a row', async () => {
|
||||||
|
stubQuestLog([PAD_THAI, GUMBO])
|
||||||
|
await renderPage()
|
||||||
|
const sheet = await openSheet(2)
|
||||||
|
fireEvent.click(within(sheet).getByRole('button', { name: /^Shrimp gumbo/ }))
|
||||||
|
expect(await screen.findByText('Dinner screen for q2')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removes a dinner only once confirmed, archiving it and taking one off the count', async () => {
|
||||||
|
const server = stubQuestLog([PAD_THAI, GUMBO], {
|
||||||
|
'DELETE /api/v1/dinners/q1': () => {
|
||||||
|
server.questLogDinners.dinners = [GUMBO]
|
||||||
|
return jsonResponse(200, { ...PAD_THAI, status: 'archived' })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await renderPage()
|
||||||
|
const sheet = await openSheet(2)
|
||||||
|
|
||||||
|
fireEvent.click(within(sheet).getByRole('button', { name: 'Remove Pad thai' }))
|
||||||
|
const confirm = screen.getByRole('dialog', { name: 'Remove Pad thai?' })
|
||||||
|
expect(confirm).toHaveTextContent('It leaves your Quest log for good.')
|
||||||
|
fireEvent.click(within(confirm).getByRole('button', { name: 'Keep it' }))
|
||||||
|
expect(screen.queryByRole('dialog', { name: 'Remove Pad thai?' })).not.toBeInTheDocument()
|
||||||
|
expect(rows(sheet)).toHaveLength(2)
|
||||||
|
expect(sent(server.fetchMock)).toEqual([])
|
||||||
|
|
||||||
|
fireEvent.click(within(sheet).getByRole('button', { name: 'Remove Pad thai' }))
|
||||||
|
fireEvent.click(within(screen.getByRole('dialog', { name: 'Remove Pad thai?' })).getByRole('button', { name: 'Remove' }))
|
||||||
|
await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Remove Pad thai?' })).not.toBeInTheDocument())
|
||||||
|
expect(sent(server.fetchMock)).toEqual(['DELETE /api/v1/dinners/q1'])
|
||||||
|
expect(names(sheet)).toEqual(['Shrimp gumbo'])
|
||||||
|
expect(screen.getByRole('button', { name: 'Quest log · 1' })).toBeInTheDocument()
|
||||||
|
// The Quest log is read again after the removal, and agrees.
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(server.fetchMock.mock.calls.filter(([input]) => String(input) === QUEST_LOG)).toHaveLength(2),
|
||||||
|
)
|
||||||
|
expect(screen.getByRole('button', { name: 'Quest log · 1' })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps focus in the open Quest log after a removal: on the next row, the one before the last, then the sheet', async () => {
|
||||||
|
const archive = (id: string) => () => {
|
||||||
|
server.questLogDinners.dinners = server.questLogDinners.dinners.filter((dinner) => dinner !== byId[id])
|
||||||
|
return jsonResponse(200, { ...byId[id]!, status: 'archived' })
|
||||||
|
}
|
||||||
|
const byId: Record<string, DinnerBody> = { q1: PAD_THAI, q2: GUMBO, q3: RISOTTO }
|
||||||
|
const server = stubQuestLog([PAD_THAI, GUMBO, RISOTTO], {
|
||||||
|
'DELETE /api/v1/dinners/q1': archive('q1'),
|
||||||
|
'DELETE /api/v1/dinners/q2': archive('q2'),
|
||||||
|
'DELETE /api/v1/dinners/q3': archive('q3'),
|
||||||
|
})
|
||||||
|
await renderPage()
|
||||||
|
const sheet = await openSheet(3)
|
||||||
|
|
||||||
|
async function removeAndExpectFocus(name: string, expected: () => HTMLElement) {
|
||||||
|
within(sheet).getByRole('button', { name: `Remove ${name}` }).focus()
|
||||||
|
fireEvent.click(within(sheet).getByRole('button', { name: `Remove ${name}` }))
|
||||||
|
fireEvent.click(within(screen.getByRole('dialog', { name: `Remove ${name}?` })).getByRole('button', { name: 'Remove' }))
|
||||||
|
await waitFor(() => expect(screen.queryByRole('dialog', { name: `Remove ${name}?` })).not.toBeInTheDocument())
|
||||||
|
expect(expected()).toHaveFocus()
|
||||||
|
expect(sheet).toContainElement(document.activeElement as HTMLElement)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A middle row: focus goes to the row now in its place.
|
||||||
|
await removeAndExpectFocus('Shrimp gumbo', () => within(sheet).getByRole('button', { name: 'Remove Mushroom risotto' }))
|
||||||
|
// The last row: focus goes to the row before it.
|
||||||
|
await removeAndExpectFocus('Mushroom risotto', () => within(sheet).getByRole('button', { name: 'Remove Pad thai' }))
|
||||||
|
// The only row: focus goes to the sheet itself.
|
||||||
|
await removeAndExpectFocus('Pad thai', () => sheet)
|
||||||
|
expect(within(sheet).getByText('Your Quest log is empty — swipe right on a side quest to add one.')).toBeInTheDocument()
|
||||||
|
|
||||||
|
// From there Escape still reaches the Quest log, and closes it.
|
||||||
|
fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
|
||||||
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['Keep it', (confirm: HTMLElement) => fireEvent.click(within(confirm).getByRole('button', { name: 'Keep it' }))],
|
||||||
|
['Escape', (confirm: HTMLElement) => fireEvent.keyDown(confirm, { key: 'Escape' })],
|
||||||
|
['the scrim', (confirm: HTMLElement) => fireEvent.click(confirm.previousElementSibling!)],
|
||||||
|
])('keeps the confirmation and sends one removal once Remove is activated: %s and a second Remove at once do nothing', async (_, dismiss) => {
|
||||||
|
const archive = deferred()
|
||||||
|
const server = stubQuestLog([PAD_THAI, GUMBO], { 'DELETE /api/v1/dinners/q1': () => archive.promise })
|
||||||
|
await renderPage()
|
||||||
|
const sheet = await openSheet(2)
|
||||||
|
|
||||||
|
fireEvent.click(within(sheet).getByRole('button', { name: 'Remove Pad thai' }))
|
||||||
|
const confirm = screen.getByRole('dialog', { name: 'Remove Pad thai?' })
|
||||||
|
const removeButton = within(confirm).getByRole('button', { name: 'Remove' })
|
||||||
|
// Straight after Remove, without waiting for the pending request to render.
|
||||||
|
fireEvent.click(removeButton)
|
||||||
|
fireEvent.click(removeButton)
|
||||||
|
dismiss(confirm)
|
||||||
|
expect(screen.getByRole('dialog', { name: 'Remove Pad thai?' })).toBe(confirm)
|
||||||
|
|
||||||
|
expect(await within(confirm).findByRole('button', { name: 'Removing…' })).toBeDisabled()
|
||||||
|
expect(within(confirm).getByRole('button', { name: 'Keep it' })).toBeDisabled()
|
||||||
|
dismiss(confirm)
|
||||||
|
expect(screen.getByRole('dialog', { name: 'Remove Pad thai?' })).toBe(confirm)
|
||||||
|
|
||||||
|
server.questLogDinners.dinners = [GUMBO]
|
||||||
|
await act(async () => archive.resolve(jsonResponse(200, { ...PAD_THAI, status: 'archived' })))
|
||||||
|
await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Remove Pad thai?' })).not.toBeInTheDocument())
|
||||||
|
expect(names(sheet)).toEqual(['Shrimp gumbo'])
|
||||||
|
expect(sent(server.fetchMock)).toEqual(['DELETE /api/v1/dinners/q1'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['keeps a disabled button focused', false],
|
||||||
|
['moves focus off a disabled button to the page', true],
|
||||||
|
])('keeps focus in the confirmation, off its disabled buttons, while a removal is under way when the browser %s', async (_, fixup) => {
|
||||||
|
// Some browsers move focus from a control to the page as it is disabled.
|
||||||
|
const setAttribute = Element.prototype.setAttribute
|
||||||
|
if (fixup) {
|
||||||
|
vi.spyOn(Element.prototype, 'setAttribute').mockImplementation(function (this: Element, name, value) {
|
||||||
|
// jsdom ignores blur() on a disabled control, so focus leaves just before.
|
||||||
|
if (name === 'disabled' && this === document.activeElement && this instanceof HTMLElement) this.blur()
|
||||||
|
setAttribute.call(this, name, value)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onTestFinished(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
const archive = deferred()
|
||||||
|
const server = stubQuestLog([PAD_THAI, GUMBO], { 'DELETE /api/v1/dinners/q1': () => archive.promise })
|
||||||
|
await renderPage()
|
||||||
|
const sheet = await openSheet(2)
|
||||||
|
|
||||||
|
fireEvent.click(within(sheet).getByRole('button', { name: 'Remove Pad thai' }))
|
||||||
|
const confirm = screen.getByRole('dialog', { name: 'Remove Pad thai?' })
|
||||||
|
const removeButton = within(confirm).getByRole('button', { name: 'Remove' })
|
||||||
|
removeButton.focus()
|
||||||
|
fireEvent.click(removeButton)
|
||||||
|
expect(removeButton).toBeDisabled()
|
||||||
|
expect(confirm).toHaveFocus()
|
||||||
|
|
||||||
|
expect(await within(confirm).findByRole('button', { name: 'Removing…' })).toBeDisabled()
|
||||||
|
expect(confirm).toHaveFocus()
|
||||||
|
|
||||||
|
server.questLogDinners.dinners = [GUMBO]
|
||||||
|
await act(async () => archive.resolve(jsonResponse(200, { ...PAD_THAI, status: 'archived' })))
|
||||||
|
await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Remove Pad thai?' })).not.toBeInTheDocument())
|
||||||
|
expect(within(sheet).getByRole('button', { name: 'Remove Shrimp gumbo' })).toHaveFocus()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps a dinner whose removal the server refuses, saying why', async () => {
|
||||||
|
const server = stubQuestLog([PAD_THAI, GUMBO], { 'DELETE /api/v1/dinners/q1': () => errorResponse(500, 'internal') })
|
||||||
|
await renderPage()
|
||||||
|
const sheet = await openSheet(2)
|
||||||
|
|
||||||
|
fireEvent.click(within(sheet).getByRole('button', { name: 'Remove Pad thai' }))
|
||||||
|
const confirm = screen.getByRole('dialog', { name: 'Remove Pad thai?' })
|
||||||
|
fireEvent.click(within(confirm).getByRole('button', { name: 'Remove' }))
|
||||||
|
expect(await within(confirm).findByRole('alert')).toHaveTextContent("Couldn't remove it. internal message")
|
||||||
|
expect(sent(server.fetchMock)).toEqual(['DELETE /api/v1/dinners/q1'])
|
||||||
|
expect(rows(sheet)).toHaveLength(2)
|
||||||
|
expect(screen.getByRole('button', { name: 'Quest log · 2' })).toBeInTheDocument()
|
||||||
|
|
||||||
|
// Keep it closes the confirmation and clears the error.
|
||||||
|
fireEvent.click(within(confirm).getByRole('button', { name: 'Keep it' }))
|
||||||
|
fireEvent.click(within(sheet).getByRole('button', { name: 'Remove Pad thai' }))
|
||||||
|
expect(within(screen.getByRole('dialog', { name: 'Remove Pad thai?' })).queryByRole('alert')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('says how to fill an empty Quest log', async () => {
|
||||||
|
stubQuestLog([])
|
||||||
|
await renderPage()
|
||||||
|
const sheet = await openSheet(0)
|
||||||
|
expect(within(sheet).getByText('Your Quest log is empty — swipe right on a side quest to add one.')).toBeInTheDocument()
|
||||||
|
expect(within(sheet).queryByRole('list')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('optimistic decisions', () => {
|
describe('optimistic decisions', () => {
|
||||||
const PAELLA = suggestionBody('s4', 'Sheet pan paella')
|
const PAELLA = suggestionBody('s4', 'Sheet pan paella')
|
||||||
const SOUP = suggestionBody('s5', 'Tortellini soup')
|
const SOUP = suggestionBody('s5', 'Tortellini soup')
|
||||||
@@ -733,8 +973,8 @@ describe('DiscoverPage', () => {
|
|||||||
[`GET ${QUEST_LOG}`]: () =>
|
[`GET ${QUEST_LOG}`]: () =>
|
||||||
jsonResponse(200, {
|
jsonResponse(200, {
|
||||||
dinners: [
|
dinners: [
|
||||||
{ id: 'q0', name: 'Quest 0' },
|
questLogBody('q0', 'Quest 0'),
|
||||||
{ id: 'dinner-s1', name: 'Korean beef bowls' },
|
questLogBody('dinner-s1', 'Korean beef bowls'),
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
@@ -754,7 +994,7 @@ describe('DiscoverPage', () => {
|
|||||||
|
|
||||||
it('takes out of the Quest log only the entry a refused Try it added, after a read of the Quest log lands during it', async () => {
|
it('takes out of the Quest log only the entry a refused Try it added, after a read of the Quest log lands during it', async () => {
|
||||||
const accept = deferred()
|
const accept = deferred()
|
||||||
const questLog = [{ id: 'q0', name: 'Quest 0' }]
|
const questLog = [questLogBody('q0', 'Quest 0')]
|
||||||
const server = stubServer([BOWLS, TIKKA, ORZO, PAELLA], {
|
const server = stubServer([BOWLS, TIKKA, ORZO, PAELLA], {
|
||||||
'POST /api/v1/suggestions/s1/accept': () => accept.promise,
|
'POST /api/v1/suggestions/s1/accept': () => accept.promise,
|
||||||
[`GET ${QUEST_LOG}`]: () => jsonResponse(200, { dinners: questLog }),
|
[`GET ${QUEST_LOG}`]: () => jsonResponse(200, { dinners: questLog }),
|
||||||
@@ -770,7 +1010,7 @@ describe('DiscoverPage', () => {
|
|||||||
|
|
||||||
// The decision is saved, and a read of the Quest log lands with its
|
// The decision is saved, and a read of the Quest log lands with its
|
||||||
// dinner in it; then the answer to the decision is lost.
|
// dinner in it; then the answer to the decision is lost.
|
||||||
questLog.push({ id: 'dinner-s1', name: 'Korean beef bowls' })
|
questLog.push(questLogBody('dinner-s1', 'Korean beef bowls'))
|
||||||
await act(async () => await queryClient.invalidateQueries({ queryKey: ['dinners', 'quest_log'] }))
|
await act(async () => await queryClient.invalidateQueries({ queryKey: ['dinners', 'quest_log'] }))
|
||||||
accept.resolve(errorResponse(500, 'internal'))
|
accept.resolve(errorResponse(500, 'internal'))
|
||||||
await screen.findByRole('alert')
|
await screen.findByRole('alert')
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import { Heart, X } from 'lucide-react'
|
import { Heart, X } from 'lucide-react'
|
||||||
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type PointerEvent, type Ref } from 'react'
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type PointerEvent, type Ref } from 'react'
|
||||||
import { useJob } from '../api/index.ts'
|
import { useJob } from '../api/index.ts'
|
||||||
import { Card } from '../ui/Card.tsx'
|
|
||||||
import { EmptyState } from '../ui/EmptyState.tsx'
|
import { EmptyState } from '../ui/EmptyState.tsx'
|
||||||
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
|
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
|
||||||
import { Icon } from '../ui/Icon.tsx'
|
import { Icon } from '../ui/Icon.tsx'
|
||||||
@@ -19,6 +18,7 @@ import {
|
|||||||
useQuestLog,
|
useQuestLog,
|
||||||
useStartBatch,
|
useStartBatch,
|
||||||
type Choice,
|
type Choice,
|
||||||
|
type QuestLogDinner,
|
||||||
type SideQuest,
|
type SideQuest,
|
||||||
} from './api.ts'
|
} from './api.ts'
|
||||||
import { DeckCard, DECK_DEPTH, type SwipeHandlers } from './DeckCard.tsx'
|
import { DeckCard, DECK_DEPTH, type SwipeHandlers } from './DeckCard.tsx'
|
||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
unfollow,
|
unfollow,
|
||||||
type Exit,
|
type Exit,
|
||||||
} from './motion.ts'
|
} from './motion.ts'
|
||||||
|
import { QuestLogSheet } from './QuestLogSheet.tsx'
|
||||||
import styles from './DiscoverPage.module.css'
|
import styles from './DiscoverPage.module.css'
|
||||||
|
|
||||||
// SWIPE_THRESHOLD is how far, in px, the top card must be dragged left or
|
// SWIPE_THRESHOLD is how far, in px, the top card must be dragged left or
|
||||||
@@ -54,36 +55,22 @@ function isEditable(target: EventTarget | null): boolean {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// QuestLogPill is the "Quest log · N" pill. It shows and hides the names
|
// QuestLogPill is the "Quest log · N" pill, which opens the Quest log
|
||||||
// of the dinners in the Quest log; the Quest log sheet replaces this list.
|
// sheet. ref is the visible pill, which Try it bumps.
|
||||||
// ref is the visible pill, which Try it bumps.
|
function QuestLogPill({ dinners, ref }: { dinners: QuestLogDinner[]; ref: Ref<HTMLSpanElement> }) {
|
||||||
function QuestLogPill({ dinners, ref }: { dinners: { id: string; name: string }[]; ref: Ref<HTMLSpanElement> }) {
|
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const listId = useId()
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.questLogButton}
|
className={styles.questLogButton}
|
||||||
|
aria-haspopup="dialog"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
aria-controls={open ? listId : undefined}
|
onClick={() => setOpen(true)}
|
||||||
onClick={() => setOpen(!open)}
|
|
||||||
>
|
>
|
||||||
<span ref={ref} className={styles.questLog}>Quest log · {dinners.length}</span>
|
<span ref={ref} className={styles.questLog}>Quest log · {dinners.length}</span>
|
||||||
</button>
|
</button>
|
||||||
{open && (
|
<QuestLogSheet open={open} dinners={dinners} onClose={() => setOpen(false)} />
|
||||||
<Card id={listId} tone="muted" className={styles.questLogList}>
|
|
||||||
{dinners.length === 0 ? (
|
|
||||||
<p className={styles.questLogEmpty}>No dinners in the Quest log yet. Try it on a Side quest to add one.</p>
|
|
||||||
) : (
|
|
||||||
<ul aria-label="Quest log" className={styles.questLogNames}>
|
|
||||||
{dinners.map((dinner) => (
|
|
||||||
<li key={dinner.id}>{dinner.name}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
/* The Quest log sheet (mock 1a's "Quest log · N" pill opens it). */
|
||||||
|
.sheet {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
max-width: 560px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rows {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The 48 px washed photo; Photo applies the washed filter. */
|
||||||
|
.photo {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--color-neutral-700);
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { Trash2 } from 'lucide-react'
|
||||||
|
import { useEffect, useId, useRef, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router'
|
||||||
|
import { Confirm } from '../settings/LeaveGuard.tsx'
|
||||||
|
import { Icon } from '../ui/Icon.tsx'
|
||||||
|
import { IconButton } from '../ui/IconButton.tsx'
|
||||||
|
import { ListRow } from '../ui/ListRow.tsx'
|
||||||
|
import { Photo } from '../ui/Photo.tsx'
|
||||||
|
import { Sheet } from '../ui/Sheet.tsx'
|
||||||
|
import { useRemoveFromQuestLog, type QuestLogDinner } from './api.ts'
|
||||||
|
import { DIFFICULTY_LABEL } from './DeckCard.tsx'
|
||||||
|
import styles from './QuestLogSheet.module.css'
|
||||||
|
|
||||||
|
export type QuestLogSheetProps = {
|
||||||
|
open: boolean
|
||||||
|
// The Quest log, as useQuestLog reads it.
|
||||||
|
dinners: QuestLogDinner[]
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// details is a Quest-log dinner's "difficulty · minutes" line, or null when
|
||||||
|
// neither is known.
|
||||||
|
function details(dinner: QuestLogDinner): string | null {
|
||||||
|
const parts = [
|
||||||
|
dinner.difficulty !== null && DIFFICULTY_LABEL[dinner.difficulty],
|
||||||
|
dinner.minutes_total !== null && `${dinner.minutes_total} min`,
|
||||||
|
].filter((part): part is string => typeof part === 'string')
|
||||||
|
return parts.length === 0 ? null : parts.join(' · ')
|
||||||
|
}
|
||||||
|
|
||||||
|
// QuestLogSheet is the Quest log as a bottom sheet: each dinner with its
|
||||||
|
// washed photo, name, difficulty and minutes. A row opens the dinner; its
|
||||||
|
// remove button, once confirmed, archives the dinner.
|
||||||
|
export function QuestLogSheet({ open, dinners, onClose }: QuestLogSheetProps) {
|
||||||
|
const titleId = useId()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const remove = useRemoveFromQuestLog()
|
||||||
|
// The dinner whose removal is being confirmed.
|
||||||
|
const [removing, setRemoving] = useState<QuestLogDinner | null>(null)
|
||||||
|
// The dinner just removed and its place in the list, until focus has
|
||||||
|
// moved on from it.
|
||||||
|
const removed = useRef<{ id: string; index: number } | null>(null)
|
||||||
|
const removeButtons = useRef(new Map<string, HTMLButtonElement>())
|
||||||
|
const sheetRef = useRef<HTMLDivElement>(null)
|
||||||
|
// Whether a removal has been sent and not yet settled. The ref is set as
|
||||||
|
// Remove is activated, so a dismissal or a second Remove that arrives before
|
||||||
|
// the next render is already refused; the state renders it.
|
||||||
|
const sending = useRef(false)
|
||||||
|
const [sendingShown, setSendingShown] = useState(false)
|
||||||
|
|
||||||
|
// Once a removal's confirmation has closed and the dinner has left the list,
|
||||||
|
// focus goes to the remove button of the dinner now in its place, or of the
|
||||||
|
// one before it when it was the last, or to the Quest log sheet itself when
|
||||||
|
// none is left. Otherwise it would be left on the removed dinner's button,
|
||||||
|
// which the confirmation hands it back to, and which is then gone.
|
||||||
|
useEffect(() => {
|
||||||
|
const done = removed.current
|
||||||
|
if (removing !== null || done === null || dinners.some((d) => d.id === done.id)) return
|
||||||
|
removed.current = null
|
||||||
|
const next = dinners[Math.min(done.index, dinners.length - 1)]
|
||||||
|
if (next) removeButtons.current.get(next.id)!.focus()
|
||||||
|
else sheetRef.current!.closest<HTMLElement>('[role="dialog"]')!.focus()
|
||||||
|
}, [removing, dinners])
|
||||||
|
|
||||||
|
function setSending(value: boolean) {
|
||||||
|
sending.current = value
|
||||||
|
setSendingShown(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelRemove() {
|
||||||
|
if (sending.current) return
|
||||||
|
remove.reset()
|
||||||
|
setRemoving(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmRemove() {
|
||||||
|
if (sending.current) return
|
||||||
|
setSending(true)
|
||||||
|
remove.mutate(removing!, {
|
||||||
|
onSuccess: (_data, dinner) => {
|
||||||
|
removed.current = { id: dinner.id, index: dinners.findIndex((d) => d.id === dinner.id) }
|
||||||
|
setRemoving(null)
|
||||||
|
},
|
||||||
|
onSettled: () => setSending(false),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Sheet open={open} onClose={onClose} aria-labelledby={titleId}>
|
||||||
|
<div ref={sheetRef} className={styles.sheet}>
|
||||||
|
<h2 id={titleId} className={styles.title}>
|
||||||
|
Quest log
|
||||||
|
</h2>
|
||||||
|
{dinners.length === 0 ? (
|
||||||
|
<p className={styles.empty}>Your Quest log is empty — swipe right on a side quest to add one.</p>
|
||||||
|
) : (
|
||||||
|
<ul aria-label="Quest log" className={styles.rows}>
|
||||||
|
{dinners.map((dinner) => (
|
||||||
|
<li key={dinner.id}>
|
||||||
|
<ListRow
|
||||||
|
leading={
|
||||||
|
// The row's name already says what the photo shows.
|
||||||
|
<span aria-hidden="true">
|
||||||
|
<Photo id={dinner.photo_id} name={dinner.name} radius="var(--radius-md)" sizes="48px" className={styles.photo} />
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
title={dinner.name}
|
||||||
|
meta={details(dinner)}
|
||||||
|
onClick={() => navigate(`/dinner/${encodeURIComponent(dinner.id)}`)}
|
||||||
|
trailing={
|
||||||
|
<IconButton
|
||||||
|
ref={(button) => {
|
||||||
|
if (button) removeButtons.current.set(dinner.id, button)
|
||||||
|
else removeButtons.current.delete(dinner.id)
|
||||||
|
}}
|
||||||
|
label={`Remove ${dinner.name}`}
|
||||||
|
tone="neutral"
|
||||||
|
onClick={() => setRemoving(dinner)}
|
||||||
|
>
|
||||||
|
<Icon icon={Trash2} />
|
||||||
|
</IconButton>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Sheet>
|
||||||
|
<Confirm
|
||||||
|
open={removing !== null}
|
||||||
|
title={`Remove ${removing?.name ?? ''}?`}
|
||||||
|
confirmLabel={sendingShown ? 'Removing…' : 'Remove'}
|
||||||
|
cancelLabel="Keep it"
|
||||||
|
busy={sendingShown}
|
||||||
|
// Once the removal is sent, it stands: the confirmation stays until it settles.
|
||||||
|
dismissible={!sendingShown}
|
||||||
|
error={remove.isError ? `Couldn't remove it. ${remove.error.message}` : null}
|
||||||
|
onCancel={cancelRemove}
|
||||||
|
onConfirm={confirmRemove}
|
||||||
|
>
|
||||||
|
It leaves your Quest log for good.
|
||||||
|
</Confirm>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
+36
-9
@@ -1,7 +1,7 @@
|
|||||||
import { useMutation, useMutationState, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useMutationState, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useApi } from '../api/index.ts'
|
import { useApi } from '../api/index.ts'
|
||||||
import { isRecord } from '../api/errors.ts'
|
import { isRecord } from '../api/errors.ts'
|
||||||
import { DIFFICULTIES, DINNERS_PATH, oneOf, photoId, type Difficulty } from '../dinners/api.ts'
|
import { DIFFICULTIES, DINNERS_PATH, dinnerPath, oneOf, parseDinner, photoId, type Difficulty, type Regular } from '../dinners/api.ts'
|
||||||
|
|
||||||
export const SUGGESTIONS_PATH = '/api/v1/suggestions'
|
export const SUGGESTIONS_PATH = '/api/v1/suggestions'
|
||||||
export const DECK_PATH = `${SUGGESTIONS_PATH}/deck`
|
export const DECK_PATH = `${SUGGESTIONS_PATH}/deck`
|
||||||
@@ -39,8 +39,9 @@ export const BATCH_JOB_KIND = 'suggestion_batch'
|
|||||||
// BatchResult is the suggestion batch job's result, published as it goes.
|
// BatchResult is the suggestion batch job's result, published as it goes.
|
||||||
export type BatchResult = { batch_id: string; stage: string; suggestion_ids: string[]; dropped: number }
|
export type BatchResult = { batch_id: string; stage: string; suggestion_ids: string[]; dropped: number }
|
||||||
|
|
||||||
// QuestLogDinner is a dinner in the Quest log, as the Quest log pill lists it.
|
// QuestLogDinner is a dinner in the Quest log, as the Quest log sheet lists
|
||||||
export type QuestLogDinner = { id: string; name: string }
|
// it: photo, name, difficulty and minutes.
|
||||||
|
export type QuestLogDinner = Pick<Regular, 'id' | 'name' | 'photo_id' | 'difficulty' | 'minutes_total'>
|
||||||
|
|
||||||
// DECISION_STATUS is the status a suggestion has once each choice is saved.
|
// DECISION_STATUS is the status a suggestion has once each choice is saved.
|
||||||
const DECISION_STATUS: Record<Choice, string> = { try: 'accepted', pass: 'declined' }
|
const DECISION_STATUS: Record<Choice, string> = { try: 'accepted', pass: 'declined' }
|
||||||
@@ -97,7 +98,8 @@ export function useDeck() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// useQuestLog is the dinners in the Quest log, in the server's order (GET
|
// useQuestLog is the dinners in the Quest log, in the server's order (GET
|
||||||
// /api/v1/dinners?status=quest_log).
|
// /api/v1/dinners?status=quest_log). The server leaves out a dinner already
|
||||||
|
// on the plan for the current week or a later one: it is scheduled.
|
||||||
export function useQuestLog() {
|
export function useQuestLog() {
|
||||||
const { apiFetch } = useApi()
|
const { apiFetch } = useApi()
|
||||||
return useQuery({
|
return useQuery({
|
||||||
@@ -107,15 +109,30 @@ export function useQuestLog() {
|
|||||||
const body = await apiFetch<unknown>(QUEST_LOG_PATH)
|
const body = await apiFetch<unknown>(QUEST_LOG_PATH)
|
||||||
if (!isRecord(body) || !Array.isArray(body.dinners)) throw new Error(`${where}: response has no dinners list`)
|
if (!isRecord(body) || !Array.isArray(body.dinners)) throw new Error(`${where}: response has no dinners list`)
|
||||||
return body.dinners.map((dinner) => {
|
return body.dinners.map((dinner) => {
|
||||||
if (!isRecord(dinner) || typeof dinner.id !== 'string' || typeof dinner.name !== 'string') {
|
const { id, name, photo_id, difficulty, minutes_total } = parseDinner(where, dinner)
|
||||||
throw new Error(`${where}: response is not a dinner`)
|
return { id, name, photo_id, difficulty, minutes_total }
|
||||||
}
|
|
||||||
return { id: dinner.id, name: dinner.name }
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// useRemoveFromQuestLog archives a Quest-log dinner (DELETE
|
||||||
|
// /api/v1/dinners/{id}). Once the server has archived it, it leaves the
|
||||||
|
// Quest log, and the Quest log is read again.
|
||||||
|
export function useRemoveFromQuestLog() {
|
||||||
|
const { apiFetch } = useApi()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (dinner: QuestLogDinner) => {
|
||||||
|
await apiFetch<unknown>(dinnerPath(dinner.id), { method: 'DELETE' })
|
||||||
|
},
|
||||||
|
onSuccess: async (_data, dinner) => {
|
||||||
|
queryClient.setQueryData<QuestLogDinner[]>(QUEST_LOG_KEY, (dinners) => dinners?.filter((d) => d.id !== dinner.id))
|
||||||
|
await queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// useStartBatch asks for more Side quests now (POST
|
// useStartBatch asks for more Side quests now (POST
|
||||||
// /api/v1/suggestions/batches). The server answers with the batch already
|
// /api/v1/suggestions/batches). The server answers with the batch already
|
||||||
// running when there is one.
|
// running when there is one.
|
||||||
@@ -215,7 +232,17 @@ export function useDecide() {
|
|||||||
if (choice !== 'try') return
|
if (choice !== 'try') return
|
||||||
// A stale deck can offer a Side quest whose dinner the Quest log has.
|
// A stale deck can offer a Side quest whose dinner the Quest log has.
|
||||||
queryClient.setQueryData<QuestLogDinner[]>(QUEST_LOG_KEY, (dinners) =>
|
queryClient.setQueryData<QuestLogDinner[]>(QUEST_LOG_KEY, (dinners) =>
|
||||||
dinners === undefined || dinners.some((d) => d.id === quest.dinner_id) ? dinners : dinners.concat({ id: quest.dinner_id, name: quest.name }),
|
dinners === undefined || dinners.some((d) => d.id === quest.dinner_id)
|
||||||
|
? dinners
|
||||||
|
: // The deck does not say the minutes; the Quest log read after the
|
||||||
|
// decision does.
|
||||||
|
dinners.concat({
|
||||||
|
id: quest.dinner_id,
|
||||||
|
name: quest.name,
|
||||||
|
photo_id: quest.photo_id,
|
||||||
|
difficulty: quest.difficulty,
|
||||||
|
minutes_total: null,
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onSettled: async (_replenishJobId, error, { quest }) => {
|
onSettled: async (_replenishJobId, error, { quest }) => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useId, type ReactNode } from 'react'
|
import { useEffect, useId, useLayoutEffect, useRef, type ReactNode } from 'react'
|
||||||
import { useBlocker } from 'react-router'
|
import { useBlocker } from 'react-router'
|
||||||
import { Button } from '../ui/Button.tsx'
|
import { Button } from '../ui/Button.tsx'
|
||||||
import { Sheet } from '../ui/Sheet.tsx'
|
import { Sheet } from '../ui/Sheet.tsx'
|
||||||
@@ -36,6 +36,9 @@ type ConfirmProps = {
|
|||||||
confirmLabel: string
|
confirmLabel: string
|
||||||
cancelLabel?: string
|
cancelLabel?: string
|
||||||
busy?: boolean
|
busy?: boolean
|
||||||
|
// When false, the cancel button, Escape and the scrim do nothing, so the
|
||||||
|
// sheet stays until the action settles.
|
||||||
|
dismissible?: boolean
|
||||||
error?: string | null
|
error?: string | null
|
||||||
onCancel: () => void
|
onCancel: () => void
|
||||||
onConfirm: () => void
|
onConfirm: () => void
|
||||||
@@ -49,14 +52,28 @@ export function Confirm({
|
|||||||
confirmLabel,
|
confirmLabel,
|
||||||
cancelLabel = 'Cancel',
|
cancelLabel = 'Cancel',
|
||||||
busy = false,
|
busy = false,
|
||||||
|
dismissible = true,
|
||||||
error = null,
|
error = null,
|
||||||
onCancel,
|
onCancel,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
}: ConfirmProps) {
|
}: ConfirmProps) {
|
||||||
const titleId = useId()
|
const titleId = useId()
|
||||||
|
const sheetRef = useRef<HTMLDivElement>(null)
|
||||||
|
// A focused button that becomes disabled either keeps focus while disabled or
|
||||||
|
// loses it to the page, depending on the browser; the dialog takes it instead.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const dialog = sheetRef.current?.closest<HTMLElement>('[role="dialog"]')
|
||||||
|
if (!dialog) return
|
||||||
|
const active = document.activeElement
|
||||||
|
const lost = active === null || active === document.body || (dialog.contains(active) && active.matches(':disabled'))
|
||||||
|
if (lost) dialog.focus()
|
||||||
|
}, [busy, dismissible])
|
||||||
|
function dismiss() {
|
||||||
|
if (dismissible) onCancel()
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Sheet open={open} onClose={onCancel} aria-labelledby={titleId}>
|
<Sheet open={open} onClose={dismiss} aria-labelledby={titleId}>
|
||||||
<div className={styles.sheet}>
|
<div ref={sheetRef} className={styles.sheet}>
|
||||||
<h2 id={titleId} className={styles.title}>
|
<h2 id={titleId} className={styles.title}>
|
||||||
{title}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
@@ -67,7 +84,7 @@ export function Confirm({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className={styles.actions}>
|
<div className={styles.actions}>
|
||||||
<Button variant="outline" onClick={onCancel}>
|
<Button variant="outline" disabled={!dismissible} onClick={onCancel}>
|
||||||
{cancelLabel}
|
{cancelLabel}
|
||||||
</Button>
|
</Button>
|
||||||
<Button disabled={busy} onClick={onConfirm}>
|
<Button disabled={busy} onClick={onConfirm}>
|
||||||
|
|||||||
Reference in New Issue
Block a user