466 lines
15 KiB
TypeScript
466 lines
15 KiB
TypeScript
import { router, useForm } from '@inertiajs/react';
|
|
import { useEffect, useRef, useState } from 'react';
|
|
import { createOperationKey } from './operation-key';
|
|
import type { Partnership } from '@/pages/partnerships/shared';
|
|
|
|
export type PublicationProps = {
|
|
record: Partnership;
|
|
owner: boolean;
|
|
canEdit: boolean;
|
|
moderator: boolean;
|
|
publicationActions: string[];
|
|
publicationCommands: { key: string; action: string; version: number }[];
|
|
origins: unknown[];
|
|
locations: unknown[];
|
|
};
|
|
type Snapshot = {
|
|
id: number;
|
|
version: number;
|
|
status: string;
|
|
context: string;
|
|
values: Record<string, string>;
|
|
actions: string[];
|
|
canEdit: boolean;
|
|
moderator: boolean;
|
|
commands: PublicationProps['publicationCommands'];
|
|
};
|
|
const states: Record<string, string> = {
|
|
submit: 'submitted',
|
|
publish: 'published',
|
|
return: 'returned',
|
|
reject: 'rejected',
|
|
suspend: 'suspended',
|
|
archive: 'archived',
|
|
renew: 'draft',
|
|
};
|
|
const object = (value: unknown): value is Record<string, unknown> =>
|
|
!!value && typeof value === 'object' && !Array.isArray(value);
|
|
const positive = (value: unknown): value is number =>
|
|
typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
|
|
function scalar(value: unknown): string | null {
|
|
return value == null
|
|
? ''
|
|
: typeof value === 'string' ||
|
|
typeof value === 'number' ||
|
|
typeof value === 'boolean'
|
|
? String(value)
|
|
: null;
|
|
}
|
|
function canonical(value: unknown): unknown {
|
|
return Array.isArray(value)
|
|
? value.map(canonical)
|
|
: object(value)
|
|
? Object.fromEntries(
|
|
Object.keys(value)
|
|
.sort()
|
|
.map((key) => [key, canonical(value[key])]),
|
|
)
|
|
: value;
|
|
}
|
|
function read(props: unknown, id: number): Snapshot | null {
|
|
if (
|
|
!object(props) ||
|
|
!object(props.record) ||
|
|
props.record.id !== id ||
|
|
!positive(props.record.version) ||
|
|
typeof props.record.status !== 'string' ||
|
|
typeof props.canEdit !== 'boolean' ||
|
|
typeof props.moderator !== 'boolean' ||
|
|
!Array.isArray(props.publicationActions) ||
|
|
!props.publicationActions.every(
|
|
(value) =>
|
|
typeof value === 'string' && Object.hasOwn(states, value),
|
|
) ||
|
|
!Array.isArray(props.publicationCommands) ||
|
|
!props.publicationCommands.every(
|
|
(value) =>
|
|
object(value) &&
|
|
typeof value.key === 'string' &&
|
|
typeof value.action === 'string' &&
|
|
Object.hasOwn(states, value.action) &&
|
|
positive(value.version),
|
|
) ||
|
|
!Array.isArray(props.origins) ||
|
|
!Array.isArray(props.locations)
|
|
)
|
|
return null;
|
|
const payload = object(props.record.payload)
|
|
? props.record.payload
|
|
: Array.isArray(props.record.payload) &&
|
|
props.record.payload.length === 0
|
|
? {}
|
|
: null;
|
|
if (!payload) return null;
|
|
const { version, status, ...content } = props.record;
|
|
const values: Record<string, string> = {};
|
|
for (const key of [
|
|
'title',
|
|
'offer',
|
|
'seeking',
|
|
'kind',
|
|
'partnership_type',
|
|
'sector_id',
|
|
'organization_id',
|
|
'deadline',
|
|
]) {
|
|
const value = scalar(props.record[key]);
|
|
if (value === null) return null;
|
|
values[key] = value;
|
|
}
|
|
for (const key of [
|
|
'summary',
|
|
'requirements',
|
|
'confidentiality',
|
|
'availability',
|
|
'consent',
|
|
'mandatory',
|
|
]) {
|
|
const item = payload[key];
|
|
const value =
|
|
key === 'mandatory' &&
|
|
Array.isArray(item) &&
|
|
item.every((entry) => typeof entry === 'string')
|
|
? item.join(', ')
|
|
: scalar(item);
|
|
if (value === null) return null;
|
|
values[key] = value;
|
|
}
|
|
values.origins = props.origins
|
|
.map((value) =>
|
|
object(value)
|
|
? (scalar(value.title ?? value.type) ??
|
|
'Origem indisponível para comparação')
|
|
: '',
|
|
)
|
|
.join('\n');
|
|
values.locations = props.locations
|
|
.map((value) =>
|
|
object(value)
|
|
? `${scalar(value.name ?? value.id) ?? 'Localização indisponível'} · ${scalar(value.role) ?? 'Papel indisponível'} · ${scalar(value.source_reference) ?? 'Fonte indisponível'}`
|
|
: '',
|
|
)
|
|
.join('\n');
|
|
return {
|
|
id,
|
|
version,
|
|
status,
|
|
context: JSON.stringify(
|
|
canonical([content, props.origins, props.locations]),
|
|
),
|
|
values,
|
|
actions: props.publicationActions,
|
|
commands:
|
|
props.publicationCommands as PublicationProps['publicationCommands'],
|
|
canEdit: props.canEdit,
|
|
moderator: props.moderator,
|
|
};
|
|
}
|
|
type Pending = {
|
|
command: {
|
|
client_key: string;
|
|
version: number;
|
|
action: string;
|
|
reason: string;
|
|
};
|
|
generation: number;
|
|
base: Snapshot;
|
|
ambiguous: boolean;
|
|
};
|
|
export function usePublication(p: PublicationProps, actorId: number) {
|
|
const alive = useRef(true);
|
|
useEffect(() => {
|
|
alive.current = true;
|
|
return () => {
|
|
alive.current = false;
|
|
};
|
|
}, []);
|
|
const id = p.record.id,
|
|
current = read(p, id),
|
|
identity = `${id}:${actorId}`;
|
|
const opened = useRef(identity),
|
|
observed = useRef(identity);
|
|
observed.current = identity;
|
|
const base = useRef(current),
|
|
pending = useRef<Pending | null>(null),
|
|
generation = useRef(0);
|
|
const form = useForm({ reason: '' });
|
|
const live = useRef(form.data.reason);
|
|
live.current = form.data.reason;
|
|
const [problem, setProblem] = useState<string | null>(null),
|
|
[needsCompare, setNeedsCompare] = useState(false),
|
|
[refreshing, setRefreshing] = useState(false),
|
|
[mustRefresh, setMustRefresh] = useState(false),
|
|
[, render] = useState(0);
|
|
const invalid =
|
|
!current || !base.current || opened.current !== observed.current;
|
|
const stale =
|
|
!!current &&
|
|
!!base.current &&
|
|
(current.version !== base.current.version ||
|
|
current.status !== base.current.status ||
|
|
current.context !== base.current.context);
|
|
const dirty = !!live.current || !!pending.current;
|
|
const can = (action: string) =>
|
|
!!current &&
|
|
(['publish', 'return', 'reject', 'suspend'].includes(action)
|
|
? current.moderator && !current.canEdit
|
|
: current.canEdit);
|
|
useEffect(() => {
|
|
if (current && stale && !dirty && !needsCompare && !form.processing) {
|
|
base.current = current;
|
|
render((value) => value + 1);
|
|
}
|
|
}, [current, stale, dirty, needsCompare, form.processing]);
|
|
function change(value: string) {
|
|
generation.current++;
|
|
live.current = value;
|
|
form.setData('reason', value);
|
|
}
|
|
function acknowledge(props: unknown, sent: Pending): boolean {
|
|
if (
|
|
!alive.current ||
|
|
pending.current !== sent ||
|
|
observed.current !== opened.current
|
|
)
|
|
return false;
|
|
const fresh = read(props, id),
|
|
command = fresh?.commands.find(
|
|
(value) =>
|
|
value.key === sent.command.client_key &&
|
|
value.action === sent.command.action &&
|
|
value.version === sent.command.version + 1,
|
|
);
|
|
if (
|
|
!fresh ||
|
|
!command ||
|
|
fresh.version < command.version ||
|
|
(fresh.version === command.version &&
|
|
(fresh.status !== states[sent.command.action] ||
|
|
fresh.context !== sent.base.context))
|
|
)
|
|
return false;
|
|
const exact = fresh.version === command.version;
|
|
base.current = exact
|
|
? fresh
|
|
: {
|
|
...sent.base,
|
|
version: command.version,
|
|
status: states[sent.command.action],
|
|
};
|
|
pending.current = null;
|
|
if (generation.current === sent.generation) {
|
|
live.current = '';
|
|
form.setData('reason', '');
|
|
}
|
|
form.setDefaults({ reason: '' });
|
|
form.clearErrors();
|
|
setNeedsCompare(!exact);
|
|
setProblem(
|
|
exact
|
|
? null
|
|
: 'A acção foi reconhecida. Há alterações posteriores; compare antes de preparar outra decisão.',
|
|
);
|
|
setMustRefresh(false);
|
|
render((value) => value + 1);
|
|
return true;
|
|
}
|
|
function refresh() {
|
|
if (refreshing || form.processing) return;
|
|
setRefreshing(true);
|
|
router.reload({
|
|
only: [
|
|
'record',
|
|
'owner',
|
|
'canEdit',
|
|
'moderator',
|
|
'publicationActions',
|
|
'publicationCommands',
|
|
'historySummary',
|
|
'origins',
|
|
'locations',
|
|
],
|
|
onSuccess: (page) => {
|
|
if (!alive.current || observed.current !== opened.current)
|
|
return;
|
|
if (pending.current && acknowledge(page.props, pending.current))
|
|
return;
|
|
const fresh = read(page.props, id);
|
|
if (!fresh) {
|
|
setProblem(
|
|
'Resposta incompatível com esta proposta. O motivo preparado foi conservado.',
|
|
);
|
|
return;
|
|
}
|
|
setMustRefresh(false);
|
|
if (
|
|
base.current &&
|
|
fresh.version === base.current.version &&
|
|
fresh.status === base.current.status &&
|
|
fresh.context === base.current.context
|
|
) {
|
|
setNeedsCompare(false);
|
|
setProblem(null);
|
|
}
|
|
},
|
|
onNetworkError: () => {
|
|
setProblem(
|
|
'Não foi possível confirmar a actualização. O motivo foi conservado.',
|
|
);
|
|
return false;
|
|
},
|
|
onHttpException: () => {
|
|
setNeedsCompare(true);
|
|
setProblem(
|
|
'Não foi possível consultar a proposta com o acesso actual. O motivo foi conservado.',
|
|
);
|
|
return false;
|
|
},
|
|
onFinish: () => {
|
|
if (alive.current) setRefreshing(false);
|
|
},
|
|
});
|
|
}
|
|
function adopt() {
|
|
if (invalid || !current || mustRefresh || refreshing || form.processing)
|
|
return;
|
|
base.current = current;
|
|
pending.current = null;
|
|
setNeedsCompare(false);
|
|
setProblem(null);
|
|
form.clearErrors();
|
|
render((value) => value + 1);
|
|
}
|
|
function send(action: string, recover = false) {
|
|
if (
|
|
invalid ||
|
|
!current ||
|
|
!base.current ||
|
|
refreshing ||
|
|
form.processing ||
|
|
!can(action)
|
|
)
|
|
return;
|
|
let sent = pending.current;
|
|
if (recover) {
|
|
if (!sent?.ambiguous || sent.command.action !== action) return;
|
|
} else {
|
|
if (
|
|
sent ||
|
|
stale ||
|
|
needsCompare ||
|
|
!current.actions.includes(action) ||
|
|
!live.current.trim()
|
|
)
|
|
return;
|
|
sent = {
|
|
command: {
|
|
client_key: createOperationKey(),
|
|
version: base.current.version,
|
|
action,
|
|
reason: live.current,
|
|
},
|
|
generation: generation.current,
|
|
base: base.current,
|
|
ambiguous: false,
|
|
};
|
|
pending.current = sent;
|
|
}
|
|
if (!sent) return;
|
|
const request = sent;
|
|
form.transform(() => ({ ...request.command }));
|
|
form.post(`/parcerias/${id}/estado`, {
|
|
preserveScroll: true,
|
|
onSuccess: (page) => {
|
|
if (!alive.current) return;
|
|
if (
|
|
!acknowledge(page.props, request) &&
|
|
pending.current === request
|
|
) {
|
|
request.ambiguous = true;
|
|
setMustRefresh(true);
|
|
setNeedsCompare(true);
|
|
setProblem(
|
|
'A resposta não confirmou esta tentativa. Conserve o motivo e recupere a mesma acção ou compare os dados actuais.',
|
|
);
|
|
}
|
|
},
|
|
onError: () => {
|
|
if (
|
|
!alive.current ||
|
|
pending.current !== request ||
|
|
observed.current !== opened.current
|
|
)
|
|
return;
|
|
pending.current = null;
|
|
setProblem(
|
|
'A acção não foi gravada. Corrija os campos indicados; o motivo foi conservado.',
|
|
);
|
|
},
|
|
onNetworkError: () => {
|
|
if (
|
|
alive.current &&
|
|
pending.current === request &&
|
|
observed.current === opened.current
|
|
) {
|
|
request.ambiguous = true;
|
|
setMustRefresh(true);
|
|
setProblem(
|
|
'A resposta perdeu-se. Pode recuperar exactamente a mesma tentativa, sem repetir uma decisão já gravada.',
|
|
);
|
|
}
|
|
return false;
|
|
},
|
|
onHttpException: (response) => {
|
|
if (
|
|
!alive.current ||
|
|
pending.current !== request ||
|
|
observed.current !== opened.current
|
|
)
|
|
return false;
|
|
if (response.status >= 500) request.ambiguous = true;
|
|
else pending.current = null;
|
|
setMustRefresh(true);
|
|
setNeedsCompare(true);
|
|
setProblem(
|
|
'A acção não foi confirmada com os dados e acesso actuais. Compare antes de continuar; o motivo foi conservado.',
|
|
);
|
|
return false;
|
|
},
|
|
});
|
|
}
|
|
return {
|
|
form,
|
|
current,
|
|
base: base.current,
|
|
stale,
|
|
invalid,
|
|
dirty,
|
|
problem,
|
|
refreshing,
|
|
needsCompare,
|
|
mustRefresh,
|
|
changes:
|
|
current && base.current
|
|
? Object.keys(current.values).filter(
|
|
(key) =>
|
|
current.values[key] !== base.current?.values[key],
|
|
)
|
|
: [],
|
|
blocked:
|
|
invalid ||
|
|
stale ||
|
|
needsCompare ||
|
|
!!pending.current ||
|
|
refreshing ||
|
|
form.processing,
|
|
recovery: pending.current?.ambiguous
|
|
? pending.current.command.action
|
|
: null,
|
|
can,
|
|
change,
|
|
refresh,
|
|
adopt,
|
|
send,
|
|
};
|
|
}
|