a3s-flow 1.1.0

Durable workflow engine and Rust SDK for A3S
Documentation
import {
  ArrowClockwise,
  ArrowsClockwise,
  Archive,
  Broadcast,
  ChartLineUp,
  CheckCircle,
  Clock,
  FileMagnifyingGlass,
  FlowArrow,
  GitBranch,
  Lightning,
  MagnifyingGlass,
  PaperPlaneTilt,
  Play,
  PlugsConnected,
  Prohibit,
  Repeat,
  ShieldCheck,
  Stack,
  Timer,
  TreeStructure,
  WebhooksLogo,
  X,
  XCircle,
} from '@phosphor-icons/react';
import {
  localizeA3SFlowDagManifest,
  type A3SFlowDagNodeCatalog,
} from '@a3s-lab/flow-ui';
import {
  useMemo,
  useRef,
  useState,
  type DragEvent,
  type KeyboardEvent,
} from 'react';
import type { WorkflowPlaygroundCopy } from './WorkflowPlayground.copy';
import { flowNodeGroups, type FlowWebsiteLocale } from './flow-node-catalog';

const iconByType: Readonly<Record<string, typeof Play>> = {
  'flow.start': Play,
  'flow.condition': GitBranch,
  'flow.step': Lightning,
  'flow.batch': Stack,
  'flow.wait': Clock,
  'flow.hook': WebhooksLogo,
  'flow.signal': Broadcast,
  'flow.child-operation': PlugsConnected,
  'flow.child-workflow': FlowArrow,
  'flow.child-workflows': TreeStructure,
  'flow.continue-as-new': ArrowsClockwise,
  'flow.progress': ChartLineUp,
  'flow.complete': CheckCircle,
  'flow.fail': XCircle,
  'flow.cancel': Prohibit,
  'flow.timeout': Timer,
  iteration: Repeat,
  loop: ArrowClockwise,
  'commerce.risk.score': ShieldCheck,
  'commerce.customs.document-review': FileMagnifyingGlass,
  'commerce.inventory.reserve': Archive,
  'commerce.message.dispatch': PaperPlaneTilt,
};

function toneForType(type: string): string {
  if (type === 'flow.complete' || type === 'flow.progress') return 'green';
  if (type === 'flow.fail' || type === 'flow.cancel') return 'red';
  if (type === 'flow.wait' || type === 'flow.hook') return 'orange';
  if (type.startsWith('flow.child') || type === 'flow.continue-as-new') {
    return 'violet';
  }
  if (type === 'flow.condition' || type === 'iteration' || type === 'loop') {
    return 'cyan';
  }
  return 'blue';
}

type WorkflowPlaygroundLibraryProps = {
  catalog: A3SFlowDagNodeCatalog;
  copy: WorkflowPlaygroundCopy;
  locale: FlowWebsiteLocale;
  open: boolean;
  onClose: () => void;
  onDragEnd: () => void;
  onDragStart: (event: DragEvent<HTMLButtonElement>, type: string) => void;
  onSelect: (type: string) => void;
};

export function WorkflowPlaygroundLibrary({
  catalog,
  copy,
  locale,
  open,
  onClose,
  onDragEnd,
  onDragStart,
  onSelect,
}: WorkflowPlaygroundLibraryProps) {
  const [activeTab, setActiveTab] = useState<'built-in' | 'custom'>('built-in');
  const [query, setQuery] = useState('');
  const builtInTabRef = useRef<HTMLButtonElement>(null);
  const customTabRef = useRef<HTMLButtonElement>(null);
  const normalizedQuery = query.trim().toLocaleLowerCase(locale);
  const matchesQuery = (values: readonly string[]) =>
    !normalizedQuery ||
    values.join(' ').toLocaleLowerCase(locale).includes(normalizedQuery);
  const builtInGroups = useMemo(() => {
    return flowNodeGroups
      .map((group) => ({
        ...group,
        nodes: group.types
          .map((type) =>
            localizeA3SFlowDagManifest(catalog.registry.require(type), locale),
          )
          .filter((manifest) => {
            return matchesQuery([
              manifest.display_name,
              manifest.description,
              manifest.type,
            ]);
          }),
      }))
      .filter(({ nodes }) => nodes.length > 0);
  }, [catalog.registry, locale, normalizedQuery]);
  const customNodes = useMemo(
    () =>
      catalog.custom.filter(({ manifest, capability }) =>
        matchesQuery([
          manifest.display_name,
          manifest.description,
          manifest.type,
          capability.id,
          capability.version,
          capability.handler,
        ]),
      ),
    [catalog.custom, locale, normalizedQuery],
  );
  const onTabKeyDown = (
    event: KeyboardEvent<HTMLButtonElement>,
    tab: 'built-in' | 'custom',
  ) => {
    if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
    event.preventDefault();
    const next =
      event.key === 'Home'
        ? 'built-in'
        : event.key === 'End'
          ? 'custom'
          : tab === 'built-in'
            ? 'custom'
            : 'built-in';
    setActiveTab(next);
    (next === 'built-in' ? builtInTabRef : customTabRef).current?.focus();
  };

  if (!open) return null;

  return (
    <aside
      aria-label={copy.nodeLibrary}
      className="a3s-node-library"
      data-testid="node-library"
    >
      <header>
        <div>
          <h2>{copy.nodeLibrary}</h2>
          <p>{copy.nodeLibraryDescription}</p>
        </div>
        <button
          aria-label={copy.close}
          onClick={onClose}
          title={copy.close}
          type="button"
        >
          <X aria-hidden="true" />
        </button>
      </header>

      <label className="a3s-node-library__search">
        <MagnifyingGlass aria-hidden="true" />
        <span className="a3s-visually-hidden">{copy.searchNodes}</span>
        <input
          autoFocus
          onChange={(event) => setQuery(event.currentTarget.value)}
          placeholder={copy.searchNodes}
          type="search"
          value={query}
        />
      </label>

      <div
        aria-label={copy.nodeLibrary}
        className="a3s-node-library__tabs"
        role="tablist"
      >
        <button
          aria-controls="workflow-built-in-nodes"
          aria-selected={activeTab === 'built-in'}
          className={activeTab === 'built-in' ? 'is-active' : undefined}
          id="workflow-built-in-tab"
          onClick={() => setActiveTab('built-in')}
          onKeyDown={(event) => onTabKeyDown(event, 'built-in')}
          ref={builtInTabRef}
          role="tab"
          tabIndex={activeTab === 'built-in' ? 0 : -1}
          type="button"
        >
          {copy.builtInNodes}
          <span>
            {catalog.registry.list({ includeInternal: false }).length -
              catalog.custom.length}
          </span>
        </button>
        <button
          aria-controls="workflow-custom-nodes"
          aria-selected={activeTab === 'custom'}
          className={activeTab === 'custom' ? 'is-active' : undefined}
          id="workflow-custom-tab"
          onClick={() => setActiveTab('custom')}
          onKeyDown={(event) => onTabKeyDown(event, 'custom')}
          ref={customTabRef}
          role="tab"
          tabIndex={activeTab === 'custom' ? 0 : -1}
          type="button"
        >
          {copy.customNodes}
          <span>{catalog.custom.length}</span>
        </button>
      </div>

      <div
        aria-labelledby={
          activeTab === 'built-in'
            ? 'workflow-built-in-tab'
            : 'workflow-custom-tab'
        }
        className="a3s-node-library__groups"
        id={
          activeTab === 'built-in'
            ? 'workflow-built-in-nodes'
            : 'workflow-custom-nodes'
        }
        role="tabpanel"
      >
        {activeTab === 'built-in' &&
          builtInGroups.map((group) => (
            <section
              aria-labelledby={`workflow-group-${group.id}`}
              key={group.id}
            >
              <h3 id={`workflow-group-${group.id}`}>{group.label[locale]}</h3>
              <div>
                {group.nodes.map((manifest) => {
                  const Icon = iconByType[manifest.type] ?? FlowArrow;
                  return (
                    <button
                      aria-label={copy.addNamedNode(manifest.display_name)}
                      data-node-tone={toneForType(manifest.type)}
                      draggable
                      key={manifest.type}
                      onClick={() => onSelect(manifest.type)}
                      onDragEnd={onDragEnd}
                      onDragStart={(event) => onDragStart(event, manifest.type)}
                      type="button"
                    >
                      <span aria-hidden="true">
                        <Icon weight="duotone" />
                      </span>
                      <span>
                        <strong>{manifest.display_name}</strong>
                        <small>{manifest.description}</small>
                        <code>{manifest.type}</code>
                      </span>
                    </button>
                  );
                })}
              </div>
            </section>
          ))}
        {activeTab === 'built-in' && builtInGroups.length === 0 && (
          <p className="a3s-node-library__empty">{copy.noNodes}</p>
        )}
        {activeTab === 'custom' && customNodes.length > 0 && (
          <section aria-labelledby="workflow-group-custom">
            <h3 id="workflow-group-custom">{copy.customNodes}</h3>
            <div>
              {customNodes.map(({ manifest, capability }) => {
                const Icon = iconByType[manifest.type] ?? FlowArrow;
                return (
                  <button
                    aria-label={copy.addNamedNode(manifest.display_name)}
                    className="is-custom"
                    data-node-tone="violet"
                    draggable
                    key={manifest.type}
                    onClick={() => onSelect(manifest.type)}
                    onDragEnd={onDragEnd}
                    onDragStart={(event) => onDragStart(event, manifest.type)}
                    type="button"
                  >
                    <span aria-hidden="true">
                      <Icon weight="duotone" />
                    </span>
                    <span>
                      <strong>{manifest.display_name}</strong>
                      <small>{manifest.description}</small>
                      <code>{manifest.type}</code>
                      <span className="a3s-node-library__capability">
                        <span>{copy.capabilityReady}</span>
                        <code>{`${capability.id}@${capability.version}`}</code>
                        <small>{`${copy.capabilityHandler} ยท ${capability.handler}`}</small>
                      </span>
                    </span>
                  </button>
                );
              })}
            </div>
          </section>
        )}
        {activeTab === 'custom' && customNodes.length === 0 && (
          <p className="a3s-node-library__empty">
            {catalog.custom.length === 0 ? copy.noCustomNodes : copy.noNodes}
          </p>
        )}
      </div>
    </aside>
  );
}