graphwalker-studio 1.0.2

Web-based visual model editor and test runner for GraphWalker
import { useModelStore } from '@/store/model-store';
import { useExecutionStore } from '@/store/execution-store';
import { useEditorStore } from '@/store/editor-store';
import type { GWModel } from '@/store/types';

function Section({ title, children }: { title: string; children: React.ReactNode }) {
  return (
    <div className="border-b border-border">
      <div className="px-4 py-2 text-xs font-semibold text-text-muted uppercase tracking-wider bg-surface-alt">
        {title}
      </div>
      <div className="p-4 space-y-3">{children}</div>
    </div>
  );
}

function Field({
  label,
  value,
  onChange,
  disabled,
  multiline,
}: {
  label: string;
  value: string;
  onChange: (v: string) => void;
  disabled?: boolean;
  multiline?: boolean;
}) {
  const cls = `
    w-full bg-surface-alt border border-border rounded-md px-3 py-1.5
    text-sm text-text focus:outline-none focus:border-primary
    disabled:opacity-40 disabled:cursor-not-allowed
    transition-colors
  `;
  return (
    <div>
      <label className="block text-xs text-text-muted mb-1">{label}</label>
      {multiline ? (
        <textarea
          className={`${cls} resize-y min-h-[60px]`}
          value={value}
          onChange={(e) => onChange(e.target.value)}
          disabled={disabled}
          rows={3}
        />
      ) : (
        <input
          className={cls}
          value={value}
          onChange={(e) => onChange(e.target.value)}
          disabled={disabled}
        />
      )}
    </div>
  );
}

export default function PropertiesPanel() {
  const models = useModelStore((s) => s.models);
  const selectedModelIndex = useModelStore((s) => s.selectedModelIndex);
  const selectedElementId = useModelStore((s) => s.selectedElementId);
  const updateModel = useModelStore((s) => s.updateModel);
  const updateVertex = useModelStore((s) => s.updateVertex);
  const updateEdge = useModelStore((s) => s.updateEdge);
  const setStartElement = useModelStore((s) => s.setStartElement);
  const delay = useExecutionStore((s) => s.delay);
  const setDelay = useExecutionStore((s) => s.setDelay);
  const seed = useEditorStore((s) => s.seed);
  const setSeed = useEditorStore((s) => s.setSeed);
  const autoSeed = useEditorStore((s) => s.autoSeed);
  const setAutoSeed = useEditorStore((s) => s.setAutoSeed);
  const globalData = useEditorStore((s) => s.globalData);
  const setGlobalData = useEditorStore((s) => s.setGlobalData);

  const model: GWModel | undefined = models[selectedModelIndex];
  if (!model) {
    return (
      <div className="w-80 bg-surface border-l border-border shrink-0 flex items-center justify-center text-text-muted text-sm">
        No model selected
      </div>
    );
  }

  const vertex = model.vertices.find((v) => v.id === selectedElementId);
  const edge = model.edges.find((e) => e.id === selectedElementId);
  const noSelection = !vertex && !edge;

  return (
    <div className="w-80 bg-surface border-l border-border shrink-0 overflow-y-auto">
      <Section title="Global">
        <div>
          <div className="flex items-center justify-between mb-1">
            <label className="text-xs text-text-muted">Seed</label>
            <label className="flex items-center gap-1.5 text-xs text-text-muted cursor-pointer select-none">
              <input
                type="checkbox"
                checked={autoSeed}
                onChange={(e) => setAutoSeed(e.target.checked)}
                className="accent-primary"
              />
              Auto
            </label>
          </div>
          <input
            className={`
              w-full bg-surface-alt border rounded-md px-3 py-1.5
              text-sm text-text focus:outline-none focus:border-primary
              disabled:opacity-40 disabled:cursor-not-allowed
              transition-colors
              ${!autoSeed && !seed.trim() ? 'border-warning' : 'border-border'}
            `}
            value={seed}
            onChange={(e) => setSeed(e.target.value)}
            disabled={autoSeed}
            placeholder={autoSeed ? '' : 'Enter a seed number'}
          />
          {!autoSeed && !seed.trim() && (
            <p className="text-xs text-warning mt-1">Enter a seed or enable auto</p>
          )}
        </div>
        <Field
          label="Global data"
          value={globalData}
          onChange={setGlobalData}
          multiline
        />
      </Section>

      <Section title="Execution">
        <Field
          label="Generator"
          value={model.generator}
          onChange={(v) => updateModel(selectedModelIndex, { generator: v })}
        />
        <div>
          <label className="block text-xs text-text-muted mb-1">
            Step delay: {delay}ms
          </label>
          <input
            type="range"
            min={0}
            max={500}
            step={10}
            value={delay}
            onChange={(e) => setDelay(Number(e.target.value))}
            className="w-full accent-primary"
          />
        </div>
      </Section>

      <Section title="Model">
        <Field
          label="Name"
          value={model.name}
          onChange={(v) => updateModel(selectedModelIndex, { name: v })}
        />
        <Field
          label="Actions"
          value={(model.actions ?? []).filter(Boolean).join('\n')}
          onChange={(v) => {
            const actions = v.split('\n').map((s) => s.trim()).filter(Boolean);
            updateModel(selectedModelIndex, { actions });
          }}
          multiline
        />
      </Section>

      <Section title="Element">
        <Field
          label="Name"
          value={vertex?.name ?? edge?.name ?? ''}
          onChange={(v) => {
            if (vertex) updateVertex(selectedModelIndex, vertex.id, { name: v });
            if (edge) updateEdge(selectedModelIndex, edge.id, { name: v });
          }}
          disabled={noSelection}
        />
        {(vertex || noSelection) && (
          <Field
            label="Shared State"
            value={vertex?.sharedState ?? ''}
            onChange={(v) => {
              if (vertex) updateVertex(selectedModelIndex, vertex.id, { sharedState: v || undefined });
            }}
            disabled={noSelection}
          />
        )}
        {(edge || noSelection) && (
          <>
            <Field
              label="Guard"
              value={edge?.guard ?? ''}
              onChange={(v) => {
                if (edge) updateEdge(selectedModelIndex, edge.id, { guard: v || undefined });
              }}
              disabled={noSelection}
            />
            <Field
              label="Weight"
              value={edge?.weight?.toString() ?? ''}
              onChange={(v) => {
                if (edge) updateEdge(selectedModelIndex, edge.id, { weight: v ? Number(v) : undefined });
              }}
              disabled={noSelection}
            />
          </>
        )}
        <Field
          label="Actions"
          value={
            vertex ? (vertex.actions ?? []).filter(Boolean).join('\n')
            : edge ? (edge.actions ?? []).filter(Boolean).join('\n')
            : (model.actions ?? []).filter(Boolean).join('\n')
          }
          onChange={(v) => {
            const actions = v.split('\n').map((s) => s.trim()).filter(Boolean);
            if (vertex) updateVertex(selectedModelIndex, vertex.id, { actions });
            else if (edge) updateEdge(selectedModelIndex, edge.id, { actions });
            else updateModel(selectedModelIndex, { actions });
          }}
          multiline
        />
        <Field
          label="Requirements"
          value={vertex?.requirements?.join('\n') ?? edge?.requirements?.join('\n') ?? ''}
          onChange={(v) => {
            const reqs = v.split('\n').map((s) => s.trim()).filter(Boolean);
            if (vertex) updateVertex(selectedModelIndex, vertex.id, { requirements: reqs });
            if (edge) updateEdge(selectedModelIndex, edge.id, { requirements: reqs });
          }}
          disabled={noSelection}
          multiline
        />
        {(vertex || edge) && (
          <div className="flex items-center gap-2">
            <label className="text-xs text-text-muted">Start element</label>
            <button
              onClick={() => setStartElement(selectedModelIndex, (vertex?.id ?? edge?.id)!)}
              className={`
                px-2 py-0.5 text-xs rounded-md border transition-colors
                ${model.startElementId === (vertex?.id ?? edge?.id)
                  ? 'bg-success/20 border-success text-success'
                  : 'border-border text-text-muted hover:border-border-hover'}
              `}
            >
              {model.startElementId === (vertex?.id ?? edge?.id) ? 'Start' : 'Set as start'}
            </button>
          </div>
        )}
      </Section>
    </div>
  );
}