Files
TREK/client/src/components/Planner/usePlaceForm.ts
T
Maurice 65abea6910 place-contrib: settle the first CI round
- The pause setting's type comes from its schema, so nullable fields read the
  same under the server's compiler settings.
- The place form passes the origin of a field through to the hook.
- Serial steps run through inOrder (src/nest/common/in-order.ts) and
  independent ones through Promise.all, instead of an await inside a loop. The
  bulk update in places.mcp.ts goes the same way, so its baseline drops by one.
- The status a call without a key gets is a named constant, not an envelope
  literal.
- A picture test no longer inherits the previous test's mock answer.
- The entity counts include the three new tables.
- The log keeps its failure per query instead of resetting it in the effect,
  and usePlaceForm sets its picture through one helper.
2026-10-31 03:25:55 +00:00

248 lines
9.4 KiB
TypeScript

import type { PlaceClientOrigin } from '@trek/shared';
import type React from 'react';
import { useEffect, useRef, useState } from 'react';
import type { useTranslation } from '../../i18n';
import type { Category, TripFile } from '../../types';
import { offlineOr } from '../../utils/needsConnection';
import type { useToast } from '../shared/Toast';
import type { PlaceDetailsSelection } from './PlaceDetailsColumn';
import {
DEFAULT_FORM,
duplicateName,
openingAutoFilled,
openingDetailsSelection,
type PlaceFormData,
type PlacePrefill,
type ResultField,
} from './PlaceFormModal.helpers';
import { originsAfterPick, resultOrigin, withOrigin } from './placeFieldOrigins';
import type { PendingPlaceImage } from './placeImageChoice';
type Translate = ReturnType<typeof useTranslation>['t'];
/** The form with another picture, and with it the origin of that picture; null where the save route decides it. */
const withImage = (
prev: PlaceFormData,
image: string | null | undefined,
origin: PlaceClientOrigin | null = null
): PlaceFormData => ({ ...prev, image_url: image, fieldOrigins: withOrigin(prev.fieldOrigins, 'image_url', origin) });
type Toast = ReturnType<typeof useToast>;
export interface PlaceFormOptions {
t: Translate;
toast: Toast;
/** Files and pictures pasted into the form become attachments only for a member who may upload. */
canUploadFiles: boolean;
}
/**
* The place add/edit form's own state: the one logic path behind the desktop dialog and
* the phone sheet, which render their own markup over it and keep their own openings,
* search and save flow. It holds the fields, which of them the last picked search result
* wrote (see mergeResult), the files chosen before the place exists, what the details
* column describes, and the duplicate warning a first save of a new place may raise.
*/
export function usePlaceForm({ t, toast, canUploadFiles }: PlaceFormOptions) {
const [form, setForm] = useState<PlaceFormData>(DEFAULT_FORM);
// Which fields the last picked search result wrote. Anything in here belongs
// to that place and goes when another is picked; anything outside it is the
// user's and survives. See mergeResult.
const autoFilledRef = useRef<Set<ResultField>>(new Set());
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
// The picture that becomes the place's own when the form is saved.
const [pendingImage, setPendingImageState] = useState<PendingPlaceImage | null>(null);
const previewRef = useRef<string | null>(null);
const setPendingImage = (next: PendingPlaceImage | null) => {
if (previewRef.current) URL.revokeObjectURL(previewRef.current);
previewRef.current = next?.kind === 'upload' ? next.preview : null;
setPendingImageState(next);
};
useEffect(
() => () => {
if (previewRef.current) URL.revokeObjectURL(previewRef.current);
},
[]
);
const [isSaving, setIsSaving] = useState(false);
const [duplicateWarning, setDuplicateWarning] = useState<string | null>(null);
// What the details column is describing. Null until there is a place to describe.
const [detailsSelection, setDetailsSelection] = useState<PlaceDetailsSelection | null>(null);
/**
* A fresh opening, on the form the caller built for it: the fields that opening owns,
* what the details column follows (otherwise it kept showing the previous place's
* pictures and facts), no files and no warning left from the last one.
*/
const openForm = (
next: PlaceFormData,
place: Parameters<typeof openingDetailsSelection>[0],
prefill: PlacePrefill | null | undefined
) => {
const autoFilled = openingAutoFilled(place, prefill);
// A map POI's website and phone are the provider's, like a search pick's.
const origins = prefill
? originsAfterPick(next.fieldOrigins, [...autoFilled], [], resultOrigin(prefill))
: undefined;
setForm(origins ? { ...next, fieldOrigins: origins } : next);
autoFilledRef.current = autoFilled;
setDetailsSelection(openingDetailsSelection(place, prefill));
setPendingFiles([]);
setPendingImage(null);
setDuplicateWarning(null);
};
/** A field set in the form: typed by hand unless the caller says it took a looked-up value over. */
const handleChange = (field: string, value: string, origin: PlaceClientOrigin = 'user') => {
// Typed by hand, so the next pick must not clear it.
autoFilledRef.current.delete(field as ResultField);
setForm((prev) => ({ ...prev, [field]: value, fieldOrigins: withOrigin(prev.fieldOrigins, field, origin) }));
};
const addFiles = (files: File[]) => setPendingFiles((prev) => [...prev, ...files]);
/** The file input's pick; the input is cleared so the same file can be chosen again. */
const addFilesFromInput = (e: React.ChangeEvent<HTMLInputElement>) => {
addFiles(Array.from(e.target.files || []));
e.target.value = '';
};
const removeFile = (idx: number) => setPendingFiles((prev) => prev.filter((_, i) => i !== idx));
// Clipboard images/PDFs from any focused field become pending attachments.
const handlePaste = (e: React.ClipboardEvent) => {
if (!canUploadFiles) return;
for (const item of Array.from(e.clipboardData?.items || [])) {
if (item.type.startsWith('image/') || item.type === 'application/pdf') {
e.preventDefault();
const file = item.getAsFile();
if (file) setPendingFiles((prev) => [...prev, file]);
return;
}
}
};
/** A source's photo picked in the details column, or the place's own picture again. */
const pickImage = (url: string | null) => {
setPendingImage(null);
setForm((prev) => withImage(prev, url ?? undefined, url ? 'enrichment' : null));
};
/** A picture from the device, shown at once and uploaded when the form is saved. */
const stageUpload = (file: File) => {
setPendingImage({ kind: 'upload', file, preview: URL.createObjectURL(file) });
// The upload route records the picture as the user's itself.
setForm((prev) => withImage(prev, undefined));
};
/** An image already attached to the place, made its picture when the form is saved. */
const stageAttached = (file: TripFile) => {
setPendingImage({ kind: 'attached', file });
setForm((prev) => withImage(prev, undefined));
};
/** No picture at all: the save drops the place's own one. */
const removeImage = () => {
setPendingImage(null);
setForm((prev) => withImage(prev, null));
};
/** The details column's description, taken over into the form. */
const adoptDescription = (text: string) =>
setForm((prev) => ({
...prev,
description: text,
fieldOrigins: withOrigin(prev.fieldOrigins, 'description', 'enrichment'),
}));
/**
* #1152: the first save of a new place warns when the trip seems to have it already;
* a second press with the warning showing is the explicit "add anyway". True when it
* warned, and the save stops there.
*/
const warnIfDuplicate = (places: Parameters<typeof duplicateName>[1]) => {
const name = duplicateName(form, places);
if (!name) return false;
setDuplicateWarning(name);
toast.warning(t('places.duplicateExists', { name }));
return true;
};
return {
form,
setForm,
autoFilledRef,
pendingFiles,
addFiles,
addFilesFromInput,
removeFile,
handlePaste,
isSaving,
setIsSaving,
duplicateWarning,
detailsSelection,
setDetailsSelection,
openForm,
handleChange,
pickImage,
pendingImage,
stageUpload,
stageAttached,
removeImage,
adoptDescription,
warnIfDuplicate,
};
}
const NEW_CATEGORY_COLOR = '#6366f1'; // theme-lint-disable: a new category's stored default, not a colour of the form
/** What a new category is created with, besides its name. */
type NewCategory = { name: string; color: string; icon: string };
export type NewCategoryOptions = {
t: Translate;
toast: Toast;
/** The new category's id, once it exists: it is picked straight away. */
onCreated: (categoryId: string) => void;
} & (
| {
/** Desktop: the name goes as typed, and a create that hands nothing back picks nothing. */
variant: 'dialog';
create: (category: NewCategory) => Promise<Category> | undefined;
}
| {
/** Phone: the name goes trimmed, and a second create waits until the first is done. */
variant: 'sheet';
create: (category: NewCategory) => Promise<Category>;
}
);
/**
* The inline "new category" of the place form, for both shells: a name typed where the
* category pills are, created with the default colour and icon and picked at once.
*/
export function useNewCategory({ t, toast, onCreated, variant, create }: NewCategoryOptions) {
const sheet = variant === 'sheet';
const [open, setOpen] = useState(false);
const [name, setName] = useState('');
const [saving, setSaving] = useState(false);
const submit = async () => {
if (!name.trim() || (sheet && saving)) return;
if (sheet) setSaving(true);
try {
const cat = await create({ name: sheet ? name.trim() : name, color: NEW_CATEGORY_COLOR, icon: 'MapPin' });
// The phone's create always hands one back; reading it unguarded fails into the catch as before.
if (sheet || cat) onCreated(String((cat as Category).id));
setName('');
setOpen(false);
} catch {
toast.error(offlineOr(t, t('places.categoryCreateError')));
} finally {
if (sheet) setSaving(false);
}
};
return { open, setOpen, name, setName, saving, submit };
}