PlaybookTriggerNode.tsx16.1 KBView on GitHub 'use client';
import { Node, mergeAttributes } from '@tiptap/core';
import { Fragment, type Node as PMNode, type Schema } from '@tiptap/pm/model';
import { useState } from 'react';
import { Pencil } from 'lucide-react';
import { createDeferredAutoConvertPlugin } from '../deferred-auto-convert';
import { NodeViewWrapper, NodeViewContent, ReactNodeViewRenderer } from '@tiptap/react';
import type {
NodeViewProps,
MarkdownToken,
MarkdownParseHelpers,
MarkdownRendererHelpers,
MarkdownLexerConfiguration,
} from '@tiptap/core';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import {
TriggerSection,
type TriggerConfig,
type CrmEventType,
} from '@/modules/aop/components/TriggerConfigEditor';
import { cronLabel } from '@/modules/aop/components/cron';
import { usePlaybookAop } from './usePlaybookAop';
export interface PlaybookTriggerOptions {
/** AOP whose field definitions power the field-change editor. */
aopId: string | null;
/** The AOP's owner, so a teammate's document resolves against them. */
ownerUserId: string | null;
}
/** `0 9 * * 1-5 America/Los_Angeles` → "Weekdays at 9 am · Los Angeles" */
function humanCron(arg: string): string {
const parts = arg.trim().split(/\s+/);
const last = parts[parts.length - 1] ?? '';
const hasTz = last.includes('/');
const expr = hasTz ? parts.slice(0, -1).join(' ') : arg.trim();
const tz = hasTz ? ` · ${last.split('/').pop()?.replace(/_/g, ' ')}` : '';
return `${cronLabel(expr)}${tz}`;
}
/**
* `playbookTrigger` — a Notion-style callout node for the backend trigger grammar
* `[on:<tag>]` … `[/on]` (see apps/server/src/services/playbook/trigger-parser.ts).
*
* It renders as an editable callout (header pill + body content area), exactly
* like the original mock trigger, and round-trips losslessly to the markdown
* delimiters. Stored content arrives as plain text (the server reconstructs Y.js
* without this client node), so an auto-convert plugin wraps `[on:…]`…`[/on]`
* regions into the node on load — mirroring the FileLinkNode approach.
*/
interface TriggerStyle {
label: string;
color: string;
}
function triggerStyle(rawTag: string): TriggerStyle {
const tag = rawTag.trim();
const head = tag.split(/[:\s]/)[0];
const arg = tag.slice(head.length).replace(/^[:\s]+/, '').trim();
switch (head) {
case 'any':
return { label: 'Always', color: '#6b7280' };
case 'email':
return { label: 'On email', color: '#2563eb' };
case 'meeting':
return { label: 'After meeting', color: '#16a34a' };
case 'slack':
return { label: 'On Slack', color: '#7c3aed' };
case 'crm-sync':
return { label: 'On CRM sync', color: '#ea580c' };
case 'before-meeting':
return { label: arg ? `${arg} min before meeting` : 'Before meeting', color: '#0d9488' };
case 'cron':
return { label: arg ? humanCron(arg) : 'Cron', color: '#9333ea' };
case 'field-change':
return { label: arg || 'Field change', color: '#d97706' };
default:
return { label: tag, color: '#6b7280' };
}
}
/** `[on:<tag>]` event head → the AOP editor's `CrmEventType`, and back. */
const TAG_TO_EVENT: Record<string, CrmEventType> = {
email: 'email',
meeting: 'meeting',
slack: 'slack',
'crm-sync': 'external_crm',
call: 'call',
note: 'note',
};
const EVENT_TO_TAG: Record<CrmEventType, string> = {
email: 'email',
meeting: 'meeting',
slack: 'slack',
external_crm: 'crm-sync',
call: 'call',
note: 'note',
};
/**
* Parse an `[on:<tag>]` string into the {@link TriggerConfig} the AOP editor
* understands. Inverse of {@link configToTag}. The `if:` clause on event tags is
* not represented in the config (the editor manages event type + schedule +
* field-change, matching the AOP event editor) — {@link TriggerView} carries it
* through separately so editing other aspects of an event trigger doesn't lose
* the condition.
*/
function tagToConfig(rawTag: string): TriggerConfig {
const tag = rawTag.trim();
const head = tag.split(/[:\s]/)[0];
const arg = tag.slice(head.length).replace(/^[:\s]+/, '').trim();
switch (head) {
case 'cron': {
const parts = arg.split(/\s+/).filter(Boolean);
const last = parts[parts.length - 1] ?? '';
const hasTz = last.includes('/');
const schedule = (hasTz ? parts.slice(0, -1) : parts).join(' ') || '0 9 * * 1-5';
return { type: 'cron', schedule, timezone: hasTz ? last : undefined };
}
case 'before-meeting': {
const minutes = parseInt(arg, 10);
return { type: 'before_meeting', minutesBefore: isNaN(minutes) ? 30 : minutes };
}
case 'field-change': {
const arrowIdx = arg.indexOf('→');
if (arrowIdx === -1) return { type: 'conversation_change' };
const field = arg.slice(0, arrowIdx).trim();
const toRaw = arg.slice(arrowIdx + 1).trim();
const toValue = toRaw === '*' || toRaw === '' ? undefined : toRaw;
return { type: 'conversation_change', watchFields: [{ field, toValue }] };
}
case 'any':
case '':
return { type: 'event_occurred' };
default: {
const event = TAG_TO_EVENT[head];
return event ? { type: 'event_occurred', eventTypes: [event] } : { type: 'event_occurred' };
}
}
}
/**
* Serialize a {@link TriggerConfig} back to an `[on:<tag>]` tag, matching the
* backend `triggerTag` grammar (apps/server/src/services/documents/convention-paths.ts).
* A single `[on:]` block is one trigger, so when several event types are
* selected only the first maps onto this block's tag.
*/
function configToTag(config: TriggerConfig): string {
switch (config.type) {
case 'cron':
return `cron: ${config.schedule}${config.timezone ? ` ${config.timezone}` : ''}`;
case 'before_meeting':
return `before-meeting: ${config.minutesBefore}`;
case 'conversation_change': {
const watch = config.watchFields?.[0];
if (!watch?.field) return 'any';
return `field-change: ${watch.field} → ${watch.toValue ?? '*'}`;
}
case 'event_occurred': {
const types = config.eventTypes ?? [];
if (types.length === 0) return 'any';
return EVENT_TO_TAG[types[0]] ?? types[0];
}
case 'webhook':
return 'webhook';
}
}
function TriggerView({ node, updateAttributes, extension }: NodeViewProps) {
const [open, setOpen] = useState(false);
const { aopId, ownerUserId } = extension.options as PlaybookTriggerOptions;
const { conversationFieldDefs, customFieldDefs } = usePlaybookAop(aopId, ownerUserId);
const tag = String(node.attrs.trigger ?? 'any');
const style = triggerStyle(tag);
// The editor doesn't manage the `if:` condition clause on event tags — capture
// it so it rides through an edit instead of being dropped (see tagToConfig).
const ifIdx = tag.indexOf('if:');
const ifClause = ifIdx >= 0 ? tag.slice(ifIdx).trim() : '';
return (
<NodeViewWrapper
as="div"
data-playbook-trigger=""
className="border-border/70 bg-muted/30 my-2 rounded-xl border"
>
<div className="flex items-center gap-2 px-3 pt-2.5" contentEditable={false}>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
className="group inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-semibold transition-shadow hover:shadow-sm"
style={{
color: style.color,
background: `${style.color}1a`,
border: `1px solid ${style.color}33`,
}}
>
{style.label}
<Pencil className="h-2.5 w-2.5 opacity-0 transition-opacity group-hover:opacity-70" />
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-[360px] p-3">
<TriggerSection
config={tagToConfig(tag)}
onChange={(next) => {
// Re-append a preserved `if:` clause only while this stays an
// event trigger — cron / before-meeting / field-change tags
// don't carry one.
const serialized = configToTag(next);
const trigger =
ifClause && next.type === 'event_occurred'
? `${serialized} ${ifClause}`
: serialized;
updateAttributes({ trigger });
}}
hasTrigger
onTriggerSet={() => {}}
conversationFieldDefs={conversationFieldDefs}
customFieldDefs={customFieldDefs}
// Webhook is wired only for the XML playbook path, not legacy [on:] tags.
allowedTriggers={['default', 'event_occurred', 'cron', 'before_meeting', 'conversation_change']}
/>
</PopoverContent>
</Popover>
</div>
<NodeViewContent className="px-3 pb-2.5 pt-1.5 text-sm" />
</NodeViewWrapper>
);
}
/**
* Slice the inline content of `block` between two `textContent` offsets,
* preserving inline atom nodes (e.g. a `fileLink` that fileLinkAutoConvert
* already produced). Atoms contribute nothing to `textContent`, so a plain
* string slice of `block.textContent` would silently drop them. Whitespace
* left by the `[on:…]` / `[/on]` markers is trimmed off the region edges.
*/
function sliceInlineBody(block: PMNode, from: number, to: number, schema: Schema): PMNode[] {
const out: PMNode[] = [];
let offset = 0; // running position in `block.textContent` space
block.forEach((child) => {
if (child.isText) {
const text = child.text ?? '';
const start = offset;
const end = start + text.length;
const s = Math.max(from, start);
const e = Math.min(to, end);
if (s < e) {
const sub = text.slice(s - start, e - start);
if (sub) out.push(schema.text(sub, child.marks));
}
offset = end;
} else if (offset >= from && offset <= to) {
// Inline atom: invisible to textContent, so it sits at a single `offset`
// boundary — keep it when that boundary falls inside the region.
out.push(child);
}
});
if (out.length > 0 && out[0].isText) {
const trimmed = (out[0].text ?? '').replace(/^\s+/, '');
if (trimmed) out[0] = schema.text(trimmed, out[0].marks);
else out.shift();
}
if (out.length > 0 && out[out.length - 1].isText) {
const i = out.length - 1;
const trimmed = (out[i].text ?? '').replace(/\s+$/, '');
if (trimmed) out[i] = schema.text(trimmed, out[i].marks);
else out.pop();
}
return out;
}
export const PlaybookTriggerNode = Node.create<PlaybookTriggerOptions>({
name: 'playbookTrigger',
group: 'block',
content: 'block+',
defining: true,
addOptions() {
return { aopId: null, ownerUserId: null };
},
addAttributes() {
return {
trigger: {
default: 'any',
parseHTML: (el) => el.getAttribute('data-trigger') ?? 'any',
renderHTML: (attrs) => (attrs.trigger ? { 'data-trigger': attrs.trigger } : {}),
},
};
},
parseHTML() {
return [{ tag: 'div[data-playbook-trigger]' }];
},
renderHTML({ HTMLAttributes }) {
return ['div', mergeAttributes({ 'data-playbook-trigger': '' }, HTMLAttributes), 0];
},
addNodeView() {
return ReactNodeViewRenderer(TriggerView);
},
// Block tokenizer — used when content is parsed as markdown (e.g. paste / fresh
// setContent). Matches `[on:<tag>]` … `[/on]` and parses the body as blocks.
markdownTokenizer: {
name: 'playbookTrigger',
level: 'block',
start(src: string) {
const idx = src.search(/^\s*\[on:[^\]]+\]/m);
return idx >= 0 ? idx : -1;
},
tokenize(
src: string,
_tokens: MarkdownToken[],
lexer: MarkdownLexerConfiguration,
): MarkdownToken | undefined {
const match = src.match(/^\s*\[on:([^\]]+)\][ \t]*\r?\n?([\s\S]*?)\r?\n?[ \t]*\[\/on\][ \t]*\r?\n?/);
if (!match) return undefined;
const [raw, tag, body] = match;
return {
type: 'playbookTrigger',
raw,
trigger: tag.trim(),
tokens: lexer.blockTokens(body.trim()),
} as MarkdownToken & { trigger: string };
},
},
parseMarkdown(token=[redacted] & { trigger?: string }, helpers: MarkdownParseHelpers) {
const children = helpers.parseChildren(token.tokens ?? []);
return helpers.createNode(
'playbookTrigger',
{ trigger: token.trigger ?? 'any' },
children.length > 0 ? children : [helpers.createNode('paragraph', {}, [])],
);
},
renderMarkdown(
node: { attrs?: Record<string, unknown>; content?: unknown[] },
helpers: MarkdownRendererHelpers,
) {
const tag = String(node.attrs?.trigger ?? 'any');
const body = helpers
.renderChildren((node.content ?? []) as Parameters<typeof helpers.renderChildren>[0])
.trim();
return `[on:${tag}]\n${body}\n[/on]`;
},
// Wrap plain-text `[on:…]` … `[/on]` regions (from Y.js / server reconstruction)
// into the node so loaded content renders as a callout. Idempotent: once wrapped,
// the markers live in the node's attrs, not as top-level text. Runs as a
// deferred dispatch (not appendTransaction) so the conversion lands in the
// Y.Doc — see deferred-auto-convert.ts for why.
addProseMirrorPlugins() {
const triggerType = this.type;
return [
createDeferredAutoConvertPlugin('playbookTriggerAutoConvert', (state) => {
const paragraph = state.schema.nodes.paragraph;
if (!paragraph) return null;
const blocks: { node: PMNode; start: number; end: number; text: string }[] = [];
state.doc.forEach((node, offset) => {
blocks.push({ node, start: offset, end: offset + node.nodeSize, text: node.textContent });
});
for (let i = 0; i < blocks.length; i += 1) {
const b = blocks[i];
if (!/^\s*\[on:([^\]]+)\]/.test(b.text)) continue;
const tag = (b.text.match(/^\s*\[on:([^\]]+)\]/) as RegExpMatchArray)[1].trim();
const openMarkerEnd = b.text.indexOf(']', b.text.indexOf('[on:')) + 1;
const sameLineClose = b.text.indexOf('[/on]', openMarkerEnd) >= 0;
let regionEnd = -1;
const bodyNodes: PMNode[] = [];
if (sameLineClose) {
regionEnd = b.end; // whole trigger on one line
// Preserve any inline body between the markers, e.g.
// `[on:meeting] @subagents/deal-notifier-agent [/on]`. Slice the
// node's inline content (not b.text) so fileLink atoms already
// converted by fileLinkAutoConvert survive — textContent omits them.
const closeAt = b.text.indexOf('[/on]', openMarkerEnd);
const inner = sliceInlineBody(b.node, openMarkerEnd, closeAt, state.schema);
if (inner.length > 0) bodyNodes.push(paragraph.create(null, inner));
} else {
let closeIdx = -1;
for (let j = i + 1; j < blocks.length; j += 1) {
if (/\[\/on\]/.test(blocks[j].text)) {
closeIdx = j;
break;
}
if (/^\s*\[on:[^\]]+\]/.test(blocks[j].text)) break; // next opener first
}
if (closeIdx === -1) continue; // unterminated → leave as text
regionEnd = blocks[closeIdx].end;
// The closing block is consumed whole, so any text sharing its
// line with `[/on]` (e.g. `[/on] Note: see companion doc`) is
// dropped. Warn rather than lose it silently.
if (blocks[closeIdx].text.trim() !== '[/on]') {
console.warn(
'[playbookTriggerAutoConvert] dropping content on the [/on] line:',
blocks[closeIdx].text.trim(),
);
}
for (let k = i + 1; k < closeIdx; k += 1) bodyNodes.push(blocks[k].node);
}
const content = bodyNodes.length > 0 ? Fragment.fromArray(bodyNodes) : Fragment.from(paragraph.create());
const triggerNode = triggerType.create({ trigger: tag }, content);
const tr = state.tr.replaceWith(b.start, regionEnd, triggerNode);
// One region per pass; re-fires for the rest until nothing remains.
return tr.docChanged ? tr : null;
}
return null;
}),
];
},
});