UIKitV3-Automator / bridge /src /WorkflowElementGenerator.php
KyklosIO's picture
feat: add Intelligent Workflow Elements tab, Dockerfile, presets, and patches installer
cc14d64
Raw History Blame Contribute Delete
27.3 kB
<?php
namespace Fyoo\FigmaBridge;
/**
* Compiles a declarative workflow specification into native YOOtheme Pro
* compound custom elements (element.json, element.php, template.php) and can
* hydrate full YOOtheme builder layout trees from domain pipeline payloads.
*/
class WorkflowElementGenerator
{
private array $spec;
public function __construct(array $spec)
{
if (empty($spec['name'])) {
throw new \InvalidArgumentException("Workflow element spec must define a 'name'");
}
if (empty($spec['title'])) {
throw new \InvalidArgumentException("Workflow element spec must define a 'title'");
}
$this->spec = $spec;
}
public static function fromJson(string $json): self
{
$data = json_decode($json, true);
if (!is_array($data)) {
throw new \InvalidArgumentException("Invalid JSON in workflow element spec");
}
return new self($data);
}
public static function fromFile(string $path): self
{
if (!file_exists($path)) {
throw new \InvalidArgumentException("Workflow spec file not found: $path");
}
return self::fromJson((string) file_get_contents($path));
}
public function name(): string
{
return $this->spec['name'];
}
public function itemName(): string
{
return $this->spec['item']['name'] ?? ($this->name() . '_item');
}
public function isContainer(): bool
{
return !empty($this->spec['container']);
}
/**
* Compiles all required files for the custom element.
*
* @return array<string, string> relative_file_path => content
*/
public function compile(): array
{
$files = [];
$name = $this->name();
// 1. Parent element
$files["elements/{$name}/element.json"] = json_encode($this->compileParentSchema(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
$files["elements/{$name}/element.php"] = $this->compileElementPhp();
$files["elements/{$name}/templates/template.php"] = $this->compileParentTemplate();
$files["elements/{$name}/templates/content.php"] = $this->compileParentContent();
$files["elements/{$name}/images/icon.svg"] = $this->defaultIconSvg();
$files["elements/{$name}/images/iconSmall.svg"] = $this->defaultIconSmallSvg();
// 2. Child item element (if container)
if ($this->isContainer()) {
$itemName = $this->itemName();
$files["elements/{$itemName}/element.json"] = json_encode($this->compileItemSchema(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
$files["elements/{$itemName}/element.php"] = $this->compileItemPhp();
$files["elements/{$itemName}/templates/template.php"] = $this->compileItemTemplate();
$files["elements/{$itemName}/templates/content.php"] = $this->compileItemContent();
$files["elements/{$itemName}/images/icon.svg"] = $this->defaultIconSvg();
$files["elements/{$itemName}/images/iconSmall.svg"] = $this->defaultIconSmallSvg();
}
return $files;
}
/**
* Installs the generated workflow element directly into the yootheme_patches child theme,
* maintaining 100% architectural consistency with the XS Elements ecosystem.
*/
public function installToPatches(string $patchesDir): array
{
$patchesDir = rtrim($patchesDir, '/');
if (!is_dir($patchesDir) || !file_exists($patchesDir . '/config.php')) {
throw new \InvalidArgumentException("Invalid yootheme_patches directory: $patchesDir");
}
$rawName = $this->name();
$cleanName = preg_replace('/^xs_/', '', $rawName);
$pascalName = str_replace(' ', '', ucwords(str_replace('_', ' ', $cleanName)));
$camelName = lcfirst(str_replace(' ', '', ucwords(str_replace('_', ' ', $rawName))));
$targetElementDir = $patchesDir . '/src/Xs/' . $pascalName . '/element';
if (!is_dir($targetElementDir)) {
mkdir($targetElementDir, 0755, true);
}
// Write elements without the extra 'elements/' prefix
$compiled = $this->compile();
$writtenFiles = [];
foreach ($compiled as $relPath => $content) {
// Strip 'elements/' prefix
$strippedPath = preg_replace('/^elements\//', '', $relPath);
$fullPath = $targetElementDir . '/' . $strippedPath;
$dir = dirname($fullPath);
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
file_put_contents($fullPath, $content);
$writtenFiles[] = $fullPath;
}
// 1. Update patches.php
$patchesFile = $patchesDir . '/patches.php';
if (file_exists($patchesFile)) {
$patchesCode = (string) file_get_contents($patchesFile);
if (!str_contains($patchesCode, "'{$rawName}'")) {
$hook = "// XS {$this->spec['title']} builder element (child-theme integrated)\n '{$rawName}' => true,\n";
$pos = strrpos($patchesCode, '];');
if ($pos !== false) {
$patchesCode = substr_replace($patchesCode, " " . $hook, $pos, 0);
file_put_contents($patchesFile, $patchesCode);
}
}
}
// 2. Update config.php
$configFile = $patchesDir . '/config.php';
if (file_exists($configFile)) {
$configCode = (string) file_get_contents($configFile);
if (!str_contains($configCode, "'{$camelName}'")) {
$block = "\n if (\$isPatchEnabled(\$config, '{$camelName}', true)) {\n"
. " \$builder->addTypePath(__DIR__ . '/src/Xs/{$pascalName}/element/*/element.json');\n"
. " }\n";
$anchor = "if (\$isPatchEnabled(\$config, 'xsChangelog', true)) {";
$pos = strpos($configCode, $anchor);
if ($pos !== false) {
$configCode = substr_replace($configCode, $block, $pos, 0);
file_put_contents($configFile, $configCode);
}
}
}
return [
'name' => $rawName,
'pascalName' => $pascalName,
'camelName' => $camelName,
'targetDir' => $targetElementDir,
'files' => $writtenFiles,
];
}
public function spec(): array
{
return $this->spec;
}
/**
* Writes compiled elements to the destination directory.
*/
public function write(string $targetDir): void
{
$compiled = $this->compile();
foreach ($compiled as $relPath => $content) {
$fullPath = rtrim($targetDir, '/') . '/' . $relPath;
$dir = dirname($fullPath);
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
file_put_contents($fullPath, $content);
}
}
/**
* Writes compiled elements and zips them into a single archive.
*/
public function zip(string $outDir, string $zipPath): void
{
$this->write($outDir);
$zip = new \ZipArchive();
if ($zip->open($zipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
throw new \RuntimeException("Cannot create zip archive at: $zipPath");
}
$files = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($outDir, \RecursiveDirectoryIterator::SKIP_DOTS),
\RecursiveIteratorIterator::LEAVES_ONLY
);
foreach ($files as $file) {
if (!$file->isDir()) {
$filePath = $file->getRealPath();
$relativePath = substr($filePath, strlen(realpath($outDir)) + 1);
$zip->addFile($filePath, $relativePath);
}
}
$zip->close();
}
/**
* Generates the YOOtheme builder element.json for the parent element.
*/
public function compileParentSchema(): array
{
$fields = [];
$defaults = [];
$tabs = [];
foreach ($this->spec['tabs'] ?? [] as $tab) {
$tabTitle = $tab['title'] ?? 'General';
$tabFieldNames = [];
foreach ($tab['fields'] ?? [] as $f) {
$fName = $f['name'];
$tabFieldNames[] = $fName;
$fieldDef = [
'label' => $f['label'] ?? ucfirst(str_replace('_', ' ', $fName)),
'type' => $this->mapFieldType($f['type'] ?? 'text'),
];
if (isset($f['description'])) {
$fieldDef['description'] = $f['description'];
}
if (isset($f['options']) && is_array($f['options'])) {
$fieldDef['options'] = $f['options'];
}
if (isset($f['show'])) {
$fieldDef['show'] = $f['show'];
}
$fields[$fName] = $fieldDef;
if (isset($f['default'])) {
$defaults[$fName] = $f['default'];
}
}
$tabs[] = [
'title' => $tabTitle,
'fields' => $tabFieldNames,
];
}
// Append builder.advanced tab for standard YOOtheme advanced controls
$tabs[] = '${builder.advanced}';
$schema = [
'@import' => './element.php',
'name' => $this->name(),
'title' => $this->spec['title'],
'group' => $this->spec['group'] ?? 'Workflow Elements',
'icon' => '${url:images/icon.svg}',
'iconSmall' => '${url:images/iconSmall.svg}',
'element' => true,
'container' => $this->isContainer(),
'width' => 500,
'defaults' => $defaults,
'templates' => [
'render' => './templates/template.php',
],
'fields' => $fields,
'fieldset' => [
'default' => [
'type' => 'tabs',
'fields' => $tabs,
],
],
];
return $schema;
}
/**
* Generates the YOOtheme builder element.json for the child item element.
*/
public function compileItemSchema(): array
{
$itemSpec = $this->spec['item'] ?? [];
$itemName = $this->itemName();
$itemTitle = $itemSpec['title'] ?? ($this->spec['title'] . ' Record');
$fields = [];
$defaults = [];
$tabs = [];
foreach ($itemSpec['tabs'] ?? [] as $tab) {
$tabTitle = $tab['title'] ?? 'Record';
$tabFieldNames = [];
foreach ($tab['fields'] ?? [] as $f) {
$fName = $f['name'];
$tabFieldNames[] = $fName;
$fieldDef = [
'label' => $f['label'] ?? ucfirst(str_replace('_', ' ', $fName)),
'type' => $this->mapFieldType($f['type'] ?? 'text'),
];
if (isset($f['description'])) {
$fieldDef['description'] = $f['description'];
}
if (isset($f['options']) && is_array($f['options'])) {
$fieldDef['options'] = $f['options'];
}
if (isset($f['show'])) {
$fieldDef['show'] = $f['show'];
}
$fields[$fName] = $fieldDef;
if (isset($f['default'])) {
$defaults[$fName] = $f['default'];
}
}
$tabs[] = [
'title' => $tabTitle,
'fields' => $tabFieldNames,
];
}
$tabs[] = '${builder.advanced}';
return [
'@import' => './element.php',
'name' => $itemName,
'title' => $itemTitle,
'width' => 500,
'defaults' => $defaults,
'templates' => [
'render' => './templates/template.php',
],
'fields' => $fields,
'fieldset' => [
'default' => [
'type' => 'tabs',
'fields' => $tabs,
],
],
];
}
/**
* Hydrates an intelligent domain pipeline payload into a complete YOOtheme builder layout tree.
*/
public function hydrateLayout(array $payload, array $overrides = []): array
{
$parentProps = array_merge(
$this->extractParentProps($payload),
$overrides['props'] ?? []
);
$children = [];
if ($this->isContainer()) {
$rawItems = $payload['items'] ?? $payload['records'] ?? $payload['agenda'] ?? [];
foreach ($rawItems as $rawItem) {
$children[] = [
'type' => $this->itemName(),
'props' => $this->extractItemProps($rawItem),
];
}
}
return [
'type' => 'layout',
'version' => '5.0.0',
'children' => [
[
'type' => 'section',
'props' => [
'style' => 'default',
'width' => 'default',
'padding' => 'default',
],
'children' => [
[
'type' => 'row',
'children' => [
[
'type' => 'column',
'props' => ['image_position' => 'center-center'],
'children' => [
[
'type' => $this->name(),
'props' => $parentProps,
'children' => $children,
],
],
],
],
],
],
],
],
];
}
private function extractParentProps(array $payload): array
{
$props = [];
foreach ($this->spec['tabs'] ?? [] as $tab) {
foreach ($tab['fields'] ?? [] as $f) {
$k = $f['name'];
if (array_key_exists($k, $payload)) {
$props[$k] = $payload[$k];
} elseif (isset($f['default'])) {
$props[$k] = $f['default'];
}
}
}
return $props;
}
private function extractItemProps(array $rawItem): array
{
$props = [];
$itemTabs = $this->spec['item']['tabs'] ?? [];
foreach ($itemTabs as $tab) {
foreach ($tab['fields'] ?? [] as $f) {
$k = $f['name'];
if (array_key_exists($k, $rawItem)) {
$props[$k] = $rawItem[$k];
} elseif (isset($f['default'])) {
$props[$k] = $f['default'];
}
}
}
return $props;
}
private function compileElementPhp(): string
{
return <<<'PHP'
<?php
namespace YOOtheme;
return [
'transforms' => [
'render' => function ($node) {
// Ensure child records are available
$node->children = $node->children ?? [];
return $node;
},
],
];
PHP;
}
private function compileItemPhp(): string
{
return <<<'PHP'
<?php
namespace YOOtheme;
return [
'transforms' => [
'render' => function ($node) {
return $node;
},
],
];
PHP;
}
private function compileParentTemplate(): string
{
return <<<'PHP'
<?php
defined('_JEXEC') or die;
$props = $props ?? [];
$children = $children ?? [];
$title = htmlspecialchars((string) ($props['title'] ?? ''), ENT_QUOTES, 'UTF-8');
$summary = htmlspecialchars((string) ($props['summary'] ?? ''), ENT_QUOTES, 'UTF-8');
$mediaUrl = (string) ($props['recording_url'] ?? $props['live_url'] ?? '');
$mediaMode = (string) ($props['media_mode'] ?? 'none');
$cardStyle = (string) ($props['card_style'] ?? 'default');
$headingLevel = (string) ($props['heading_level'] ?? 'h2');
$layout = (string) ($props['layout'] ?? 'workspace');
$status = (string) ($props['meeting_status'] ?? $props['status'] ?? '');
$badgeClass = 'uk-badge';
if (in_array($status, ['live', 'active'])) {
$badgeClass .= ' uk-label-danger';
} elseif (in_array($status, ['complete', 'approved'])) {
$badgeClass .= ' uk-label-success';
}
$elementId = 'wf-' . substr(md5($title . ($props['meeting_key'] ?? '')), 0, 8);
?>
<div id="<?= $elementId ?>" class="fyoo-workflow-container uk-margin-medium-bottom">
<div class="uk-card uk-card-<?= htmlspecialchars($cardStyle, ENT_QUOTES, 'UTF-8') ?> uk-card-body uk-card-hover uk-border-rounded">
<!-- Workflow Header -->
<div class="uk-flex uk-flex-between uk-flex-middle uk-flex-wrap uk-margin-small-bottom">
<div>
<?php if (!empty($props['body_name'])): ?>
<span class="uk-text-meta uk-text-uppercase uk-text-bold"><?= htmlspecialchars($props['body_name'], ENT_QUOTES, 'UTF-8') ?></span>
<?php endif; ?>
<<?= $headingLevel ?> class="uk-margin-remove-top uk-margin-small-bottom"><?= $title ?></<?= $headingLevel ?>>
</div>
<?php if ($status !== ''): ?>
<div>
<span class="<?= $badgeClass ?>"><?= htmlspecialchars(strtoupper($status), ENT_QUOTES, 'UTF-8') ?></span>
</div>
<?php endif; ?>
</div>
<?php if ($summary !== ''): ?>
<p class="uk-text-muted uk-margin-small-bottom"><?= $summary ?></p>
<?php endif; ?>
<!-- Split Workspace Layout -->
<div class="uk-grid-match uk-grid-medium uk-margin-medium-top" uk-grid>
<!-- Media & Details Column -->
<?php if ($mediaMode !== 'none' && $mediaUrl !== ''): ?>
<div class="uk-width-2-5@m">
<div class="uk-card uk-card-default uk-card-body uk-padding-small uk-border-rounded" uk-sticky="offset: 24; bottom: #<?= $elementId ?>;">
<div class="uk-cover-container uk-height-medium uk-border-rounded">
<?php if (str_contains($mediaUrl, 'youtube.com') || str_contains($mediaUrl, 'youtu.be') || str_contains($mediaUrl, 'vimeo.com')): ?>
<iframe src="<?= htmlspecialchars($mediaUrl, ENT_QUOTES, 'UTF-8') ?>" uk-cover uk-responsive frameborder="0" allowfullscreen></iframe>
<?php else: ?>
<video id="<?= $elementId ?>-player" class="uk-width-1-1 uk-border-rounded" controls <?= !empty($props['poster']) ? 'poster="' . htmlspecialchars($props['poster'], ENT_QUOTES, 'UTF-8') . '"' : '' ?>>
<source src="<?= htmlspecialchars($mediaUrl, ENT_QUOTES, 'UTF-8') ?>" type="video/mp4">
<?php if (!empty($props['captions_url'])): ?>
<track kind="captions" src="<?= htmlspecialchars($props['captions_url'], ENT_QUOTES, 'UTF-8') ?>" srclang="en" label="English" default>
<?php endif; ?>
</video>
<?php endif; ?>
</div>
<?php if (!empty($props['starts_at']) || !empty($props['location'])): ?>
<ul class="uk-list uk-list-divider uk-margin-small-top uk-text-small">
<?php if (!empty($props['starts_at'])): ?>
<li><span uk-icon="icon: calendar; ratio: 0.8" class="uk-margin-small-right"></span><?= htmlspecialchars($props['starts_at'], ENT_QUOTES, 'UTF-8') ?></li>
<?php endif; ?>
<?php if (!empty($props['location'])): ?>
<li><span uk-icon="icon: location; ratio: 0.8" class="uk-margin-small-right"></span><?= htmlspecialchars($props['location'], ENT_QUOTES, 'UTF-8') ?></li>
<?php endif; ?>
</ul>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
<!-- Records & Pipeline Items Column -->
<div class="<?= ($mediaMode !== 'none' && $mediaUrl !== '') ? 'uk-width-3-5@m' : 'uk-width-1-1' ?>">
<?php if (!empty($props['show_filters']) && !empty($children)): ?>
<div uk-filter="target: .js-workflow-filter">
<ul class="uk-subnav uk-subnav-pill uk-margin-small-bottom">
<li class="uk-active" uk-filter-control><a href="#">All</a></li>
<li uk-filter-control="filter: [data-kind='agenda']"><a href="#">Agenda</a></li>
<li uk-filter-control="filter: [data-kind='resource']"><a href="#">Documents</a></li>
<li uk-filter-control="filter: [data-kind='chapter']"><a href="#">Chapters</a></li>
</ul>
<ul class="js-workflow-filter uk-list uk-list-medium">
<?php foreach ($children as $child): ?>
<li><?= $builder->render($child) ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php else: ?>
<ul class="uk-list uk-list-medium">
<?php foreach ($children as $child): ?>
<li><?= $builder->render($child) ?></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</div>
</div>
</div>
</div>
PHP;
}
private function compileItemTemplate(): string
{
return <<<'PHP'
<?php
defined('_JEXEC') or die;
$props = $props ?? [];
$type = (string) ($props['record_type'] ?? 'agenda');
$title = htmlspecialchars((string) ($props['title'] ?? ''), ENT_QUOTES, 'UTF-8');
$desc = htmlspecialchars((string) ($props['description'] ?? ''), ENT_QUOTES, 'UTF-8');
$num = htmlspecialchars((string) ($props['agenda_number'] ?? ''), ENT_QUOTES, 'UTF-8');
$outcome = (string) ($props['outcome'] ?? '');
$link = (string) ($props['link'] ?? '');
$startTime = (string) ($props['start_time'] ?? '');
$speaker = (string) ($props['speaker'] ?? $props['presenter'] ?? '');
?>
<div class="uk-card uk-card-default uk-card-small uk-card-body uk-border-rounded uk-box-shadow-hover-small" data-kind="<?= htmlspecialchars($type, ENT_QUOTES, 'UTF-8') ?>">
<div class="uk-flex uk-flex-between uk-flex-middle">
<div class="uk-flex uk-flex-middle">
<?php if ($num !== ''): ?>
<span class="uk-badge uk-margin-small-right"><?= $num ?></span>
<?php endif; ?>
<h4 class="uk-margin-remove uk-text-bold"><?= $title ?></h4>
</div>
<div>
<?php if ($outcome !== ''): ?>
<span class="uk-label uk-label-<?= ($outcome === 'approved' ? 'success' : ($outcome === 'tabled' ? 'warning' : 'muted')) ?>"><?= htmlspecialchars(strtoupper($outcome), ENT_QUOTES, 'UTF-8') ?></span>
<?php endif; ?>
<?php if ($startTime !== ''): ?>
<span class="uk-label uk-label-primary"><span uk-icon="icon: play; ratio: 0.6"></span> <?= htmlspecialchars($startTime, ENT_QUOTES, 'UTF-8') ?></span>
<?php endif; ?>
</div>
</div>
<?php if ($desc !== ''): ?>
<p class="uk-margin-small-top uk-margin-remove-bottom uk-text-small uk-text-muted"><?= $desc ?></p>
<?php endif; ?>
<?php if ($speaker !== ''): ?>
<div class="uk-text-meta uk-margin-xsmall-top"><span uk-icon="icon: user; ratio: 0.7"></span> <?= htmlspecialchars($speaker, ENT_QUOTES, 'UTF-8') ?></div>
<?php endif; ?>
<?php if ($link !== ''): ?>
<div class="uk-margin-small-top">
<a href="<?= htmlspecialchars($link, ENT_QUOTES, 'UTF-8') ?>" class="uk-button uk-button-default uk-button-small" target="_blank">
<span uk-icon="icon: download; ratio: 0.7"></span> View Resource
</a>
</div>
<?php endif; ?>
</div>
PHP;
}
private function compileParentContent(): string
{
return <<<'PHP'
<?php
defined('_JEXEC') or die;
$props = is_array($props ?? null) ? $props : [];
$children = is_array($children ?? null) ? $children : [];
$title = is_string($props['title'] ?? null) ? trim($props['title']) : '';
?>
<?php if ($title !== '') : ?>
<p><strong><?= htmlspecialchars($title, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?></strong></p>
<?php endif; ?>
<?php if ($children !== []) : ?>
<ol>
<?php foreach ($children as $child) : ?>
<li><?= $builder->render($child, ['element' => $props]) ?></li>
<?php endforeach; ?>
</ol>
<?php endif; ?>
PHP;
}
private function compileItemContent(): string
{
return <<<'PHP'
<?php
defined('_JEXEC') or die;
$props = is_array($props ?? null) ? $props : [];
$title = is_string($props['title'] ?? null) ? trim($props['title']) : '';
$desc = is_string($props['description'] ?? null) ? trim($props['description']) : '';
?>
<?php if ($title !== '') : ?>
<strong><?= htmlspecialchars($title, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?></strong>
<?php endif; ?>
<?php if ($desc !== '') : ?>
<p><?= htmlspecialchars($desc, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?></p>
<?php endif; ?>
PHP;
}
private function mapFieldType(string $type): string
{
return match ($type) {
'text' => 'text',
'textarea' => 'textarea',
'editor' => 'editor',
'select' => 'select',
'checkbox' => 'checkbox',
'color' => 'color',
'image' => 'image',
'video' => 'video',
'number' => 'number',
'range' => 'range',
default => 'text',
};
}
private function defaultIconSvg(): string
{
return '<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><circle fill="none" stroke="#000" stroke-width="1.1" cx="10" cy="10" r="9"/><polygon points="8,5 14,10 8,15" fill="#000"/></svg>';
}
private function defaultIconSmallSvg(): string
{
return '<svg width="14" height="14" viewBox="0 0 14 14" xmlns="http://www.w3.org/2000/svg"><circle fill="none" stroke="#000" stroke-width="1" cx="7" cy="7" r="6"/></svg>';
}
}