846 lines
30 KiB
TypeScript
846 lines
30 KiB
TypeScript
import { router, useForm } from '@inertiajs/react';
|
|
import { useEffect, useRef, useState } from 'react';
|
|
import { createOperationKey } from './operation-key';
|
|
|
|
type Values = Record<string, string>;
|
|
type Kind = 'agreement' | 'milestone';
|
|
type Snapshot = {
|
|
actorId: number | null;
|
|
partnershipVersion: number | null;
|
|
interestVersion: number | null;
|
|
commands: {
|
|
key: string;
|
|
action: string;
|
|
version: number;
|
|
status: string;
|
|
party: string | null;
|
|
}[];
|
|
confirmations: Record<string, { actorId: number; reason: string }>;
|
|
id: number | null;
|
|
version: number | null;
|
|
context: string;
|
|
roomVersion: number;
|
|
status: string;
|
|
canWrite: boolean;
|
|
values: Values;
|
|
};
|
|
function object(value: unknown): value is Record<string, unknown> {
|
|
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
}
|
|
function positive(value: unknown): value is number {
|
|
return (
|
|
typeof value === 'number' && Number.isSafeInteger(value) && value > 0
|
|
);
|
|
}
|
|
function text(value: unknown): string | null {
|
|
return value == null ? '' : typeof value === 'string' ? value : null;
|
|
}
|
|
const milestoneKeys = [
|
|
'title',
|
|
'responsible_id',
|
|
'due_date',
|
|
'status',
|
|
'evidence',
|
|
];
|
|
function snapshot(
|
|
props: unknown,
|
|
roomId: number,
|
|
kind: Kind,
|
|
itemId: number | null,
|
|
creationKey?: string,
|
|
): Snapshot | null {
|
|
if (
|
|
!object(props) ||
|
|
!object(props.room) ||
|
|
props.room.id !== roomId ||
|
|
!positive(props.room.version) ||
|
|
typeof props.room.status !== 'string' ||
|
|
typeof props.canWrite !== 'boolean'
|
|
)
|
|
return null;
|
|
const room = props.room;
|
|
const actorId = positive(props.agreementActorId)
|
|
? props.agreementActorId
|
|
: null;
|
|
const partnershipVersion =
|
|
object(props.partnership) && positive(props.partnership.version)
|
|
? props.partnership.version
|
|
: null;
|
|
const interestVersion =
|
|
object(props.interest) && positive(props.interest.version)
|
|
? props.interest.version
|
|
: null;
|
|
if (
|
|
kind === 'agreement' &&
|
|
(!actorId ||
|
|
!partnershipVersion ||
|
|
!interestVersion ||
|
|
!Array.isArray(props.agreementCommands) ||
|
|
!props.agreementCommands.every(
|
|
(command) =>
|
|
object(command) &&
|
|
typeof command.key === 'string' &&
|
|
typeof command.action === 'string' &&
|
|
positive(command.version) &&
|
|
typeof command.status === 'string' &&
|
|
(command.party === null ||
|
|
command.party === 'proponent' ||
|
|
command.party === 'candidate'),
|
|
))
|
|
)
|
|
return null;
|
|
let values: Record<string, string | null>,
|
|
id: number | null = roomId,
|
|
version: number | null = room.version as number;
|
|
const confirmations: Snapshot['confirmations'] = {};
|
|
if (kind === 'agreement') {
|
|
if (room.agreement !== null && !object(room.agreement)) return null;
|
|
const agreement = object(room.agreement) ? room.agreement : {};
|
|
if (object(agreement.confirmations))
|
|
for (const party of ['proponent', 'candidate']) {
|
|
const confirmation = agreement.confirmations[party];
|
|
if (object(confirmation) && positive(confirmation.actor_id))
|
|
confirmations[party] = {
|
|
actorId: confirmation.actor_id,
|
|
reason:
|
|
typeof confirmation.reason === 'string'
|
|
? confirmation.reason
|
|
: '',
|
|
};
|
|
}
|
|
values = {
|
|
title: text(agreement.title),
|
|
terms: text(agreement.terms),
|
|
reason: '',
|
|
};
|
|
} else if (itemId === null && !creationKey) {
|
|
id = null;
|
|
version = null;
|
|
values = {
|
|
title: '',
|
|
responsible_id: '',
|
|
due_date: '',
|
|
status: 'pending',
|
|
evidence: '',
|
|
};
|
|
} else {
|
|
if (!Array.isArray(props.milestones)) return null;
|
|
const item = props.milestones.find(
|
|
(candidate) =>
|
|
object(candidate) &&
|
|
(creationKey
|
|
? candidate.client_key === creationKey
|
|
: candidate.id === itemId),
|
|
);
|
|
if (
|
|
!object(item) ||
|
|
!positive(item.id) ||
|
|
!positive(item.version) ||
|
|
!positive(item.responsible_id)
|
|
)
|
|
return null;
|
|
id = item.id;
|
|
version = item.version;
|
|
values = {
|
|
title: text(item.title),
|
|
responsible_id: String(item.responsible_id),
|
|
due_date: text(item.due_date),
|
|
status: text(item.status),
|
|
evidence: text(item.evidence),
|
|
};
|
|
}
|
|
if (!Object.values(values).every((value) => typeof value === 'string'))
|
|
return null;
|
|
return {
|
|
actorId,
|
|
partnershipVersion,
|
|
interestVersion,
|
|
confirmations,
|
|
commands:
|
|
kind === 'agreement' && Array.isArray(props.agreementCommands)
|
|
? props.agreementCommands.map((command) => ({
|
|
key: command.key,
|
|
action: command.action,
|
|
version: command.version,
|
|
status: command.status,
|
|
party: command.party,
|
|
}))
|
|
: [],
|
|
id,
|
|
version,
|
|
context: JSON.stringify(
|
|
kind === 'agreement'
|
|
? [
|
|
room.version,
|
|
room.status,
|
|
props.canWrite,
|
|
partnershipVersion,
|
|
interestVersion,
|
|
]
|
|
: [room.version, room.status, props.canWrite],
|
|
),
|
|
roomVersion: room.version as number,
|
|
status: room.status as string,
|
|
canWrite: props.canWrite,
|
|
values: values as Values,
|
|
};
|
|
}
|
|
const same = (a: Values, b: Values) =>
|
|
Object.keys(a).every((key) => a[key] === b[key]);
|
|
const normal = (value: string) => value.trim();
|
|
export function useRoomEdit({
|
|
props,
|
|
roomId,
|
|
kind,
|
|
itemId = null,
|
|
}: {
|
|
props: unknown;
|
|
roomId: number;
|
|
kind: Kind;
|
|
itemId?: number | null;
|
|
}) {
|
|
const alive = useRef(true);
|
|
useEffect(() => {
|
|
alive.current = true;
|
|
return () => {
|
|
alive.current = false;
|
|
};
|
|
}, []);
|
|
const current = snapshot(props, roomId, kind, itemId);
|
|
const initial = useRef(current);
|
|
const initialIdentity = useRef(JSON.stringify([roomId, kind, itemId]));
|
|
const observedIdentity = useRef(initialIdentity.current);
|
|
observedIdentity.current = JSON.stringify([roomId, kind, itemId]);
|
|
const empty: Values =
|
|
kind === 'agreement'
|
|
? { title: '', terms: '', reason: '' }
|
|
: {
|
|
title: '',
|
|
responsible_id: '',
|
|
due_date: '',
|
|
status: 'pending',
|
|
evidence: '',
|
|
};
|
|
const form = useForm<{
|
|
version: number | null;
|
|
client_key: string;
|
|
values: Values;
|
|
}>({
|
|
version: current?.version ?? null,
|
|
client_key: createOperationKey(),
|
|
values: current?.values ?? empty,
|
|
});
|
|
const live = useRef(form.data);
|
|
live.current = form.data;
|
|
const edited = useRef(new Set<string>());
|
|
const generation = useRef<Record<string, number>>({});
|
|
const pending = useRef<{
|
|
command: Record<string, string | number | null>;
|
|
generations: Record<string, number>;
|
|
ambiguous: boolean;
|
|
base: Snapshot;
|
|
values: Values;
|
|
} | null>(null);
|
|
const [choices, setChoices] = useState<Record<string, 'mine' | 'current'>>(
|
|
{},
|
|
);
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
const [needsCompare, setNeedsCompare] = useState(false);
|
|
const [problem, setProblem] = useState<string | null>(null);
|
|
const [mustRefresh, setMustRefresh] = useState(false),
|
|
[accessDenied, setAccessDenied] = useState(false);
|
|
const [created, setCreated] = useState<number | null>(null);
|
|
const isNew = kind === 'milestone' && itemId === null;
|
|
const keys = Object.keys(current?.values ?? empty);
|
|
const invalid =
|
|
!current ||
|
|
!initial.current ||
|
|
(kind === 'agreement' && current.actorId !== initial.current.actorId) ||
|
|
initialIdentity.current !== JSON.stringify([roomId, kind, itemId]);
|
|
const stale =
|
|
!!current &&
|
|
!!initial.current &&
|
|
(current.version !== initial.current.version ||
|
|
current.context !== initial.current.context ||
|
|
!same(current.values, initial.current.values));
|
|
const changed =
|
|
current && initial.current
|
|
? keys.filter(
|
|
(key) => current.values[key] !== initial.current?.values[key],
|
|
)
|
|
: [];
|
|
const conflicts = current
|
|
? changed.filter(
|
|
(key) =>
|
|
edited.current.has(key) &&
|
|
live.current.values[key] !== current.values[key],
|
|
)
|
|
: [];
|
|
const choiceKey = (key: string) =>
|
|
JSON.stringify([
|
|
current?.version,
|
|
current?.context,
|
|
key,
|
|
current?.values[key],
|
|
]);
|
|
const unresolved = conflicts.filter(
|
|
(key) => choices[choiceKey(key)] !== 'mine',
|
|
);
|
|
const editable =
|
|
!!current?.canWrite &&
|
|
(kind === 'agreement'
|
|
? ['negotiation', 'agreement_recorded', 'agreed', 'execution']
|
|
: ['agreement_recorded', 'agreed', 'execution']
|
|
).includes(current.status);
|
|
const recovering =
|
|
(isNew || kind === 'agreement') &&
|
|
!!pending.current?.ambiguous &&
|
|
!!current?.canWrite &&
|
|
!accessDenied;
|
|
const blocked =
|
|
invalid ||
|
|
stale ||
|
|
refreshing ||
|
|
needsCompare ||
|
|
(kind === 'agreement' && (accessDenied || !!pending.current)) ||
|
|
(!editable && !recovering);
|
|
const dirty =
|
|
!!pending.current ||
|
|
(!!initial.current &&
|
|
(!same(live.current.values, initial.current.values) ||
|
|
keys.some(
|
|
(key) =>
|
|
edited.current.has(key) &&
|
|
current &&
|
|
live.current.values[key] !== current.values[key],
|
|
)));
|
|
// Untouched controls follow current data. Deliberate edits and uncertain commands require comparison.
|
|
useEffect(() => {
|
|
if (
|
|
!current ||
|
|
invalid ||
|
|
(!stale && !(kind === 'agreement' && pending.current?.ambiguous)) ||
|
|
dirty ||
|
|
edited.current.size > 0 ||
|
|
pending.current ||
|
|
form.processing ||
|
|
needsCompare
|
|
)
|
|
return;
|
|
if (kind === 'agreement' && mustRefresh) return;
|
|
initial.current = current;
|
|
live.current = {
|
|
...live.current,
|
|
version: current.version,
|
|
values: { ...current.values },
|
|
};
|
|
form.setDefaults(live.current);
|
|
form.setData(live.current);
|
|
}, [current, invalid, stale, dirty, form.processing, needsCompare]);
|
|
function change(key: string, value: string) {
|
|
if (!keys.includes(key)) return;
|
|
edited.current.add(key);
|
|
generation.current[key] = (generation.current[key] ?? 0) + 1;
|
|
live.current = {
|
|
...live.current,
|
|
values: { ...live.current.values, [key]: value },
|
|
};
|
|
form.setData(live.current);
|
|
setChoices((previous) => {
|
|
const next = { ...previous };
|
|
delete next[choiceKey(key)];
|
|
return next;
|
|
});
|
|
}
|
|
function choose(key: string, value: 'mine' | 'current') {
|
|
if (!current || !keys.includes(key)) return;
|
|
if (value === 'current') change(key, current.values[key]);
|
|
setChoices((previous) => ({ ...previous, [choiceKey(key)]: value }));
|
|
}
|
|
function adopt() {
|
|
if (
|
|
!current ||
|
|
invalid ||
|
|
(!stale && !(kind === 'agreement' && pending.current?.ambiguous)) ||
|
|
(kind === 'agreement' && (mustRefresh || accessDenied)) ||
|
|
unresolved.length ||
|
|
refreshing ||
|
|
form.processing
|
|
)
|
|
return;
|
|
const values = { ...current.values };
|
|
for (const key of keys)
|
|
if (edited.current.has(key)) values[key] = live.current.values[key];
|
|
initial.current = current;
|
|
live.current = { ...live.current, version: current.version, values };
|
|
form.setData(live.current);
|
|
form.setDefaults({ ...live.current, values: current.values });
|
|
form.clearErrors();
|
|
setChoices({});
|
|
setNeedsCompare(false);
|
|
setProblem(null);
|
|
if (kind === 'agreement') pending.current = null;
|
|
}
|
|
function httpFailure(response: { status: number }) {
|
|
if (!alive.current) return false;
|
|
if (kind === 'agreement') {
|
|
setMustRefresh(true);
|
|
setAccessDenied([401, 403, 404, 419].includes(response.status));
|
|
}
|
|
setProblem(
|
|
response.status === 409
|
|
? 'O registo ou o estado da sala mudou. Compare com os dados actuais; as suas edições foram conservadas.'
|
|
: 'Não foi possível confirmar a operação com o acesso actual. As suas edições foram conservadas.',
|
|
);
|
|
if (response.status === 409 || response.status >= 500)
|
|
setNeedsCompare(true);
|
|
if (pending.current) {
|
|
if (kind === 'agreement' && response.status === 409) {
|
|
pending.current = null;
|
|
return false;
|
|
}
|
|
if (
|
|
response.status >= 500 ||
|
|
response.status === 409 ||
|
|
pending.current.ambiguous
|
|
)
|
|
pending.current.ambiguous = true;
|
|
else pending.current = null;
|
|
}
|
|
return false;
|
|
}
|
|
function refresh() {
|
|
if (refreshing || form.processing) return;
|
|
setRefreshing(true);
|
|
router.reload({
|
|
only: [
|
|
'room',
|
|
'milestones',
|
|
'participants',
|
|
'canWrite',
|
|
'messages',
|
|
'documents',
|
|
'partnership',
|
|
'interest',
|
|
'agreementActorId',
|
|
'agreementCommands',
|
|
'agreementParty',
|
|
],
|
|
onSuccess: (page) => {
|
|
if (!alive.current) return;
|
|
if (
|
|
kind === 'agreement' &&
|
|
pending.current &&
|
|
acknowledgeAgreement(page.props, pending.current)
|
|
)
|
|
return;
|
|
const fresh = snapshot(page.props, roomId, kind, itemId);
|
|
if (
|
|
kind === 'agreement' &&
|
|
fresh &&
|
|
fresh.actorId === initial.current?.actorId
|
|
) {
|
|
setMustRefresh(false);
|
|
setAccessDenied(false);
|
|
}
|
|
if (
|
|
fresh &&
|
|
initial.current &&
|
|
fresh.version === initial.current.version &&
|
|
fresh.context === initial.current.context &&
|
|
same(fresh.values, initial.current.values)
|
|
) {
|
|
setNeedsCompare(false);
|
|
setProblem(null);
|
|
}
|
|
},
|
|
onHttpException: () => {
|
|
if (!alive.current) return false;
|
|
if (kind === 'agreement') {
|
|
setMustRefresh(true);
|
|
setAccessDenied(true);
|
|
}
|
|
setProblem(
|
|
'Não foi possível recuperar esta sala 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 acknowledgeAgreement(
|
|
props: unknown,
|
|
sent: NonNullable<typeof pending.current>,
|
|
): boolean {
|
|
if (!alive.current || pending.current !== sent) return false;
|
|
const fresh = snapshot(props, roomId, 'agreement', null),
|
|
action = String(sent.command.action),
|
|
version = Number(sent.command.version) + 1;
|
|
const receipt = fresh?.commands.find(
|
|
(command) =>
|
|
command.key === sent.command.client_key &&
|
|
command.action === action &&
|
|
command.version === version,
|
|
);
|
|
if (
|
|
!fresh ||
|
|
!receipt ||
|
|
fresh.actorId !== sent.base.actorId ||
|
|
fresh.version === null ||
|
|
fresh.version < version
|
|
)
|
|
return false;
|
|
const expected: Values =
|
|
action === 'record'
|
|
? {
|
|
title: normal(sent.values.title),
|
|
terms: normal(sent.values.terms),
|
|
reason: '',
|
|
}
|
|
: { ...sent.base.values, reason: '' };
|
|
if (
|
|
fresh.version === version &&
|
|
(fresh.status !== receipt.status ||
|
|
fresh.values.title !== expected.title ||
|
|
fresh.values.terms !== expected.terms)
|
|
)
|
|
return false;
|
|
if (
|
|
fresh.version === version &&
|
|
action === 'confirm' &&
|
|
(!receipt.party ||
|
|
fresh.confirmations[receipt.party]?.actorId !==
|
|
sent.base.actorId ||
|
|
fresh.confirmations[receipt.party]?.reason !==
|
|
normal(String(sent.command.reason)) ||
|
|
(receipt.status === 'agreed' &&
|
|
(!fresh.confirmations.proponent ||
|
|
!fresh.confirmations.candidate)))
|
|
)
|
|
return false;
|
|
const later =
|
|
fresh.version > version ||
|
|
fresh.partnershipVersion !== sent.base.partnershipVersion ||
|
|
fresh.interestVersion !== sent.base.interestVersion;
|
|
const saved: Snapshot = later
|
|
? {
|
|
...sent.base,
|
|
version,
|
|
roomVersion: version,
|
|
status: receipt.status,
|
|
values: expected,
|
|
context: JSON.stringify([
|
|
version,
|
|
receipt.status,
|
|
sent.base.canWrite,
|
|
sent.base.partnershipVersion,
|
|
sent.base.interestVersion,
|
|
]),
|
|
}
|
|
: fresh;
|
|
const merged = { ...saved.values };
|
|
edited.current.clear();
|
|
for (const key of keys)
|
|
if (
|
|
(generation.current[key] ?? 0) !==
|
|
(sent.generations[key] ?? 0) ||
|
|
(action !== 'record' &&
|
|
key !== 'reason' &&
|
|
live.current.values[key] !== sent.base.values[key])
|
|
) {
|
|
merged[key] = live.current.values[key];
|
|
edited.current.add(key);
|
|
}
|
|
initial.current = saved;
|
|
pending.current = null;
|
|
live.current = {
|
|
...live.current,
|
|
version: saved.version,
|
|
values: merged,
|
|
};
|
|
form.setDefaults({ ...live.current, values: saved.values });
|
|
form.setData(live.current);
|
|
form.clearErrors();
|
|
setChoices({});
|
|
setNeedsCompare(later);
|
|
setMustRefresh(false);
|
|
setAccessDenied(false);
|
|
setProblem(
|
|
later
|
|
? 'A acção foi reconhecida, mas existem decisões posteriores. Compare antes de preparar outra acção.'
|
|
: null,
|
|
);
|
|
return true;
|
|
}
|
|
function submit(action = 'record', recover = false) {
|
|
if (
|
|
!alive.current ||
|
|
form.processing ||
|
|
!initial.current ||
|
|
(kind === 'agreement' &&
|
|
(invalid || accessDenied || !current?.canWrite || refreshing))
|
|
)
|
|
return;
|
|
if (recover && kind === 'agreement') {
|
|
if (!pending.current?.ambiguous) return;
|
|
action = String(pending.current.command.action);
|
|
} else if (blocked) return;
|
|
const base = initial.current;
|
|
if (kind === 'agreement' && !recover) {
|
|
const allowed: Record<string, string[]> = {
|
|
record: ['negotiation', 'agreement_recorded'],
|
|
confirm: ['agreement_recorded'],
|
|
start: ['agreed'],
|
|
complete: ['execution'],
|
|
renew: ['agreed', 'execution'],
|
|
cancel: [
|
|
'negotiation',
|
|
'agreement_recorded',
|
|
'agreed',
|
|
'execution',
|
|
],
|
|
};
|
|
if (!allowed[action]?.includes(base.status)) return;
|
|
}
|
|
const command: Record<string, string | number | null> =
|
|
pending.current?.command ??
|
|
(kind === 'agreement'
|
|
? action === 'record'
|
|
? {
|
|
...live.current.values,
|
|
version: live.current.version,
|
|
action,
|
|
client_key: createOperationKey(),
|
|
partnership_version: base.partnershipVersion,
|
|
interest_version: base.interestVersion,
|
|
}
|
|
: {
|
|
version: live.current.version,
|
|
action,
|
|
reason: live.current.values.reason,
|
|
client_key: createOperationKey(),
|
|
partnership_version: base.partnershipVersion,
|
|
interest_version: base.interestVersion,
|
|
}
|
|
: {
|
|
...live.current.values,
|
|
version: live.current.version,
|
|
...(isNew ? { client_key: live.current.client_key } : {}),
|
|
});
|
|
const sentGenerations = pending.current?.generations ?? {
|
|
...generation.current,
|
|
};
|
|
if ((isNew || kind === 'agreement') && !pending.current)
|
|
pending.current = {
|
|
command,
|
|
generations: sentGenerations,
|
|
ambiguous: false,
|
|
base,
|
|
values: { ...live.current.values },
|
|
};
|
|
const sentValues = { ...live.current.values };
|
|
const attempt = pending.current;
|
|
setProblem(null);
|
|
form.transform(() => command);
|
|
const unknown = () => {
|
|
if (!alive.current) return false;
|
|
if (kind === 'agreement' && pending.current !== attempt)
|
|
return false;
|
|
if (kind === 'agreement') setMustRefresh(true);
|
|
if (pending.current) pending.current.ambiguous = true;
|
|
else setNeedsCompare(true);
|
|
setProblem(
|
|
'A gravação não foi confirmada. As suas edições foram conservadas.',
|
|
);
|
|
return false;
|
|
};
|
|
const options = {
|
|
preserveScroll: true,
|
|
onSuccess: (page: { props: Record<string, unknown> }) => {
|
|
if (!alive.current) return;
|
|
if (kind === 'agreement') {
|
|
if (
|
|
attempt &&
|
|
pending.current === attempt &&
|
|
!acknowledgeAgreement(page.props, attempt)
|
|
) {
|
|
attempt.ambiguous = true;
|
|
setMustRefresh(true);
|
|
setNeedsCompare(true);
|
|
setProblem(
|
|
'A resposta não reconheceu esta tentativa. A preparação foi conservada.',
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
if (
|
|
observedIdentity.current !==
|
|
JSON.stringify([roomId, kind, itemId])
|
|
)
|
|
return;
|
|
const saved = snapshot(
|
|
page.props,
|
|
roomId,
|
|
kind,
|
|
itemId,
|
|
isNew ? String(command.client_key) : undefined,
|
|
);
|
|
if (
|
|
!saved ||
|
|
(!isNew && saved.version !== Number(command.version) + 1)
|
|
) {
|
|
setProblem(
|
|
'A resposta não confirmou este registo. Recupere os dados antes de continuar; a edição foi conservada.',
|
|
);
|
|
setNeedsCompare(true);
|
|
if (pending.current) pending.current.ambiguous = true;
|
|
return;
|
|
}
|
|
if (!isNew) {
|
|
if (
|
|
milestoneKeys.some(
|
|
(key) =>
|
|
saved.values[key] !== normal(sentValues[key]),
|
|
) ||
|
|
saved.context !== base.context
|
|
) {
|
|
setProblem(
|
|
'Os dados devolvidos diferem da operação enviada. Compare antes de continuar; a sua edição foi conservada.',
|
|
);
|
|
setNeedsCompare(true);
|
|
return;
|
|
}
|
|
}
|
|
const defaults = isNew
|
|
? snapshot(page.props, roomId, kind, null)
|
|
: saved;
|
|
if (!defaults) return;
|
|
const merged = { ...defaults.values };
|
|
edited.current.clear();
|
|
for (const key of keys)
|
|
if (
|
|
(generation.current[key] ?? 0) !==
|
|
(sentGenerations[key] ?? 0)
|
|
) {
|
|
merged[key] = live.current.values[key];
|
|
edited.current.add(key);
|
|
}
|
|
initial.current = defaults;
|
|
if (isNew) {
|
|
setCreated(saved.id);
|
|
pending.current = null;
|
|
}
|
|
live.current = {
|
|
version: defaults.version,
|
|
client_key: isNew
|
|
? createOperationKey()
|
|
: live.current.client_key,
|
|
values: merged,
|
|
};
|
|
form.setDefaults({ ...live.current, values: defaults.values });
|
|
form.setData(live.current);
|
|
form.clearErrors();
|
|
setChoices({});
|
|
setNeedsCompare(false);
|
|
setProblem(null);
|
|
},
|
|
onError: () => {
|
|
if (
|
|
!alive.current ||
|
|
(kind === 'agreement' && pending.current !== attempt)
|
|
)
|
|
return;
|
|
if (pending.current && !pending.current.ambiguous)
|
|
pending.current = null;
|
|
},
|
|
onHttpException: (response: { status: number }) =>
|
|
kind === 'agreement' && pending.current !== attempt
|
|
? false
|
|
: httpFailure(response),
|
|
onNetworkError: unknown,
|
|
onCancel: unknown,
|
|
};
|
|
if (kind === 'agreement')
|
|
form.post(`/parcerias/salas/${roomId}/acordo`, options);
|
|
else if (isNew) form.post(`/parcerias/salas/${roomId}/marcos`, options);
|
|
else form.put(`/parcerias/salas/${roomId}/marcos/${itemId}`, options);
|
|
}
|
|
return {
|
|
form,
|
|
values: form.data.values,
|
|
current: current?.values ?? empty,
|
|
baseVersion: initial.current?.version,
|
|
version: current?.version,
|
|
status: current?.status,
|
|
stale,
|
|
invalid,
|
|
editable,
|
|
dirty,
|
|
blocked,
|
|
refreshing,
|
|
changed,
|
|
conflicts,
|
|
unresolved,
|
|
problem,
|
|
recovering,
|
|
pendingAction:
|
|
kind === 'agreement' && pending.current?.ambiguous
|
|
? String(pending.current.command.action)
|
|
: null,
|
|
canRecover:
|
|
kind === 'agreement' &&
|
|
recovering &&
|
|
!invalid &&
|
|
!refreshing &&
|
|
!form.processing,
|
|
recover: () => submit('record', true),
|
|
mustRefresh: kind === 'agreement' && mustRefresh,
|
|
created,
|
|
change,
|
|
choose,
|
|
adopt,
|
|
refresh,
|
|
submit,
|
|
};
|
|
}
|
|
export type RoomEditor = ReturnType<typeof useRoomEdit>;
|
|
|
|
/** One room-level leave guard. No draft content is persisted in browser storage. */
|
|
export function useRoomLeaveGuard(dirty: boolean) {
|
|
const live = useRef(dirty);
|
|
live.current = dirty;
|
|
useEffect(() => {
|
|
const leave = (event: BeforeUnloadEvent) => {
|
|
if (live.current) {
|
|
event.preventDefault();
|
|
event.returnValue = '';
|
|
}
|
|
};
|
|
const stop = router.on('before', (event) => {
|
|
if (
|
|
live.current &&
|
|
event.detail.visit.method === 'get' &&
|
|
event.detail.visit.preserveState !== true &&
|
|
!window.confirm(
|
|
'Há alterações por guardar nesta sala. Pretende sair sem as guardar?',
|
|
)
|
|
)
|
|
event.preventDefault();
|
|
});
|
|
window.addEventListener('beforeunload', leave);
|
|
return () => {
|
|
stop();
|
|
window.removeEventListener('beforeunload', leave);
|
|
};
|
|
}, []);
|
|
}
|