Add a Cacti-native form API and migrate the Site editor (#7368)

This commit is contained in:
Thomas Vincent authored and GitHub committed 2026-08-22 19:06:10 -07:00
1 parent ff475bef6d
commit 7159ce5115
7 files changed
+341 -6

No files matched your search

+66
View File
@@ -0,0 +1,66 @@
name: Form Coverage
on:
push:
branches: [develop]
paths:
- '.github/workflows/form-coverage.yml'
- '.github/actions/setup-php-composer/**'
- 'lib/CactiForm.php'
- 'phpunit-form-coverage.xml'
- 'sites.php'
- 'tests/Unit/CactiFormTest.php'
- 'tests/integration/CactiFormIntegrationTest.php'
pull_request:
branches: [develop]
paths:
- '.github/workflows/form-coverage.yml'
- '.github/actions/setup-php-composer/**'
- 'lib/CactiForm.php'
- 'phpunit-form-coverage.xml'
- 'sites.php'
- 'tests/Unit/CactiFormTest.php'
- 'tests/integration/CactiFormIntegrationTest.php'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: form-coverage-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
COMPOSER_ALLOW_SUPERUSER: 1
jobs:
coverage:
name: Form API 100% coverage
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
persist-credentials: false
- name: Setup locked PHP 8.1 dependencies
uses: ./.github/actions/setup-php-composer
with:
php-version: '8.1'
coverage: xdebug
extensions: intl, mysql, gd, ldap, gmp, xml, curl, json, mbstring
dependency-mode: locked-form-coverage
install-args: --prefer-dist --no-progress --no-interaction
- name: Enforce line coverage
env:
XDEBUG_MODE: coverage
run: |
set -euo pipefail
include/vendor/bin/pest \
--configuration=phpunit-form-coverage.xml \
--coverage \
--coverage-text \
--min=100
+1
View File
@@ -318,6 +318,7 @@ Cacti CHANGELOG
-feature: Extract remote agent reverse DNS forward-confirmation into the remote_agent_fcrdns_confirmed() helper
-feature#7190: Replace PHPMailer with Symfony Mailer for native mail, sendmail, and SMTP (including OAuth2)
-feature#7252: Add output-context helpers and a safe local URL builder
-feature#7368: Add a Cacti-native form API and migrate the Site editor
-feature#7370: Allow plugins to register process tables for the technical support view
-feature#7372: Add installer domain foundation types and lifecycle tests
+125
View File
@@ -0,0 +1,125 @@
<?php
/*
+-------------------------------------------------------------------------+
| Copyright (C) 2004-2026 The Cacti Group |
| |
| This program is free software; you can redistribute it and/or |
| modify it under the terms of the GNU General Public License |
| as published by the Free Software Foundation; either version 2 |
| of the License, or (at your option) any later version. |
| |
| This program is distributed in the hope that it will be useful, |
| but WITHOUT ANY WARRANTY; without even the implied warranty of |
| MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| GNU General Public License for more details. |
+-------------------------------------------------------------------------+
| Cacti: The Complete RRDtool-based Graphing Solution |
+-------------------------------------------------------------------------+
*/
/**
* Immutable entry point for Cacti edit forms.
*
* The field schema intentionally remains compatible with draw_edit_form().
* Core and plugins get a Cacti-owned contract while existing forms can
* migrate incrementally without changing markup or theme integration.
*/
final class CactiForm {
/** @var array<string, mixed> */
private array $config = [];
/** @var array<string, array<string, mixed>> */
private array $fields;
private Closure $renderer;
private Closure $hydrator;
/**
* @param array<string, array<string, mixed>> $fields
*/
public function __construct(array $fields, ?callable $renderer = null, ?callable $hydrator = null) {
$this->fields = self::validateFields($fields);
$this->renderer = Closure::fromCallable($renderer ?? static function (array $definition) : void {
draw_edit_form($definition);
});
$this->hydrator = Closure::fromCallable($hydrator ?? static function (array $fields, array $values) : array {
return inject_form_variables($fields, $values);
});
}
/**
* @param array<string, mixed> $values
*/
public function withValues(array $values) : self {
$form = clone $this;
$form->fields = self::validateFields(($this->hydrator)($this->fields, $values));
return $form;
}
public function withoutFormTag() : self {
return $this->withConfig('no_form_tag', true);
}
public function postTo(string $action) : self {
if ($action === '') {
throw new InvalidArgumentException('A form action cannot be empty.');
}
return $this->withConfig('post_to', $action);
}
public function named(string $name) : self {
if ($name === '') {
throw new InvalidArgumentException('A form name cannot be empty.');
}
return $this->withConfig('form_name', $name);
}
public function multipart() : self {
return $this->withConfig('enctype', 'multipart/form-data');
}
/**
* @return array{config: array<string, mixed>, fields: array<string, array<string, mixed>>}
*/
public function definition() : array {
return [
'config' => $this->config,
'fields' => $this->fields,
];
}
public function render() : void {
($this->renderer)($this->definition());
}
private function withConfig(string $name, mixed $value) : self {
$form = clone $this;
$form->config[$name] = $value;
return $form;
}
/**
* @param array<mixed> $fields
*
* @return array<string, array<string, mixed>>
*/
private static function validateFields(array $fields) : array {
foreach ($fields as $name => $field) {
if (!is_string($name) || $name === '') {
throw new InvalidArgumentException('Cacti form fields must have non-empty string names.');
}
if (!is_array($field) || !isset($field['method']) || !is_string($field['method']) || $field['method'] === '') {
throw new InvalidArgumentException(sprintf("Cacti form field '%s' must define a method.", $name));
}
}
return $fields;
}
}
+17
View File
@@ -0,0 +1,17 @@
<phpunit bootstrap="tests/bootstrap-unit.php" beStrictAboutOutputDuringTests="false">
<php>
<ini name="error_reporting" value="-1"/>
<env name="CACTI_TEST_BOOTSTRAP" value="1"/>
</php>
<source>
<include>
<file>lib/CactiForm.php</file>
</include>
</source>
<testsuites>
<testsuite name="Form coverage">
<file>tests/Unit/CactiFormTest.php</file>
<file>tests/integration/CactiFormIntegrationTest.php</file>
</testsuite>
</testsuites>
</phpunit>
+5 -6
View File
@@ -23,6 +23,7 @@
*/
require('./include/auth.php');
require_once CACTI_PATH_LIBRARY . '/CactiForm.php';
$actions = [
1 => __('Delete'),
@@ -475,12 +476,10 @@ function site_edit() : void {
html_start_box($header_label, '100%', true, 3, 'center', '');
draw_edit_form(
[
'config' => ['no_form_tag' => true],
'fields' => inject_form_variables($fields_site_edit, (isset($site) ? $site : []))
]
);
(new CactiForm($fields_site_edit))
->withValues($site ?? [])
->withoutFormTag()
->render();
html_end_box(true, true);
+87
View File
@@ -0,0 +1,87 @@
<?php
/*
+-------------------------------------------------------------------------+
| Copyright (C) 2004-2026 The Cacti Group |
| |
| This program is free software; you can redistribute it and/or |
| modify it under the terms of the GNU General Public License |
| as published by the Free Software Foundation; either version 2 |
| of the License, or (at your option) any later version. |
| |
| This program is distributed in the hope that it will be useful, |
| but WITHOUT ANY WARRANTY; without even the implied warranty of |
| MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| GNU General Public License for more details. |
+-------------------------------------------------------------------------+
| Cacti: The Complete RRDtool-based Graphing Solution |
+-------------------------------------------------------------------------+
*/
require_once CACTI_PATH_LIBRARY . '/CactiForm.php';
function cacti_form_test_fields() : array {
return [
'name' => [
'method' => 'textbox',
'friendly_name' => 'Name',
'value' => '|arg1:name|',
],
];
}
test('CactiForm builds the legacy renderer contract immutably', function () {
$form = (new CactiForm(cacti_form_test_fields()))
->withoutFormTag()
->postTo('sites.php')
->named('site')
->multipart();
expect($form->definition())->toBe([
'config' => [
'no_form_tag' => true,
'post_to' => 'sites.php',
'form_name' => 'site',
'enctype' => 'multipart/form-data',
],
'fields' => cacti_form_test_fields(),
]);
});
test('CactiForm hydrates values through an injected Cacti adapter', function () {
$hydrator = function (array $fields, array $values) : array {
$fields['name']['value'] = $values['name'];
return $fields;
};
$original = new CactiForm(cacti_form_test_fields(), hydrator: $hydrator);
$hydrated = $original->withValues(['name' => 'Datacenter']);
expect($original->definition()['fields']['name']['value'])->toBe('|arg1:name|')
->and($hydrated->definition()['fields']['name']['value'])->toBe('Datacenter');
});
test('CactiForm delegates rendering through its Cacti contract', function () {
$rendered = null;
$form = new CactiForm(cacti_form_test_fields(), function (array $definition) use (&$rendered) : void {
$rendered = $definition;
});
$form->withoutFormTag()->render();
expect($rendered)->toBe([
'config' => ['no_form_tag' => true],
'fields' => cacti_form_test_fields(),
]);
});
test('CactiForm rejects malformed definitions and empty identifiers', function () {
expect(fn () => new CactiForm(['name' => []]))
->toThrow(InvalidArgumentException::class, "field 'name' must define a method")
->and(fn () => new CactiForm([0 => ['method' => 'textbox']]))
->toThrow(InvalidArgumentException::class, 'non-empty string names')
->and(fn () => (new CactiForm(cacti_form_test_fields()))->postTo(''))
->toThrow(InvalidArgumentException::class, 'action cannot be empty')
->and(fn () => (new CactiForm(cacti_form_test_fields()))->named(''))
->toThrow(InvalidArgumentException::class, 'name cannot be empty');
});
@@ -0,0 +1,40 @@
<?php
/*
+-------------------------------------------------------------------------+
| Copyright (C) 2004-2026 The Cacti Group |
| |
| This program is free software; you can redistribute it and/or |
| modify it under the terms of the GNU General Public License |
| as published by the Free Software Foundation; either version 2 |
| of the License, or (at your option) any later version. |
| |
| This program is distributed in the hope that it will be useful, |
| but WITHOUT ANY WARRANTY; without even the implied warranty of |
| MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| GNU General Public License for more details. |
+-------------------------------------------------------------------------+
| Cacti: The Complete RRDtool-based Graphing Solution |
+-------------------------------------------------------------------------+
*/
require_once CACTI_PATH_LIBRARY . '/CactiForm.php';
it('hydrates and renders through the real Cacti form helpers', function () {
$form = new CactiForm([
'name' => [
'method' => 'textbox',
'friendly_name' => 'Name',
'description' => 'Site name',
'value' => '|arg1:name|',
'max_length' => '100',
],
]);
ob_start();
$form->withValues(['name' => 'Datacenter'])->withoutFormTag()->render();
$html = ob_get_clean();
expect($html)->toContain("name='name'")
->and($html)->toContain("value='Datacenter'")
->and($html)->not->toContain("<form class='cactiForm'");
});