unique id
* 'html' => html code
* 'callback' => callback to call to process form
* )
* - render_form() return the form html code
* - get_values() return an array of field_id => value pairs
* - get_fields() return the array of defined fields
* - process() process the form callback
*
* @package magicoli/opensim-helpers
**/
require_once( dirname(__DIR__) . '/classes/init.php' );
class OpenSim_Form {
private $form_id;
private $fields = array();
private $callback;
private static $forms = array();
private $errors;
private $html;
private $completed;
private $multistep;
public $tasks;
public function __construct($args = array(), $step = 0) {
if( is_string( $args )) {
// If only a string is passed, consider it as form_id for a pending form
$args = array( 'form_id' => $args );
}
if (!is_array($args)) {
error_log(__METHOD__ . ' invalid argument type ' . gettype($args));
throw new InvalidArgumentException('Invalid argument type: ' . gettype($args));
}
$args = OpenSim::parse_args($args, array(
'form_id' => uniqid('form-', true),
'fields' => array(),
'callback' => null,
'multistep' => false,
));
$this->form_id = $args['form_id'];
$this->multistep = $args['multistep'];
$this->steps = $args['steps'] ?? false;
$this->callback = $args['callback'];
$this->add_fields($args['fields']);
$this->completed = $_SESSION[$this->form_id]['completed'] ?? $this->completed;
self::$forms[$this->form_id] = $this;
// $this->refresh_steps();
}
/**
* Static factory method to register an instance of OpenSim_Form.
* Handles exceptions internally to avoid requiring try-catch blocks during instantiation.
*
* @param array $args Arguments for form initialization.
* @param int $step Optional step parameter.
* @return OpenSim_Form|false Returns an instance of OpenSim_Form on success, or false on failure.
*/
public static function register($args = array(), $step = 0) {
try {
return new self($args, $step);
} catch (InvalidArgumentException $e) {
error_log($e->getMessage());
OpenSim::notify_error($e->getMessage() );
return false;
}
}
public function add_steps($steps) {
if( empty( $steps )) {
return false;
}
$this->steps = OpenSim::parse_args( $steps, $this->steps );
}
public function add_fields( $fields) {
if( empty( $fields )) {
return;
}
$this->fields = OpenSim::parse_args( $fields, $this->fields );
$this->get_next_step();
}
public function task_error( $field_id, $message, $type = 'warning' ) {
$this->errors[$field_id] = array(
'message' => $message ?? 'Error',
'type' => empty( $type ) ? 'warning' : $type,
);
}
public function render_form() {
if( ! empty( $this->html )) {
return $this->html;
}
if( $this->multistep ) {
$this->refresh_steps();
$fields = $this->get_step_fields() ?? array();
if( ! is_array( $fields )) {
error_log( __METHOD__ . ' wrong field format ' );
return false;
}
} else {
$fields = $this->fields;
}
$form_id = $this->form_id;
// Update Reset button to remain a submit button and bypass validation
$reset_button = ( empty($_SESSION[$form_id])) ? '' : sprintf(
'',
_( 'Reset Form' )
);
if( empty( $fields ) && empty( $reset_button ) ) {
error_log( __METHOD__ . ' called with empty fields' );
return false;
}
$html = '';
$fields = empty($fields) ? array() : $fields;
foreach ( $fields as $field => $data ) {
$add_class = '';
$add_attrs = '';
if( ! empty( $this->errors[$field] ) ) {
$field_error = $this->errors[$field];
$data['help'] = OpenSim::error_html( $field_error, 'warning' ) . ( $data['help'] ?? '' );
if( $field_error['type'] == 'danger' ) {
$add_class .= ' is-invalid';
}
}
if( $data['type'] == 'plaintext' ) {
$data['type'] = 'text';
$add_class .= ' form-control-plaintext';
}
if( isset( $data['readonly'] ) && $data['readonly'] ) {
$add_class .= ' text-muted';
$add_attrs .= ' readonly';
}
$add_attrs .= isset( $data['disabled'] ) && $data['disabled'] ? ' disabled' : '';
$add_attrs .= isset( $data['required'] ) && $data['required'] ? ' required' : '';
// $placeholder = isset( $data['placeholder'] ) ? $data['placeholder'] : '';
$html .= sprintf(
'
%s
',
$field,
$data['label'],
$data['type'],
$field,
$add_attrs . $add_class,
$_POST[$field] ?? $data['value'] ?? $data['default'] ?? '',
$data['placeholder'] ?? '',
$add_attrs,
$data['help'] ?? ''
);
}
$submit = empty( $html ) ? '' : sprintf(
'',
_('Submit')
);
$buttons = sprintf(
''
. ''
. '%s
',
$this->form_id,
$this->next_step_key ?? '',
$reset_button . $submit
);
$html = sprintf(
'',
$this->form_id,
$_SERVER['PHP_SELF'],
$html . $buttons
);
OpenSim::enqueue_script( 'form', 'js/form.js' );
OpenSim::enqueue_style( 'form', 'css/form.css' );
$this->html = $html;
return $html;
}
public function process() {
if( empty( $_POST )) {
// Only init values if form is not submitted
return $this->get_values();
}
if (is_callable($this->callback)) {
return call_user_func($this->callback, $this->get_values());
} else {
if (is_array($this->callback)) {
$callback_name = get_class($this->callback[0]) . '::' . $this->callback[1];
} else {
$callback_name = $this->callback;
}
error_log( $callback_name . ' is not callable from ' . __METHOD__ );
return false;
}
}
/**
* Get values from fields definition and post.
*
* TODO: make sure values are not replaced with post values before this step,
* although it doesn't hurt with the current usage, it might be useful to
* compare old and new value in the process() method called later.
*/
public function get_values() {
$form_id = $this->form_id;
$values = array();
if( $this->multistep ) {
$fields = $this->get_step_fields() ?? array();
if( ! is_array( $fields )) {
error_log( __METHOD__ . ' wrong field format ' );
return false;
}
} else {
$fields = $this->fields;
}
// error_log( 'Fields: ' . print_r( $fields, true ) );
foreach( $fields as $key => $field ) {
$values[$key] = $_POST[$key] ?? $_SESSION[$form_id][$key] ?? $field['value'] ?? null;
}
return $values;
}
private function get_step_fields() {
if( ! isset( $this->next_step_key ) || ! isset( $this->fields[$this->next_step_key] ) ) {
return array();
}
return $this->fields[$this->next_step_key];
}
// Get defined fields
public function get_fields() {
return $this->fields;
}
public function get_form( $form_id ) {
if( empty( $form_id )) {
return false;
}
return isset( self::$forms[$form_id] ) ? self::$forms[$form_id] : false;
}
public function get_forms() {
return self::$forms ?? false;
}
public function get_next_step() {
if( empty( $this->steps )) {
return false;
}
$steps = $this->steps;
$current_step = array_search($this->completed, array_keys($steps));
if( empty( $this->completed ) ) {
$next_step_key = key($steps);
$next_step_label = $steps[$next_step_key];
} else {
$next_step_key = array_keys($steps)[$current_step + 1] ?? null;
if( empty($steps[$next_step_key])) {
$next_step_key='completed';
$next_step_label = _('Completed');
} else {
$next_step_label = $steps[$next_step_key] ?? null;
}
}
// $this->next_step = $next_step_label;
$this->next_step_key = $next_step_key;
$this->tasks = $this->steps[$next_step_key]['tasks'] ?? false;
return $this->next_step_key;
}
/**
* Use the value of $this->complete as last completed step, get the next step and
* build a navigation html.
*/
private function refresh_steps() {
if( empty( $this->steps )) {
return false;
}
$steps = $this->steps;
if( ! empty($_POST['form_id']) ) {
$form_id = $_POST['form_id'];
$form = self::$forms[$form_id];
if( $form ) {
$form->process();
} else {
error_log( __METHOD__ . ' Form ' . $form_id . ' is not registered' );
return false;
}
}
$next_step_key = $this->get_next_step();
// Set progression table
$progress = array();
$status = 'completed';
foreach( $steps as $key => $step ) {
if( $key == $next_step_key ) {
$progress[$key] = 'active';
$status = '';
} else {
$progress[$key] = $status;
}
}
$this->progression = $progress;
}
/**
* Build HTML progress bar with bootstrap classes
*/
public function render_progress() {
$this->refresh_steps();
if( empty( $this->steps )) {
return false;
}
$steps = $this->steps;
$progress = $this->progression;
$status = 'completed';
$html = '';
foreach( $steps as $key => $step ) {
$status = $progress[$key] ?? 'disabled';
$label = $steps[$key]['label'] ?? $key;
$style = '';
switch( $status ) {
case 'completed':
$status .= ' text-success';
$label .= ' ✓';
// $style = 'style="color:green"';
break;
case 'active':
$status = 'active bg-secondary';
$style = 'style="font-weight:bold"';
break;
}
$status = empty($status) ? 'disabled' : $status;
// if( $key == $next_step_key ) {
// $progress[$key] = 'active';
// $status = '';
// } else {
// $progress[$key] = $status;
// }
// '%s
',
$html .= sprintf( '-
%s
',
$status,
$style,
$label,
);
}
$html .= '
';
return $html;
}
public function validate_file( $field_id, $file_path, $strict = false ) {
if( empty( $file_path ) ) {
if( $strict ) {
$message = sprintf( _('File %s is required'), $field_id );
$this->task_error( $field_id, $message, 'danger' );
return false;
}
// if not strict, allow it to be empty
return true;
}
if( ! file_exists( $file_path )) {
$message = sprintf( _('File %s not found'), $file_path );
$this->task_error( $field_id, $message, 'danger' );
return false;
}
return true;
}
/**
* Make sure the file is a valid .ini file
*/
public function is_valid_ini_file( $field_id, $file_path, $strict = false ) {
if( ! $this->validate_file( $field_id, $file_path, true )) {
$this->task_error( $field_id, _('File not found'), 'danger' );
return false;
}
// If strict, use parse_ini_file
if( $strict ) {
$ini = parse_ini_file( $file_path );
if( empty( $ini )) {
$message = sprintf( _('File %s does not comply with .ini standards.'), $file_path );
$this->task_error( $field_id, $message, 'danger' );
// throw new Exception( $message );
return false;
}
return true;
}
// If not strict, a light check is enough
//
// OpenSim uses some non-standard formatting that are not supported by parse_ini_file.
// Ignore comments and empty lines, then make sure the remaining contains
// only key = value pairs or [sections]
$ini = file_get_contents( $file_path );
$lines = explode( "\n", $ini );
$valid = true;
// Filter out comments and empty lines
$lines = array_filter( array_map( 'trim', $lines ), function( $line ) {
return ! empty( $line ) && ! preg_match( '/^\s*;/', $line );
});
// Filter out valid lines, leaving only invalid ones
$valid = array_filter( array_map( function( $line ) {
return preg_match( '/^\[.*\]$|.*=.*$/', $line ) ? false : $line;
}, $lines ));
if( ! empty( $valid )) {
$message = sprintf( _('File %s is not a valid .ini file'), $file_path );
error_log( $message . ', found invalid lines: ' . print_r( $valid, true ) );
$this->task_error( $field_id, $message, 'danger' );
return false;
}
return true;
}
/**
* Make sure the file is a valid Robust.ini file
*/
public function is_robust_ini_file( $field_id, $file_path ) {
if( ! $this->is_valid_ini_file( $field_id, $file_path )) {
throw new Exception( _('Not a valid ini file') );
// return false;
}
$required_sections = array(
'DatabaseService',
'GridInfoService',
'LoginService',
);
// Check if all required sections are present, with array_map (without parse_ini_file)
$ini = file_get_contents( $file_path );
$lines = explode( "\n", $ini );
$sections = array_map( function( $line ) {
if( preg_match( '/^\[(.*)\]\s*$/', $line, $matches )) {
return $matches[1];
}
return false;
}, $lines );
$missing = array_diff( $required_sections, $sections );
if( ! empty( $missing )) {
$message = sprintf( _('Not a valid Robust config file.'), $file_path, implode( ', ', $missing ));
$this->task_error( $field_id, $message, 'danger' );
$message = sprintf( _('%s is missing required sections: %s'), $file_path, '- ' . implode( '
- ', $missing ) . '
' );
throw new Exception( $message );
}
return true;
}
public function complete( $step ) {
$this->completed = $step;
$_SESSION[$this->form_id]['completed'] = $step;
$this->refresh_steps();
}
}