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

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,
};
}