Files
Investidor-V2/resources/js/lib/partnership-edit.ts
T

863 lines
29 KiB
TypeScript

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<Sections, keyof Payload> & {
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<string, unknown> {
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<PartnershipSection>());
const generation = useRef<Partial<Record<PartnershipSection, number>>>({});
const pendingCreation = useRef<{
command: Draft;
generation: Partial<Record<PartnershipSection, number>>;
ambiguous: boolean;
} | null>(null);
const createdId = useRef<number | null>(null);
const pendingUpdate = useRef<{
id: number;
command: Draft & { operation_key: string };
generation: Partial<Record<PartnershipSection, number>>;
base: Snapshot;
ambiguous: boolean;
} | null>(null);
const [choices, setChoices] = useState<Record<string, 'mine' | 'current'>>(
{},
);
const [refreshing, setRefreshing] = useState(false);
const [problem, setProblem] = useState<string | null>(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<K extends keyof Draft>(
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<Record<PartnershipSection, number>>,
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<typeof pendingUpdate.current>,
): 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<string, unknown> }) => {
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<typeof usePartnershipEdit>;