import { router, useForm } from '@inertiajs/react'; import { useEffect, useRef, useState } from 'react'; import { createOperationKey } from '@/lib/operation-key'; import type { Partnership } from '@/pages/partnerships/shared'; export type PartnershipLocation = { geo_location_id: string; role: string; source_reference: string; }; export type PartnershipOrigin = { type: string; target_id: string }; export type PartnershipEditProps = { editorActorId?: number; saveCommands?: { key: string; version: number }[]; record: Partnership | null; locations: PartnershipLocation[]; origins: PartnershipOrigin[]; }; type Payload = { summary: string; requirements: string; mandatory: string[]; confidentiality: string; consent: boolean; availability: string; }; type Sections = Payload & { title: string; offer: string; seeking: string; organization_id: string; sector_id: string; kind: string; partnership_type: string; deadline: string; locations: PartnershipLocation[]; origins: PartnershipOrigin[]; }; type Draft = Omit & { payload: Payload; client_key: string; version: number | null; }; type Snapshot = { actorId: number | null; commands: { key: string; version: number }[]; id: number | null; version: number | null; status: string; clientKey: string | null; values: Sections; }; const textKeys = [ 'title', 'offer', 'seeking', 'organization_id', 'sector_id', 'kind', 'partnership_type', 'deadline', 'summary', 'requirements', 'confidentiality', 'availability', ] as const; const keys = [ ...textKeys, 'mandatory', 'consent', 'locations', 'origins', ] as const; export type PartnershipSection = (typeof keys)[number]; const editableStates = ['draft', 'returned', 'rejected', 'published']; function object(value: unknown): value is Record { return !!value && typeof value === 'object' && !Array.isArray(value); } function text(value: unknown, fallback = '') { return value == null ? fallback : typeof value === 'string' ? value : undefined; } function bool(value: unknown) { return value == null ? false : value === true || value === 1 || value === '1' ? true : value === false || value === 0 || value === '0' ? false : undefined; } function location(value: unknown): value is PartnershipLocation { return ( object(value) && typeof value.geo_location_id === 'string' && typeof value.role === 'string' && typeof value.source_reference === 'string' ); } function origin(value: unknown): value is PartnershipOrigin { return ( object(value) && typeof value.type === 'string' && typeof value.target_id === 'string' ); } function validSections(value: unknown): value is Sections { return ( object(value) && textKeys.every((key) => typeof value[key] === 'string') && typeof value.consent === 'boolean' && Array.isArray(value.mandatory) && value.mandatory.every((item) => typeof item === 'string') && Array.isArray(value.locations) && value.locations.every(location) && Array.isArray(value.origins) && value.origins.every(origin) ); } function readSnapshot(props: unknown): Snapshot | null { if ( !object(props) || !Array.isArray(props.locations) || !Array.isArray(props.origins) ) return null; if ( props.editorActorId !== undefined && (typeof props.editorActorId !== 'number' || !Number.isSafeInteger(props.editorActorId) || props.editorActorId < 1) ) return null; if ( props.saveCommands !== undefined && (!Array.isArray(props.saveCommands) || !props.saveCommands.every( (value) => object(value) && typeof value.key === 'string' && typeof value.version === 'number' && Number.isSafeInteger(value.version) && value.version > 0, )) ) return null; const record = props.record; if ( record !== null && (!object(record) || !Number.isSafeInteger(record.id) || typeof record.id !== 'number' || record.id < 1 || !Number.isSafeInteger(record.version) || typeof record.version !== 'number' || record.version < 1 || typeof record.status !== 'string' || (!object(record.payload) && !( Array.isArray(record.payload) && record.payload.length === 0 ))) ) return null; const row = object(record) ? record : {}; const payload = object(row.payload) ? row.payload : {}; const values = { title: text(row.title), offer: text(row.offer), seeking: text(row.seeking), organization_id: text(row.organization_id), sector_id: row.sector_id == null ? '' : typeof row.sector_id === 'number' ? String(row.sector_id) : undefined, kind: text(row.kind, 'seeking'), partnership_type: text(row.partnership_type), deadline: text(row.deadline), summary: text(payload.summary), requirements: text(payload.requirements), mandatory: payload.mandatory ?? [], confidentiality: text(payload.confidentiality, 'public'), consent: bool(payload.consent), availability: text(payload.availability), locations: props.locations.map((item) => object(item) ? { geo_location_id: item.geo_location_id, role: item.role, source_reference: text(item.source_reference), } : null, ), origins: props.origins.map((item) => object(item) ? { type: item.type, target_id: item.target_id } : null, ), }; if (!validSections(values)) return null; return { actorId: typeof props.editorActorId === 'number' ? props.editorActorId : null, commands: Array.isArray(props.saveCommands) ? props.saveCommands.map((value) => ({ key: value.key, version: value.version, })) : [], id: typeof row.id === 'number' ? row.id : null, version: typeof row.version === 'number' ? row.version : null, status: typeof row.status === 'string' ? row.status : 'draft', clientKey: typeof row.client_key === 'string' ? row.client_key : null, values, }; } function toDraft( values: Sections, client_key: string, version: number | null, ): Draft { const { summary, requirements, mandatory, confidentiality, consent, availability, ...fields } = values; return { ...fields, client_key, version, payload: { summary, requirements, mandatory, confidentiality, consent, availability, }, }; } function sections({ payload, client_key: _key, version: _version, ...fields }: Draft): Sections { return { ...fields, ...payload }; } function canonical(value: Sections[PartnershipSection]) { return JSON.stringify( Array.isArray(value) ? value.map((item) => JSON.stringify(item)).sort() : value, ); } function equal( a: Sections[PartnershipSection], b: Sections[PartnershipSection], ) { return canonical(a) === canonical(b); } function same(a: Sections, b: Sections) { return keys.every((key) => equal(a[key], b[key])); } function normalised(values: Sections): Sections { const result = { ...values }; for (const key of textKeys) result[key] = result[key].trim(); return { ...result, locations: result.locations.map((item) => ({ geo_location_id: item.geo_location_id.trim(), role: item.role.trim(), source_reference: item.source_reference.trim(), })), origins: result.origins.map((item) => ({ type: item.type.trim(), target_id: item.target_id.trim(), })), }; } const empty = readSnapshot({ record: null, locations: [], origins: [] }); if (!empty) throw new Error('Empty partnership editor definition is invalid.'); const emptyValues = empty.values; export function usePartnershipEdit(props: PartnershipEditProps) { const alive = useRef(true); useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []); const current = readSnapshot(props); const operationKey = useRef(createOperationKey()); const form = useForm( toDraft( current?.values ?? emptyValues, operationKey.current, current?.version ?? null, ), ); const base = useRef(current); const live = useRef(form.data); live.current = form.data; const identity = useRef(current?.id ?? null); identity.current = current?.id ?? null; const edited = useRef(new Set()); const generation = useRef>>({}); const pendingCreation = useRef<{ command: Draft; generation: Partial>; ambiguous: boolean; } | null>(null); const createdId = useRef(null); const pendingUpdate = useRef<{ id: number; command: Draft & { operation_key: string }; generation: Partial>; base: Snapshot; ambiguous: boolean; } | null>(null); const [choices, setChoices] = useState>( {}, ); const [refreshing, setRefreshing] = useState(false); const [problem, setProblem] = useState(null); const [needsCompare, setNeedsCompare] = useState(false); const [mustRefresh, setMustRefresh] = useState(false), [accessDenied, setAccessDenied] = useState(false); const own = sections(form.data); const invalid = !current || !base.current || current.actorId !== base.current.actorId || (current.id !== base.current.id && !( base.current.id === null && (current.id === createdId.current || current.clientKey === pendingCreation.current?.command.client_key) )); const stale = !!current && !!base.current && (current.id !== base.current.id || current.version !== base.current.version || current.status !== base.current.status || !same(current.values, base.current.values)); const baseline = base.current; const changed = current && baseline ? keys.filter( (key) => !equal(current.values[key], baseline.values[key]), ) : []; const conflicts = current ? changed.filter( (key) => edited.current.has(key) && !equal(own[key], current.values[key]), ) : []; const choiceKey = (key: PartnershipSection) => JSON.stringify([ current?.id, current?.version, current?.status, key, current ? canonical(current.values[key]) : '', ]); const unresolved = conflicts.filter( (key) => choices[choiceKey(key)] !== 'mine', ); const editable = !!current && editableStates.includes(current.status); const blocked = invalid || stale || refreshing || needsCompare || accessDenied || !!pendingUpdate.current || !editable; const dirty = !!baseline && (!!pendingUpdate.current || !!pendingCreation.current || !same(own, baseline.values) || (!!current && keys.some( (key) => edited.current.has(key) && !equal(own[key], current.values[key]), ))); useEffect(() => { const leave = (event: BeforeUnloadEvent) => { if (dirty) { event.preventDefault(); event.returnValue = ''; } }; const stop = router.on('before', (event) => { if ( dirty && event.detail.visit.method === 'get' && event.detail.visit.preserveState !== true && !window.confirm( 'Há alterações por guardar na parceria. Pretende sair sem as guardar?', ) ) event.preventDefault(); }); window.addEventListener('beforeunload', leave); return () => { stop(); window.removeEventListener('beforeunload', leave); }; }, [dirty]); function replace(next: Draft) { const previous = sections(live.current), after = sections(next); const changed = keys.filter((key) => !equal(previous[key], after[key])); for (const key of changed) { edited.current.add(key); generation.current[key] = (generation.current[key] ?? 0) + 1; } live.current = next; form.setData(() => next); setChoices((previous) => { const next = { ...previous }; for (const key of changed) delete next[choiceKey(key)]; return next; }); } function setData( key: K, value: Draft[K] | ((previous: Draft[K]) => Draft[K]), ) { const next = typeof value === 'function' ? value(live.current[key]) : value; replace({ ...live.current, [key]: next }); } function choose(key: PartnershipSection, choice: 'mine' | 'current') { if (!current) return; if (choice === 'current') replace( toDraft( { ...sections(live.current), [key]: current.values[key] }, live.current.client_key, live.current.version, ), ); setChoices((previous) => ({ ...previous, [choiceKey(key)]: choice })); } function adopt() { if ( !current || (!stale && !pendingUpdate.current?.ambiguous && !needsCompare) || invalid || refreshing || form.processing || unresolved.length ) return; if (mustRefresh) return; const selected = { ...current.values }; for (const key of keys) if (edited.current.has(key)) Object.assign(selected, { [key]: sections(live.current)[key] }); const saved = toDraft( current.values, current.clientKey ?? live.current.client_key, current.version, ); base.current = current; createdId.current = null; pendingCreation.current = null; pendingUpdate.current = null; form.setDefaults(saved); live.current = toDraft(selected, saved.client_key, saved.version); form.setData(live.current); form.clearErrors(); setProblem(null); setNeedsCompare(false); setChoices({}); } function refresh() { if (refreshing || form.processing) return; setRefreshing(true); router.reload({ only: [ 'record', 'locations', 'origins', 'editorActorId', 'saveCommands', ], onSuccess: (page) => { if (!alive.current) return; if ( pendingUpdate.current && acknowledgeUpdate(page.props, pendingUpdate.current) ) return; const fresh = readSnapshot(page.props); if ( !fresh || fresh.actorId !== base.current?.actorId || fresh.id !== base.current?.id ) { setProblem( 'A resposta não corresponde a esta conta e parceria. A preparação foi conservada.', ); return; } setMustRefresh(false); setAccessDenied(false); if ( fresh && base.current && fresh.id === base.current.id && fresh.version === base.current.version && fresh.status === base.current.status && same(fresh.values, base.current.values) ) { setNeedsCompare(false); setProblem(null); } }, onHttpException: () => { if (!alive.current) return false; setAccessDenied(true); setMustRefresh(true); setProblem( 'Não foi possível recuperar esta parceria com o acesso actual. As suas edições foram conservadas.', ); return false; }, onNetworkError: () => { if (!alive.current) return false; setProblem( 'A recuperação não foi confirmada. As suas edições foram conservadas.', ); return false; }, onFinish: () => { if (alive.current) setRefreshing(false); }, }); } function applySaved( saved: Snapshot, command: Draft, sentGeneration: Partial>, later = false, ) { const merged = { ...saved.values }; edited.current.clear(); for (const key of keys) if ((generation.current[key] ?? 0) !== (sentGeneration[key] ?? 0)) { Object.assign(merged, { [key]: sections(live.current)[key] }); edited.current.add(key); } base.current = saved; createdId.current = null; pendingCreation.current = null; pendingUpdate.current = null; const defaults = toDraft( saved.values, saved.clientKey ?? command.client_key, saved.version, ); form.setDefaults(defaults); live.current = toDraft(merged, defaults.client_key, defaults.version); form.setData(live.current); form.clearErrors(); setChoices({}); setMustRefresh(false); setAccessDenied(false); setNeedsCompare(later); setProblem( later ? 'A gravação foi reconhecida. Existem alterações posteriores; compare antes de voltar a guardar.' : null, ); } function acknowledgeUpdate( pageProps: unknown, sent: NonNullable, ): boolean { if ( !alive.current || pendingUpdate.current !== sent || identity.current !== sent.id ) return false; const saved = readSnapshot(pageProps), expectedVersion = (sent.base.version ?? 0) + 1; if ( !saved || saved.id !== sent.id || saved.actorId !== sent.base.actorId || !saved.commands.some( (command) => command.key === sent.command.operation_key && command.version === expectedVersion, ) || saved.version === null || saved.version < expectedVersion ) return false; const expected = normalised(sections(sent.command)); if (saved.version === expectedVersion) { if (saved.status !== 'draft' || !same(saved.values, expected)) return false; applySaved(saved, sent.command, sent.generation); return true; } applySaved( { ...sent.base, version: expectedVersion, status: 'draft', values: expected, commands: saved.commands, }, sent.command, sent.generation, true, ); return true; } function save(recover = false) { if ( !alive.current || form.processing || refreshing || invalid || accessDenied || (!recover && blocked) ) return; const update = recover ? pendingUpdate.current : null; if (recover && (!update?.ambiguous || current?.id !== update.id)) return; const submittedId = update?.id ?? base.current?.id ?? null; const pending = submittedId === null ? pendingCreation.current : null; const command = update?.command ?? pending?.command ?? structuredClone(live.current); const sentGeneration = update?.generation ?? pending?.generation ?? { ...generation.current }; if (submittedId !== null && !update && base.current) pendingUpdate.current = { id: submittedId, command: { ...command, operation_key: createOperationKey() }, generation: sentGeneration, base: base.current, ambiguous: false, }; const submittedUpdate = pendingUpdate.current; if (submittedId === null && !pending) pendingCreation.current = { command, generation: sentGeneration, ambiguous: false, }; setProblem(null); form.transform(() => submittedUpdate?.command ?? command); const options = { preserveScroll: true, onSuccess: (page: { props: Record }) => { if (!alive.current) return; if (submittedUpdate) { if ( !acknowledgeUpdate(page.props, submittedUpdate) && pendingUpdate.current === submittedUpdate ) { submittedUpdate.ambiguous = true; setMustRefresh(true); setNeedsCompare(true); setProblem( 'A resposta não confirmou esta gravação. A preparação foi conservada; recupere a mesma tentativa ou compare os dados actuais.', ); } return; } const saved = readSnapshot(page.props); if ( !saved || saved.id === null || (submittedId !== null ? saved.id !== submittedId || identity.current !== submittedId : saved.clientKey !== command.client_key || (identity.current !== null && identity.current !== saved.id)) ) { setProblem( 'A gravação não foi reconciliada com este formulário. Compare os dados antes de continuar.', ); setNeedsCompare(true); return; } if (submittedId === null) createdId.current = saved.id; pendingCreation.current = null; if ( saved.status !== 'draft' || !same(saved.values, normalised(sections(command))) ) { setProblem( 'A resposta contém uma versão com dados diferentes do que enviou. Compare antes de voltar a guardar; as suas edições foram conservadas.', ); setNeedsCompare(true); return; } applySaved(saved, command, sentGeneration); }, onError: () => { if (!alive.current) return; if ( submittedUpdate && pendingUpdate.current === submittedUpdate ) pendingUpdate.current = null; if (!pendingCreation.current?.ambiguous) pendingCreation.current = null; }, onHttpException: (response: { status: number }) => { if (!alive.current) return false; if (submittedUpdate) { if (pendingUpdate.current !== submittedUpdate) return false; if ( response.status >= 500 || [401, 403, 404, 419].includes(response.status) ) submittedUpdate.ambiguous = true; else pendingUpdate.current = null; setMustRefresh(true); setNeedsCompare(true); setAccessDenied( [401, 403, 404, 419].includes(response.status), ); setProblem( 'A gravação não foi confirmada com os dados e acesso actuais. Compare antes de continuar; as edições foram conservadas.', ); return false; } if (submittedId !== null && response.status === 409) { setNeedsCompare(true); setProblem( 'A parceria foi alterada ou mudou de estado. Compare com os dados actuais antes de guardar; as suas edições foram conservadas.', ); } else { setProblem( response.status === 403 ? 'Não foi possível confirmar a operação com o acesso actual. As suas edições foram conservadas.' : 'A gravação não foi confirmada. As suas edições foram conservadas.', ); if (submittedId !== null) setNeedsCompare(response.status >= 500); } if (pendingCreation.current) { if ( response.status >= 500 || response.status === 409 || pendingCreation.current.ambiguous ) pendingCreation.current.ambiguous = true; else pendingCreation.current = null; } return false; }, onNetworkError: () => { if (!alive.current) return false; if ( submittedUpdate && pendingUpdate.current !== submittedUpdate ) return false; if ( submittedUpdate && pendingUpdate.current === submittedUpdate ) { submittedUpdate.ambiguous = true; setMustRefresh(true); } setProblem( 'A gravação não foi confirmada. As suas edições foram conservadas.', ); if (pendingCreation.current) pendingCreation.current.ambiguous = true; else setNeedsCompare(true); return false; }, onCancel: () => { if (!alive.current) return; if ( submittedUpdate && pendingUpdate.current !== submittedUpdate ) return; if ( submittedUpdate && pendingUpdate.current === submittedUpdate ) { submittedUpdate.ambiguous = true; setMustRefresh(true); } if (pendingCreation.current) pendingCreation.current.ambiguous = true; else setNeedsCompare(true); setProblem( 'A gravação foi interrompida; conserve a edição e recupere os dados antes de continuar.', ); }, }; if (submittedId === null) form.post('/parcerias/rascunhos', options); else form.put(`/parcerias/${submittedId}`, options); } return { form, setData, save, refresh, adopt, choose, problem, current, values: own, changed, conflicts, unresolved, stale, invalid, refreshing, editable, blocked, dirty, retryingCreation: !!pendingCreation.current?.ambiguous, retryingSave: !!pendingUpdate.current?.ambiguous, canRecoverSave: !!pendingUpdate.current?.ambiguous && !invalid && !accessDenied && !refreshing && !form.processing, recoverSave: () => save(true), mustRefresh, baseVersion: base.current?.version ?? null, }; } export type PartnershipEditor = ReturnType;