mirror of
https://github.com/Cacti/cacti.git
synced 2026-10-23 03:27:02 +09:00
Add a Cacti-native form API and migrate the Site editor (#7368)
This commit is contained in:
1 parent
ff475bef6d
commit
7159ce5115
7 files changed
+341
-6
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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'");
|
||||
});
|
||||
Reference in new issue
Block a user