diff --git a/ChangeLog b/ChangeLog index 267e673e67a..51fad26a29f 100644 --- a/ChangeLog +++ b/ChangeLog @@ -176,6 +176,7 @@ For developers: --------------- NEW: Use another hash algorithm v2 based on sha256/hmac for immutable logs (#37725) NEW: Replace MyObject MyModule occurrences (#38370) +NEW: ModuleBuilder - Allow selecting which optional tabs (contact, note, document, agenda) are generated for an object (#38570) NEW: add hooks in reception card (#37214) NEW: add new hook in BonPrelevement::EnregDestinataireSEPA() function (#37419) NEW: Add hook selectForFormsListUrl in Form::selectForForms (#37447) diff --git a/htdocs/core/lib/modulebuilder.lib.php b/htdocs/core/lib/modulebuilder.lib.php index df6ddce09b6..40a4ad7ddb8 100644 --- a/htdocs/core/lib/modulebuilder.lib.php +++ b/htdocs/core/lib/modulebuilder.lib.php @@ -1465,3 +1465,85 @@ function countItemsInDirectory($path, $type = 1) } return $count; } + +/** + * Return the map of optional tabs that can be generated for a ModuleBuilder object. + * The CARD tab is always generated and is therefore not listed here. + * HISTORY is an alias of AGENDA (object event history is the agenda tab in Dolibarr). + * + * @return array Map: tab key => metadata + */ +function getModuleBuilderObjectTabs() +{ + return array( + 'contact' => array('file' => 'myobject_contact.php', 'var' => 'showtabofpagecontact', 'marker' => 'CONTACT', 'label' => 'Contacts'), + 'note' => array('file' => 'myobject_note.php', 'var' => 'showtabofpagenote', 'marker' => 'NOTE', 'label' => 'Notes'), + 'document' => array('file' => 'myobject_document.php', 'var' => 'showtabofpagedocument', 'marker' => 'DOCUMENT', 'label' => 'Documents'), + 'agenda' => array('file' => 'myobject_agenda.php', 'var' => 'showtabofpageagenda', 'marker' => 'AGENDA', 'label' => 'Events'), + ); +} + +/** + * Filter a list of requested tab keys against the known optional tabs map. + * Protects against injection of unknown keys, removes duplicates, normalizes order. + * + * @param string[] $requested Raw tab keys requested by the user (e.g. from GETPOST array) + * @param array $map Map from getModuleBuilderObjectTabs() + * @return string[] Sanitized list of valid tab keys, in map order + */ +function filterEnabledTabs($requested, $map) +{ + $valid = array(); + if (!is_array($requested) || empty($requested)) { + return $valid; + } + foreach (array_keys($map) as $tabkey) { + if (in_array($tabkey, $requested, true)) { + $valid[] = $tabkey; + } + } + return $valid; +} + +/** + * Apply substitutions to a module descriptor file while preserving the MODULEBUILDER comment markers. + * Markers such as "BEGIN MODULEBUILDER LEFTMENU MYOBJECT" must keep their MYOBJECT/MYMODULE placeholder + * so that generating subsequent objects can still locate them (see checkExistComment()). A blanket + * substitution would rewrite them to the first object name and break the generation of further objects. + * + * @param string $file Path to the module descriptor file + * @param array $arrayreplacement Substitution map (search => replace), applied as literal strings + * @return int 1 on success, -1 on read/write error + */ +function dolReplaceInFilePreservingModuleBuilderMarkers($file, $arrayreplacement) +{ + if (!file_exists($file)) { + return -1; + } + $content = file_get_contents($file); + if ($content === false) { + return -1; + } + + // Hide every "/* BEGIN|END MODULEBUILDER ... */" marker behind a sentinel before substituting + $foundmarkers = array(); + preg_match_all('/\/\*\s*(?:BEGIN|END) MODULEBUILDER [^*]*\*\//', $content, $foundmarkers); + $sentinels = array(); + foreach (array_values(array_unique($foundmarkers[0])) as $index => $marker) { + $key = "\0MODULEBUILDERMARKER".$index."\0"; + $sentinels[$key] = $marker; + $content = str_replace($marker, $key, $content); + } + + $content = str_replace(array_keys($arrayreplacement), array_values($arrayreplacement), $content); + + // Restore the protected markers untouched + if (!empty($sentinels)) { + $content = strtr($content, $sentinels); + } + + if (file_put_contents($file, $content) === false) { + return -1; + } + return 1; +} diff --git a/htdocs/langs/en_US/modulebuilder.lang b/htdocs/langs/en_US/modulebuilder.lang index ba852c68cf3..01143a4afe3 100644 --- a/htdocs/langs/en_US/modulebuilder.lang +++ b/htdocs/langs/en_US/modulebuilder.lang @@ -172,6 +172,9 @@ GeneratePermissions=I want to manage permissions on this object GeneratePermissionsHelp=If you check this, some code will be added to manage permissions to read, write and delete record of the objects NoGenerateLines=I don't want to manage lines on this object NoGenerateLinesHelp=If you check this, some code will be removed to manage lines of the objects +EnabledTabsForObject=Tabs to generate for this object +EnabledTabsForObjectHelp=Only the selected tabs will generate their page file and tab link. The card tab is always generated. History is the agenda tab. +WarningTabSelectionOnRegeneration=This object already exists. Tab selection changes are applied on a best-effort basis on regeneration: already generated files are not overwritten or deleted, and a previously removed tab block cannot be re-injected automatically. PermissionDeletedSuccesfuly=Permission has been successfully removed PermissionUpdatedSuccesfuly=Permission has been successfully updated PermissionAddedSuccesfuly=Permission has been successfully added diff --git a/htdocs/langs/fr_FR/modulebuilder.lang b/htdocs/langs/fr_FR/modulebuilder.lang index d6dc5ce6a7a..431d663d408 100644 --- a/htdocs/langs/fr_FR/modulebuilder.lang +++ b/htdocs/langs/fr_FR/modulebuilder.lang @@ -170,6 +170,9 @@ DefinePropertiesFromExistingTableDesc=Si une table dans la base de données (pou DefinePropertiesFromExistingTableDesc2=Laisser vide si la table n'existe pas encore. Le générateur de code utilisera différents types de champs pour créer un exemple de table que vous pourrez modifier ultérieurement. GeneratePermissions=Je souhaite gérer les permissions sur cet objet GeneratePermissionsHelp=Si vous cochez ceci, du code sera ajouté pour gérer les permissions de lecture, d'écriture et de suppression des enregistrements des objets. +EnabledTabsForObject=Onglets à générer pour cet objet +EnabledTabsForObjectHelp=Seuls les onglets sélectionnés généreront leur fichier de page et leur lien d'onglet. L'onglet fiche est toujours généré. L'historique correspond à l'onglet agenda. +WarningTabSelectionOnRegeneration=Cet objet existe déjà. Les changements de sélection d'onglets sont appliqués au mieux lors d'une régénération : les fichiers déjà générés ne sont ni écrasés ni supprimés, et un bloc d'onglet précédemment retiré ne peut pas être réinjecté automatiquement. PermissionDeletedSuccesfuly=Les permissions ont été retirées avec succès PermissionUpdatedSuccesfuly=Les permissions ont été mises à jour avec succès PermissionAddedSuccesfuly=Les permissions ont été ajoutées avec succès diff --git a/htdocs/modulebuilder/index.php b/htdocs/modulebuilder/index.php index 334c7eba8ae..b3c05587af3 100644 --- a/htdocs/modulebuilder/index.php +++ b/htdocs/modulebuilder/index.php @@ -1151,6 +1151,10 @@ if ($dirins && $action == 'initobject' && $module && $objectname) { // Test on $srcdir = DOL_DOCUMENT_ROOT.'/modulebuilder/template'; $destdir = $dirins.'/'.strtolower($module); + // Optional tabs selected by user, and detection of an already generated object (for idempotence warning) + $enabledtabs = filterEnabledTabs(GETPOST('enabledtab', 'array'), getModuleBuilderObjectTabs()); + $objectalreadyexists = dol_is_file($destdir.'/class/'.strtolower($objectname).'.class.php'); + // The dir was not created by init dol_mkdir($destdir.'/class'); dol_mkdir($destdir.'/img'); @@ -1469,6 +1473,13 @@ if ($dirins && $action == 'initobject' && $module && $objectname) { // Test on $filetogenerate[$templateFile] = $ncObj->applyToFilename($templateFile); } + // Exclude tab page files for tabs not selected by user + foreach (getModuleBuilderObjectTabs() as $tabkey => $tabinfo) { + if (!in_array($tabkey, $enabledtabs, true)) { + unset($filetogenerate[$tabinfo['file']]); + } + } + if (GETPOST('includerefgeneration', 'aZ09')) { dol_mkdir($destdir.'/core/modules/'.strtolower($module)); @@ -1727,13 +1738,50 @@ if ($dirins && $action == 'initobject' && $module && $objectname) { // Test on // Pattern to remove everything between the tags $pattern = '/\/\/BEGIN MODULEBUILDER LINES.*?\/\/END MODULEBUILDER LINES\s*/s'; foreach ($TFilePaths as $filePath) { - if (! removePatternFromFile($filePath, $pattern)) { + // Skip files that were not generated (e.g. the API class when API generation is disabled); + // a missing optional file must not abort the whole object generation. + if (file_exists($filePath) && !removePatternFromFile($filePath, $pattern)) { $error++; } } } } + // Apply object tab selection on the generated lib file: + // selected tabs -> hardcode the show flag to 1 (visible without extra config) ; unselected -> remove flag declaration and tab block + if (!$error) { + $libdestfile = $destdir.'/'.$ncObj->applyToFilename('lib/mymodule_myobject.lib.php'); + foreach (getModuleBuilderObjectTabs() as $tabkey => $tabinfo) { + $marker = $tabinfo['marker']; + if (in_array($tabkey, $enabledtabs, true)) { + $arrayreplacement = array( + '/\$'.$tabinfo['var'].' = getDolGlobalInt\([^;]*\);/' => '$'.$tabinfo['var'].' = 1;' + ); + if (dolReplaceInFile($libdestfile, $arrayreplacement, '', '0', 0, 1) < 0) { + $error++; + dol_syslog("modulebuilder: failed to activate tab flag '".$tabkey."' in ".$libdestfile, LOG_ERR); + } + } else { + if (!removePatternFromFile($libdestfile, '/\h*\/\/ BEGIN MODULEBUILDER TABFLAG '.$marker.'.*?\/\/ END MODULEBUILDER TABFLAG '.$marker.'\s*/s') + || !removePatternFromFile($libdestfile, '/\h*\/\/ BEGIN MODULEBUILDER TAB '.$marker.'.*?\/\/ END MODULEBUILDER TAB '.$marker.'\s*/s')) { + $error++; + dol_syslog("modulebuilder: failed to purge tab '".$tabkey."' in ".$libdestfile, LOG_ERR); + } + } + } + // Agenda has an extra event widget on the card page: purge it too to avoid a dead link when the agenda tab is excluded + if (!$error && !in_array('agenda', $enabledtabs, true)) { + $carddestfile = $destdir.'/'.$ncObj->applyToFilename('myobject_card.php'); + if (!removePatternFromFile($carddestfile, '/\h*\/\/ BEGIN MODULEBUILDER TAB AGENDA.*?\/\/ END MODULEBUILDER TAB AGENDA\s*/s')) { + $error++; + dol_syslog("modulebuilder: failed to purge agenda widget in ".$carddestfile, LOG_ERR); + } + } + if ($objectalreadyexists) { + setEventMessages($langs->trans("WarningTabSelectionOnRegeneration"), null, 'warnings'); + } + } + if (!$error) { // Edit PHP files to make replacement foreach ($filetogenerate as $destfile) { @@ -1752,7 +1800,12 @@ if ($dirins && $action == 'initobject' && $module && $objectname) { // Test on ] ); - $result = dolReplaceInFile($phpfileval['fullname'], $arrayreplacement); // @phpstan-ignore-line + if (basename($phpfileval['fullname']) === 'mod'.$module.'.class.php') { + // Module descriptor: substitute content but keep the persistent MODULEBUILDER markers intact + $result = dolReplaceInFilePreservingModuleBuilderMarkers($phpfileval['fullname'], $arrayreplacement); + } else { + $result = dolReplaceInFile($phpfileval['fullname'], $arrayreplacement); // @phpstan-ignore-line + } //var_dump($result); if ($result < 0) { setEventMessages($langs->trans("ErrorFailToMakeReplacementInto", $phpfileval['fullname']), null, 'errors'); @@ -1778,8 +1831,15 @@ if ($dirins && $action == 'initobject' && $module && $objectname) { // Test on ] ); $allModulePhpFiles = dol_dir_list($destdir, 'files', 1, '\.php$'); + // The module descriptor must NOT go through the blanket substitution: it keeps persistent + // MYOBJECT/MYMODULE markers (TOPMENU/LEFTMENU) reused when generating subsequent objects, and its + // own placeholders are already resolved by initmodule and the dedicated menu/permission blocks. + $moduledescriptorbasename = 'mod'.$module.'.class.php'; if (is_array($allModulePhpFiles) && !empty($allModulePhpFiles)) { foreach ($allModulePhpFiles as $phpFileval) { + if (basename($phpFileval['fullname']) === $moduledescriptorbasename) { + continue; + } $result = dolReplaceInFile($phpFileval['fullname'], $moduleReplacementAll); if ($result < 0) { setEventMessages($langs->trans("ErrorFailToMakeReplacementInto", $phpFileval['fullname']), null, 'warnings'); @@ -4234,6 +4294,9 @@ if ($module == 'initmodule') { print ''; print ''; + // Tabs selected by default = all optional tabs; reflect posted state on redisplay + $enabledtabsdefault = GETPOSTISSET('enabledtab') ? GETPOST('enabledtab', 'array') : array_keys(getModuleBuilderObjectTabs()); + print ''.$langs->trans("EnterNameOfObjectDesc").'

'; print '
'; @@ -4273,6 +4336,13 @@ if ($module == 'initmodule') { print '
'; print '
'; print '
'; + print '
'.$form->textwithpicto($langs->trans("EnabledTabsForObject"), $langs->trans("EnabledTabsForObjectHelp")).'
'; + foreach (getModuleBuilderObjectTabs() as $tabkey => $tabinfo) { + $checked = in_array($tabkey, $enabledtabsdefault, true) ? ' checked' : ''; + print ' '; + print '   '; + } + print '
'; print '
'; print ''; print '
'; diff --git a/htdocs/modulebuilder/template/lib/mymodule_myobject.lib.php b/htdocs/modulebuilder/template/lib/mymodule_myobject.lib.php index 62178c3f0c3..9597b160498 100644 --- a/htdocs/modulebuilder/template/lib/mymodule_myobject.lib.php +++ b/htdocs/modulebuilder/template/lib/mymodule_myobject.lib.php @@ -34,10 +34,18 @@ function myobjectPrepareHead($object) $langs->load("mymodule@mymodule"); + // BEGIN MODULEBUILDER TABFLAG CONTACT $showtabofpagecontact = getDolGlobalInt('MAIN_MYMODULE_SHOW_PAGE_OF_CONTACT'); + // END MODULEBUILDER TABFLAG CONTACT + // BEGIN MODULEBUILDER TABFLAG NOTE $showtabofpagenote = getDolGlobalInt('MAIN_MYMODULE_SHOW_PAGE_OF_NOTE'); + // END MODULEBUILDER TABFLAG NOTE + // BEGIN MODULEBUILDER TABFLAG DOCUMENT $showtabofpagedocument = getDolGlobalInt('MAIN_MYMODULE_SHOW_PAGE_OF_DOCUMENT'); + // END MODULEBUILDER TABFLAG DOCUMENT + // BEGIN MODULEBUILDER TABFLAG AGENDA $showtabofpageagenda = getDolGlobalInt('MAIN_MYMODULE_SHOW_PAGE_OF_AGENDA'); + // END MODULEBUILDER TABFLAG AGENDA $h = 0; $head = array(); @@ -47,13 +55,16 @@ function myobjectPrepareHead($object) $head[$h][2] = 'card'; $h++; + // BEGIN MODULEBUILDER TAB CONTACT if ($showtabofpagecontact) { $head[$h][0] = dolBuildUrl(dol_buildpath("/mymodule/myobject_contact.php", 1), ['id' => $object->id]); $head[$h][1] = $langs->trans("Contacts"); $head[$h][2] = 'contact'; $h++; } + // END MODULEBUILDER TAB CONTACT + // BEGIN MODULEBUILDER TAB NOTE if ($showtabofpagenote) { if (isset($object->fields['note_public']) || isset($object->fields['note_private'])) { $nbNote = 0; @@ -72,7 +83,9 @@ function myobjectPrepareHead($object) $h++; } } + // END MODULEBUILDER TAB NOTE + // BEGIN MODULEBUILDER TAB DOCUMENT if ($showtabofpagedocument) { require_once DOL_DOCUMENT_ROOT.'/core/lib/files.lib.php'; require_once DOL_DOCUMENT_ROOT.'/core/class/link.class.php'; @@ -87,13 +100,16 @@ function myobjectPrepareHead($object) $head[$h][2] = 'document'; $h++; } + // END MODULEBUILDER TAB DOCUMENT + // BEGIN MODULEBUILDER TAB AGENDA if ($showtabofpageagenda) { $head[$h][0] = dolBuildUrl(dol_buildpath("/mymodule/myobject_agenda.php", 1), ['id' => $object->id]); $head[$h][1] = $langs->trans("Events"); $head[$h][2] = 'agenda'; $h++; } + // END MODULEBUILDER TAB AGENDA // Show more tabs from modules // Entries must be declared in modules descriptor with line diff --git a/htdocs/modulebuilder/template/myobject_card.php b/htdocs/modulebuilder/template/myobject_card.php index 05abb7f419f..f3b1b0c7d0c 100644 --- a/htdocs/modulebuilder/template/myobject_card.php +++ b/htdocs/modulebuilder/template/myobject_card.php @@ -637,6 +637,7 @@ if ($object->id > 0 && (empty($action) || ($action != 'edit' && $action != 'crea print '
'; + // BEGIN MODULEBUILDER TAB AGENDA $MAXEVENT = 10; $morehtmlcenter = dolGetButtonTitle($langs->trans('SeeAll'), '', 'fa fa-bars imgforviewmode', dol_buildpath('/mymodule/myobject_agenda.php', 1).'?id='.$object->id); @@ -649,6 +650,7 @@ if ($object->id > 0 && (empty($action) || ($action != 'edit' && $action != 'crea $formactions = new FormActions($db); $somethingshown = $formactions->showactions($object, $object->element.'@'.$object->module, (is_object($object->thirdparty) ? $object->thirdparty->id : 0), 1, '', $MAXEVENT, '', $morehtmlcenter); } + // END MODULEBUILDER TAB AGENDA print '
'; } diff --git a/test/phpunit/ModuleBuilderLibTest.php b/test/phpunit/ModuleBuilderLibTest.php new file mode 100644 index 00000000000..f88a72a8f97 --- /dev/null +++ b/test/phpunit/ModuleBuilderLibTest.php @@ -0,0 +1,79 @@ + + * + * 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 3 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. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +/** + * \file test/phpunit/ModuleBuilderLibTest.php + * \ingroup test + * \brief PHPUnit test for modulebuilder.lib.php tab selection helpers + * \remarks To run this script as CLI: phpunit filename.php + */ + +global $conf,$user,$langs,$db; +require_once dirname(__FILE__).'/../../htdocs/master.inc.php'; +require_once dirname(__FILE__).'/../../htdocs/core/lib/modulebuilder.lib.php'; +require_once dirname(__FILE__).'/CommonClassTest.class.php'; + +/** + * Class for PHPUnit tests + * + * @backupGlobals disabled + * @backupStaticAttributes enabled + * @remarks backupGlobals must be disabled to have db,conf,user and lang not erased. + * @phan-file-suppress PhanUndeclaredClass + * @phan-file-suppress PhanUndeclaredExtendedClass + * @phan-file-suppress PhanUndeclaredMethod + * @phan-file-suppress PhanTypeMismatchArgumentProbablyReal + */ +class ModuleBuilderLibTest extends CommonClassTest +{ + /** + * testGetModuleBuilderObjectTabs + * + * @return void + */ + public function testGetModuleBuilderObjectTabs() + { + $map = getModuleBuilderObjectTabs(); + $this->assertSame(array('contact', 'note', 'document', 'agenda'), array_keys($map)); + $this->assertSame('myobject_contact.php', $map['contact']['file']); + $this->assertSame('showtabofpageagenda', $map['agenda']['var']); + $this->assertSame('DOCUMENT', $map['document']['marker']); + } + + /** + * testFilterEnabledTabs + * + * @return void + */ + public function testFilterEnabledTabs() + { + $map = getModuleBuilderObjectTabs(); + + // Nominal: returns requested keys in map order + $this->assertSame(array('contact', 'agenda'), filterEnabledTabs(array('agenda', 'contact'), $map)); + + // Unknown key is rejected + $this->assertSame(array('contact'), filterEnabledTabs(array('contact', 'evil'), $map)); + + // Empty / non-array returns empty + $this->assertSame(array(), filterEnabledTabs(array(), $map)); + $this->assertSame(array(), filterEnabledTabs('', $map)); + + // Duplicates collapsed + $this->assertSame(array('note'), filterEnabledTabs(array('note', 'note'), $map)); + } +}