438 lines
14 KiB
JavaScript
438 lines
14 KiB
JavaScript
import fs from 'node:fs';
|
|
import { createRequire } from 'node:module';
|
|
import { compileFunction } from 'node:vm';
|
|
import ts from 'typescript';
|
|
const require = createRequire(import.meta.url);
|
|
export const leadId = '10000000-0000-4000-8000-000000000001';
|
|
export function props(patch = {}) {
|
|
return {
|
|
lead: {
|
|
id: leadId,
|
|
version_no: 1,
|
|
title: 'SYNTHETIC lead',
|
|
status: 'new',
|
|
owner_user_id: 1,
|
|
tasks: [],
|
|
},
|
|
contacts: [],
|
|
owners: [
|
|
{ id: 1, name: 'SYNTHETIC manager' },
|
|
{ id: 2, name: 'SYNTHETIC colleague' },
|
|
],
|
|
nextAction: { future_tasks: 0 },
|
|
taskCreation: { lead_id: leadId, actor_id: 1, commands: [] },
|
|
...patch,
|
|
};
|
|
}
|
|
export function nodes(tree) {
|
|
return Array.isArray(tree)
|
|
? tree.flatMap(nodes)
|
|
: tree && typeof tree === 'object'
|
|
? [tree, ...nodes(tree.props?.children)]
|
|
: [];
|
|
}
|
|
export function text(tree) {
|
|
return Array.isArray(tree)
|
|
? tree.map(text).join('')
|
|
: tree?.props
|
|
? text(tree.props.children)
|
|
: typeof tree === 'string' || typeof tree === 'number'
|
|
? String(tree)
|
|
: '';
|
|
}
|
|
const clone = (value) =>
|
|
Array.isArray(value)
|
|
? value.map(clone)
|
|
: value && Object.getPrototypeOf(value) === Object.prototype
|
|
? Object.fromEntries(
|
|
Object.entries(value).map(([k, v]) => [k, clone(v)]),
|
|
)
|
|
: value;
|
|
export function harness(initial = props()) {
|
|
const frames = new Map(),
|
|
sent = [],
|
|
reloads = [],
|
|
before = new Set(),
|
|
unload = new Set();
|
|
let active,
|
|
cursor = 0,
|
|
changed = false,
|
|
effects = [],
|
|
current = initial,
|
|
tree,
|
|
sequence = 0,
|
|
confirmCalls = 0;
|
|
const windowMock = {
|
|
confirm() {
|
|
confirmCalls++;
|
|
return false;
|
|
},
|
|
addEventListener(name, callback) {
|
|
if (name === 'beforeunload') unload.add(callback);
|
|
},
|
|
removeEventListener(name, callback) {
|
|
if (name === 'beforeunload') unload.delete(callback);
|
|
},
|
|
};
|
|
const state = (initialValue) => {
|
|
const frame = active,
|
|
i = cursor++;
|
|
if (!(i in frame.cells))
|
|
frame.cells[i] =
|
|
typeof initialValue === 'function'
|
|
? initialValue()
|
|
: initialValue;
|
|
return [
|
|
frame.cells[i],
|
|
(value) => {
|
|
const next =
|
|
typeof value === 'function' ? value(frame.cells[i]) : value;
|
|
if (!Object.is(next, frame.cells[i])) {
|
|
frame.cells[i] = next;
|
|
changed = true;
|
|
}
|
|
},
|
|
];
|
|
};
|
|
const hooks = {
|
|
useState: state,
|
|
useRef(value) {
|
|
const i = cursor++;
|
|
active.cells[i] ??= { current: value };
|
|
return active.cells[i];
|
|
},
|
|
useEffect(callback, deps) {
|
|
const frame = active,
|
|
i = cursor++,
|
|
prior = frame.cells[i];
|
|
if (!prior || !deps.every((value, j) => value === prior.deps[j])) {
|
|
frame.cells[i] = { deps };
|
|
effects.push(() => {
|
|
prior?.cleanup?.();
|
|
frame.cells[i].cleanup = callback();
|
|
});
|
|
}
|
|
},
|
|
useCallback(callback, deps) {
|
|
const i = cursor++,
|
|
prior = active.cells[i];
|
|
if (!prior || !deps.every((value, j) => value === prior.deps[j]))
|
|
active.cells[i] = { deps, callback };
|
|
return active.cells[i].callback;
|
|
},
|
|
};
|
|
const mocks = {
|
|
react: hooks,
|
|
'@inertiajs/react': {
|
|
usePage: () => ({
|
|
props: { auth: { user: { id: current.actorId ?? 1 } } },
|
|
}),
|
|
Link: 'a',
|
|
router: {
|
|
reload(options) {
|
|
reloads.push(options);
|
|
},
|
|
on(name, callback) {
|
|
if (name === 'before') before.add(callback);
|
|
return () => before.delete(callback);
|
|
},
|
|
post(url, data) {
|
|
sent.push({ method: 'post', url, data, options: {} });
|
|
},
|
|
},
|
|
useForm(initialData) {
|
|
const i = cursor++;
|
|
if (active.cells[i]) return active.cells[i];
|
|
const form = {
|
|
data: clone(initialData),
|
|
defaults: clone(initialData),
|
|
errors: {},
|
|
processing: false,
|
|
setData(key, value) {
|
|
this.data =
|
|
typeof key === 'function'
|
|
? key(this.data)
|
|
: typeof key === 'object'
|
|
? key
|
|
: { ...this.data, [key]: value };
|
|
changed = true;
|
|
},
|
|
setDefaults(data) {
|
|
this.defaults = clone(data);
|
|
},
|
|
clearErrors() {
|
|
this.errors = {};
|
|
changed = true;
|
|
},
|
|
reset(...keys) {
|
|
for (const key of keys)
|
|
this.data[key] = this.defaults[key];
|
|
changed = true;
|
|
},
|
|
transform(callback) {
|
|
this.transformer = callback;
|
|
},
|
|
};
|
|
for (const method of ['post', 'put', 'patch'])
|
|
form[method] = (url, options) => {
|
|
form.processing = true;
|
|
changed = true;
|
|
sent.push({
|
|
method,
|
|
url,
|
|
options,
|
|
data: clone(
|
|
form.transformer
|
|
? form.transformer(form.data)
|
|
: form.data,
|
|
),
|
|
form,
|
|
});
|
|
};
|
|
active.cells[i] = form;
|
|
return form;
|
|
},
|
|
},
|
|
'../management/shared': {
|
|
Management: 'main',
|
|
Field: 'label',
|
|
button: '',
|
|
control: '',
|
|
},
|
|
'@/pages/management/shared': {
|
|
Management: 'main',
|
|
Field: 'label',
|
|
button: '',
|
|
control: '',
|
|
},
|
|
'@/components/crm-edit-comparison': { default: () => null },
|
|
'@/lib/crm-record-edit': {
|
|
useCrmRecordEdit: ({ values }) => ({
|
|
form: { processing: false },
|
|
values,
|
|
error: () => undefined,
|
|
blocked: false,
|
|
}),
|
|
},
|
|
'./types': {
|
|
leadStates: { new: 'Novo', qualified: 'Qualificado' },
|
|
taskStates: { open: 'Aberta', done: 'Conclu?da' },
|
|
},
|
|
};
|
|
const operation = {
|
|
createOperationKey: () =>
|
|
`00000000-0000-4000-8000-${String(++sequence).padStart(12, '0')}`,
|
|
};
|
|
mocks['./operation-key'] = operation;
|
|
mocks['@/lib/operation-key'] = operation;
|
|
function load(path) {
|
|
const code = ts.transpileModule(
|
|
fs.readFileSync(
|
|
new URL('../../../' + path, import.meta.url),
|
|
'utf8',
|
|
),
|
|
{
|
|
compilerOptions: {
|
|
module: ts.ModuleKind.CommonJS,
|
|
jsx: ts.JsxEmit.ReactJSX,
|
|
target: ts.ScriptTarget.ES2022,
|
|
},
|
|
},
|
|
).outputText;
|
|
const module = { exports: {} };
|
|
compileFunction(code, ['require', 'module', 'exports'])(
|
|
(name) => mocks[name] ?? require(name),
|
|
module,
|
|
module.exports,
|
|
);
|
|
return module.exports;
|
|
}
|
|
mocks['./profile-json'] = load('resources/js/lib/profile-json.ts');
|
|
mocks['./crm-task-preparation'] = load(
|
|
'resources/js/lib/crm-task-preparation.ts',
|
|
);
|
|
for (const [name, path] of [
|
|
['@/lib/crm-task-creation', 'resources/js/lib/crm-task-creation.ts'],
|
|
[
|
|
'@/components/crm-task-creation',
|
|
'resources/js/components/crm-task-creation.tsx',
|
|
],
|
|
])
|
|
if (fs.existsSync(new URL('../../../' + path, import.meta.url)))
|
|
mocks[name] = load(path);
|
|
const Page = load('resources/js/pages/crm/lead.tsx').default;
|
|
function expand(value, path, seen) {
|
|
if (Array.isArray(value))
|
|
return value.map((child, i) =>
|
|
expand(child, path + '/' + (child?.key ?? i), seen),
|
|
);
|
|
if (!value || typeof value !== 'object' || !value.props) return value;
|
|
if (typeof value.type === 'function') {
|
|
const id = path + '/' + value.type.name + ':' + (value.key ?? '');
|
|
seen.add(id);
|
|
const frame = frames.get(id) ?? { cells: [] };
|
|
frames.set(id, frame);
|
|
const previous = active,
|
|
priorCursor = cursor;
|
|
active = frame;
|
|
cursor = 0;
|
|
const rendered = value.type(value.props);
|
|
active = previous;
|
|
cursor = priorCursor;
|
|
return {
|
|
type: 'component',
|
|
props: {
|
|
name: value.type.name,
|
|
children: expand(rendered, id, seen),
|
|
},
|
|
};
|
|
}
|
|
return {
|
|
...value,
|
|
props: {
|
|
...value.props,
|
|
children: expand(
|
|
value.props.children,
|
|
path + '/children',
|
|
seen,
|
|
),
|
|
},
|
|
};
|
|
}
|
|
function render(next = current) {
|
|
current = next;
|
|
const previousWindow = globalThis.window;
|
|
globalThis.window = windowMock;
|
|
try {
|
|
let loops = 0;
|
|
do {
|
|
changed = false;
|
|
const seen = new Set();
|
|
tree = expand(
|
|
{ type: Page, key: 'page', props: current },
|
|
'root',
|
|
seen,
|
|
);
|
|
for (const [id, frame] of frames)
|
|
if (!seen.has(id)) {
|
|
frame.cells.forEach((cell) => cell?.cleanup?.());
|
|
frames.delete(id);
|
|
}
|
|
const queue = effects;
|
|
effects = [];
|
|
queue.forEach((effect) => effect());
|
|
if (++loops > 12) throw Error('Component state did not settle');
|
|
} while (changed);
|
|
return tree;
|
|
} finally {
|
|
globalThis.window = previousWindow;
|
|
}
|
|
}
|
|
render();
|
|
return {
|
|
sent,
|
|
reloads,
|
|
render,
|
|
get props() {
|
|
return current;
|
|
},
|
|
get tree() {
|
|
return tree;
|
|
},
|
|
all: () => nodes(tree),
|
|
form(name) {
|
|
return nodes(tree).find((node) => node.props?.id === name);
|
|
},
|
|
button(label, scope = tree) {
|
|
return nodes(scope).find(
|
|
(node) => node.type === 'button' && text(node) === label,
|
|
);
|
|
},
|
|
field(name, label) {
|
|
const root = this.form(name);
|
|
const field = nodes(root).find(
|
|
(node) => node.type === 'label' && node.props.label === label,
|
|
);
|
|
return nodes(field).find((node) =>
|
|
['input', 'select', 'textarea'].includes(node.type),
|
|
);
|
|
},
|
|
change(name, label, value, rerender = true) {
|
|
const field = this.field(name, label);
|
|
if (!field) throw Error('Missing field ' + name + '/' + label);
|
|
field.props.onChange({
|
|
target: { value, checked: value, files: value ? [value] : [] },
|
|
});
|
|
if (rerender) render();
|
|
},
|
|
submit(name) {
|
|
const form = this.form(name);
|
|
form.props.onSubmit({ preventDefault() {} });
|
|
render();
|
|
return sent.at(-1);
|
|
},
|
|
click(label, scope = tree) {
|
|
const button = this.button(label, scope);
|
|
if (!button) throw Error('Missing button ' + label);
|
|
button.props.onClick();
|
|
render();
|
|
return sent.at(-1);
|
|
},
|
|
success(next, request = sent.at(-1)) {
|
|
render(next);
|
|
request.options.onSuccess?.({ props: next });
|
|
request.form.processing = false;
|
|
render(next);
|
|
},
|
|
validation(errors, request = sent.at(-1)) {
|
|
request.form.errors = errors;
|
|
request.options.onError?.(errors);
|
|
request.form.processing = false;
|
|
render();
|
|
},
|
|
fail(request = sent.at(-1), status = null) {
|
|
if (status === null) request.options.onNetworkError();
|
|
else request.options.onHttpException({ status });
|
|
request.form.processing = false;
|
|
render();
|
|
},
|
|
refreshed(next, request = reloads.at(-1)) {
|
|
render(next);
|
|
request.onSuccess?.({ props: next });
|
|
request.onFinish?.();
|
|
render(next);
|
|
},
|
|
leave(preserveState = false) {
|
|
let prevented = false;
|
|
const previousWindow = globalThis.window;
|
|
globalThis.window = windowMock;
|
|
try {
|
|
for (const fn of before)
|
|
fn({
|
|
detail: { visit: { method: 'get', preserveState } },
|
|
preventDefault() {
|
|
prevented = true;
|
|
},
|
|
});
|
|
} finally {
|
|
globalThis.window = previousWindow;
|
|
}
|
|
return prevented;
|
|
},
|
|
unload() {
|
|
let prevented = false;
|
|
for (const fn of unload)
|
|
fn({
|
|
preventDefault() {
|
|
prevented = true;
|
|
},
|
|
returnValue: null,
|
|
});
|
|
return prevented;
|
|
},
|
|
get confirmCalls() {
|
|
return confirmCalls;
|
|
},
|
|
};
|
|
}
|