mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-12 23:31:02 +00:00
65abea6910
- 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.
248 lines
9.4 KiB
TypeScript
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 };
|
|
}
|