Add Get started, a guided walk through what is left to set up after installing (cron, email, backups, billing, support, the team, starter content - each pack or picked item by item - and a first client), each step a short form of its own with Skip for now and ticked off from the settings themselves, shown to the first admin to sign in on a new install and as a Finish setting up card on the dashboard until done, skipped or dismissed, and in Admin > Get started any time; database 3.4.7 adds settings.config_getting_started (on for a new install, off for an updated one), config_getting_started_skipped and config_getting_started_opened_at; the starter content loaders take the items to load, the mail server check moves to settingsMailServerCheck() for Admin > Mail and the Email step, app.js only starts DataTables where it is loaded, and setup's finish step points at Get started

This commit is contained in:
johnnyq
2026-10-25 14:46:02 -04:00
parent 58e502ad38
commit 6d29583cef
6 changed files with 305 additions and 52 deletions

View File

@@ -3,8 +3,11 @@
/*
* Get started - Starter content: the packs Maintenance > Starter Content has,
* each with how much it would add, ticked to add (getting_started_content).
* Only what isn't here already is added, so ticking one that is changes
* nothing.
* Pick which, under each, lists what it would add - every item ticked, so the
* whole pack goes in unless some are unticked (picked[pack] says the list was
* there, items[pack][] what is ticked in it; js/getting_started.js keeps the
* pack's tick and count in step). Only what isn't here already is listed or
* added.
*/
defined('FROM_GETTING_STARTED') || die("Direct file access is not allowed");
@@ -13,7 +16,6 @@ defined('FROM_STARTER_CONTENT') || define('FROM_STARTER_CONTENT', true);
require_once dirname(__DIR__) . '/post/starter_content_model.php';
$gs_packs = starterContentPacks();
$gs_pack_status = starterContentStatus($mysqli);
?>
<form action="post.php" method="post" data-gs-form>
@@ -21,19 +23,54 @@ $gs_pack_status = starterContentStatus($mysqli);
<div class="list-group mb-3 itflow-gs-packs" data-gs-packs>
<?php foreach ($gs_packs as $gs_key => $gs_pack) {
$gs_status = $gs_pack_status[$gs_key];
$gs_all_here = $gs_status['missing'] === 0; ?>
<label class="list-group-item d-flex gap-3 align-items-start<?= $gs_all_here ? ' is-here' : '' ?>" data-gs-pack="<?= $gs_key ?>">
<input class="form-check-input flex-shrink-0 mt-1" type="checkbox" name="packs[]" value="<?= $gs_key ?>"<?= $gs_all_here ? ' disabled' : ' checked' ?>>
<span class="itflow-gs-pack-icon" aria-hidden="true"><i class="fas fa-fw <?= escapeHtml($gs_pack['icon']) ?>"></i></span>
<span class="min-w-0 flex-grow-1">
<span class="d-flex flex-wrap align-items-baseline gap-2">
<span class="fw-semibold"><?= escapeHtml($gs_pack['label']) ?></span>
<span class="small text-body-secondary"><?= $gs_all_here ? '<i class="fas fa-check text-success me-1"></i>All ' . $gs_status['total'] . ' here' : ($gs_status['present'] ? $gs_status['missing'] . ' more to add' : $gs_status['total'] . ' to add') ?></span>
</span>
<span class="small text-body-secondary itflow-gs-pack-about" title="<?= escapeHtml($gs_pack['description']) ?>"><?= escapeHtml($gs_pack['description']) ?></span>
</span>
</label>
$gs_items = starterContentItems($mysqli, $gs_key);
$gs_total = starterContentTotal($gs_key);
$gs_missing = count($gs_items);
$gs_all_here = $gs_missing === 0;
$gs_count = $gs_all_here ? '' : ($gs_missing < $gs_total ? "$gs_missing more to add" : "$gs_missing to add"); ?>
<div class="list-group-item<?= $gs_all_here ? ' is-here' : '' ?>" data-gs-pack="<?= $gs_key ?>">
<div class="d-flex gap-3 align-items-start">
<input class="form-check-input flex-shrink-0 mt-1" type="checkbox" name="packs[]" value="<?= $gs_key ?>" id="gsPack_<?= $gs_key ?>"<?= $gs_all_here ? ' disabled' : ' checked' ?> data-gs-pack-check>
<span class="itflow-gs-pack-icon" aria-hidden="true"><i class="fas fa-fw <?= escapeHtml($gs_pack['icon']) ?>"></i></span>
<div class="min-w-0 flex-grow-1">
<label class="d-flex flex-wrap align-items-baseline gap-2 mb-0" for="gsPack_<?= $gs_key ?>">
<span class="fw-semibold"><?= escapeHtml($gs_pack['label']) ?></span>
<?php if ($gs_all_here) { ?>
<span class="small text-body-secondary"><i class="fas fa-check text-success me-1"></i>All <?= $gs_total ?> here</span>
<?php } else { ?>
<span class="small text-body-secondary" data-gs-pack-count data-gs-of="<?= $gs_missing ?>" data-gs-more="<?= $gs_missing < $gs_total ? 1 : 0 ?>"><?= $gs_count ?></span>
<?php } ?>
</label>
<div class="small text-body-secondary itflow-gs-pack-about" title="<?= escapeHtml($gs_pack['description']) ?>"><?= escapeHtml($gs_pack['description']) ?></div>
<?php if (!$gs_all_here) { ?>
<details class="itflow-gs-pick mt-1" data-gs-pick>
<summary class="small">Pick which</summary>
<input type="hidden" name="picked[<?= $gs_key ?>]" value="1">
<div class="itflow-gs-pick-tools small mt-2">
<button type="button" class="btn btn-link btn-sm p-0 align-baseline" data-gs-pick-all>All</button>
<span class="text-body-secondary" aria-hidden="true">·</span>
<button type="button" class="btn btn-link btn-sm p-0 align-baseline" data-gs-pick-none>None</button>
</div>
<div class="itflow-gs-pick-list mt-2">
<div class="itflow-gs-pick-cols">
<?php $gs_group = null;
foreach ($gs_items as $gs_n => $gs_item) {
if ($gs_item['group'] !== '' && $gs_item['group'] !== $gs_group) {
$gs_group = $gs_item['group']; ?>
<div class="itflow-gs-pick-group"><?= escapeHtml($gs_group) ?></div>
<?php } ?>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="items[<?= $gs_key ?>][]" value="<?= escapeHtml($gs_item['key']) ?>" id="gsItem_<?= $gs_key ?>_<?= $gs_n ?>" checked data-gs-item>
<label class="form-check-label" for="gsItem_<?= $gs_key ?>_<?= $gs_n ?>"><?= escapeHtml($gs_item['name']) ?></label>
</div>
<?php } ?>
</div>
</div>
</details>
<?php } ?>
</div>
</div>
</div>
<?php } ?>
</div>
<p class="small text-body-secondary">Nothing you already have is changed. Each pack can be added later, or brought up to date, in Maintenance &gt; Starter Content.</p>

View File

@@ -389,14 +389,25 @@ if (isset($_POST['getting_started_content'])) {
$added = 0;
$summary = [];
$none_ticked = 0;
foreach ($picked as $pack) {
$pack_added = starterContentLoad($mysqli, $pack);
// The items ticked under Pick which - the whole pack when its list wasn't sent
$only = isset($_POST['picked'][$pack]) ? array_map('strval', array_filter((array) ($_POST['items'][$pack] ?? []), 'is_string')) : null;
if ($only === []) {
$none_ticked++;
continue;
}
$pack_added = starterContentLoad($mysqli, $pack, false, $only);
if ($pack_added) {
$added += $pack_added;
$summary[] = "$pack_added " . $packs[$pack]['label'];
}
}
if (!$added && $none_ticked === count($picked)) {
flashAlert('Nothing is ticked under Pick which - tick what to add, or Skip for now', 'info');
redirect(gettingStartedUrl('content'));
}
if ($added) {
logAudit("Starter Content", "Create", "$session_name loaded starter content in Get started - " . escapeSql(implode(', ', $summary)));
flashAlert('Added <strong>' . $added . '</strong> things to start from: ' . escapeHtml(implode(', ', $summary)));

View File

@@ -159,33 +159,169 @@ function starterTicketTemplateId($mysqli, $name) {
// ------------------------------
// starterContentLoad
// Single entry point. $dry_run counts what would be added without touching
// anything, which is what the page uses to show the missing column.
// anything, which is what the page uses to show the missing column. $only
// limits it to some of the pack's items, by starterItemKey() - null is the
// whole pack (Get started's Pick which, admin/getting_started/content.php).
// Returns the number of top level rows added (or that would be added).
// ------------------------------
function starterContentLoad($mysqli, $pack, $dry_run = false) {
function starterContentLoad($mysqli, $pack, $dry_run = false, $only = null) {
if ($only !== null && !$only) {
return 0;
}
switch ($pack) {
case 'categories':
return starterLoadCategories($mysqli, $dry_run);
return starterLoadCategories($mysqli, $dry_run, $only);
case 'tags':
return starterLoadTags($mysqli, $dry_run);
return starterLoadTags($mysqli, $dry_run, $only);
case 'ticket_templates':
return starterLoadTicketTemplates($mysqli, $dry_run);
return starterLoadTicketTemplates($mysqli, $dry_run, $only);
case 'project_templates':
return starterLoadProjectTemplates($mysqli, $dry_run);
return starterLoadProjectTemplates($mysqli, $dry_run, $only);
case 'canned_responses':
return starterLoadCannedResponses($mysqli, $dry_run);
return starterLoadCannedResponses($mysqli, $dry_run, $only);
case 'vendor_templates':
return starterLoadVendorTemplates($mysqli, $dry_run);
return starterLoadVendorTemplates($mysqli, $dry_run, $only);
case 'document_templates':
return starterLoadDocumentTemplates($mysqli, $dry_run);
return starterLoadDocumentTemplates($mysqli, $dry_run, $only);
case 'products':
return starterLoadProducts($mysqli, $dry_run);
return starterLoadProducts($mysqli, $dry_run, $only);
case 'license_templates':
return starterLoadLicenseTemplates($mysqli, $dry_run);
return starterLoadLicenseTemplates($mysqli, $dry_run, $only);
}
return 0;
}
// ------------------------------
// starterContentRows
// A pack's items as this library has them, in order.
// ------------------------------
function starterContentRows($pack) {
switch ($pack) {
case 'categories':
return starterContentCategories();
case 'tags':
return starterContentTags();
case 'ticket_templates':
return starterContentTicketTemplates();
case 'project_templates':
return starterContentProjectTemplates();
case 'canned_responses':
return starterContentCannedResponses();
case 'vendor_templates':
return starterContentVendorTemplates();
case 'document_templates':
return starterContentDocumentTemplates();
case 'products':
return starterContentProducts();
case 'license_templates':
return starterContentLicenseTemplates();
}
return [];
}
// ------------------------------
// starterItemKey / starterItemName / starterItemGroup
// One item of a pack - what picks it out (a category or tag with its type,
// as two can share a name), what it is called, and what it is listed under:
// a category's type, a tag's kind, a product's category, or nothing.
// ------------------------------
function starterItemKey($pack, $row) {
switch ($pack) {
case 'categories':
return $row['type'] . '|' . $row['name'];
case 'tags':
return $row[0] . '|' . $row[1];
case 'products':
return (string) $row[0];
}
return (string) $row['name'];
}
function starterItemName($pack, $row) {
switch ($pack) {
case 'tags':
return (string) $row[1];
case 'products':
return (string) $row[0];
}
return (string) $row['name'];
}
function starterItemGroup($pack, $row) {
switch ($pack) {
case 'categories':
return (string) $row['type'];
case 'tags':
return function_exists('tagEntityByType') ? (string) (tagEntityByType($row[0])['title'] ?? '') : '';
case 'products':
return (string) $row[4];
}
return '';
}
// ------------------------------
// starterOnly
// The rows of a pack that $only names (starterItemKey()) - all of them when
// $only is null.
// ------------------------------
function starterOnly($pack, array $rows, $only) {
if ($only === null) {
return $rows;
}
$only = array_flip(array_map('strval', (array) $only));
return array_values(array_filter($rows, function ($row) use ($pack, $only) {
return isset($only[starterItemKey($pack, $row)]);
}));
}
// ------------------------------
// starterContentItems
// What loading a pack would add - its items not here yet, each as key, name
// and group - for picking which. Here is as each loader judges it: the same
// name (and type, for a category or tag), whatever its case.
// ------------------------------
function starterContentItems($mysqli, $pack) {
switch ($pack) {
case 'categories':
$existing = starterExistingNames($mysqli, 'categories', 'category_name', 'category_type');
$here = function ($row) use ($existing) {
return isset($existing[$row['type'] . '|' . mb_strtolower($row['name'])]);
};
break;
case 'tags':
$existing = starterExistingNames($mysqli, 'tags', 'tag_name', 'tag_type');
$here = function ($row) use ($existing) {
return isset($existing[$row[0] . '|' . mb_strtolower($row[1])]);
};
break;
case 'canned_responses':
$existing = [];
$sql = mysqli_query($mysqli, "SELECT canned_response_name FROM canned_responses WHERE canned_response_user_id = 0");
while ($row = mysqli_fetch_row($sql)) {
$existing[mb_strtolower($row[0])] = true;
}
break;
default:
$tables = ['ticket_templates' => ['ticket_templates', 'ticket_template_name'], 'project_templates' => ['project_templates', 'project_template_name'],
'vendor_templates' => ['vendor_templates', 'vendor_template_name'], 'document_templates' => ['document_templates', 'document_template_name'],
'products' => ['products', 'product_name'], 'license_templates' => ['software_templates', 'software_template_name']];
if (!isset($tables[$pack])) {
return [];
}
$existing = starterExistingNames($mysqli, $tables[$pack][0], $tables[$pack][1]);
}
$here = $here ?? function ($row) use ($pack, $existing) {
return isset($existing[mb_strtolower(starterItemName($pack, $row))]);
};
$items = [];
foreach (starterContentRows($pack) as $row) {
if (!$here($row)) {
$items[] = ['key' => starterItemKey($pack, $row), 'name' => starterItemName($pack, $row), 'group' => starterItemGroup($pack, $row)];
}
}
return $items;
}
// ------------------------------
// starterContentStatus
// Per pack totals for the page - how many the library holds, how many are
@@ -235,11 +371,11 @@ function starterContentTotal($pack) {
// ------------------------------
// starterLoadCategories
// ------------------------------
function starterLoadCategories($mysqli, $dry_run = false) {
function starterLoadCategories($mysqli, $dry_run = false, $only = null) {
$existing = starterExistingNames($mysqli, 'categories', 'category_name', 'category_type');
$added = 0;
foreach (starterContentCategories() as $category) {
foreach (starterOnly('categories', starterContentCategories(), $only) as $category) {
if (isset($existing[$category['type'] . '|' . mb_strtolower($category['name'])])) {
continue;
}
@@ -268,11 +404,11 @@ function starterLoadCategories($mysqli, $dry_run = false) {
// ------------------------------
// starterLoadTags
// ------------------------------
function starterLoadTags($mysqli, $dry_run = false) {
function starterLoadTags($mysqli, $dry_run = false, $only = null) {
$existing = starterExistingNames($mysqli, 'tags', 'tag_name', 'tag_type');
$added = 0;
foreach (starterContentTags() as $tag) {
foreach (starterOnly('tags', starterContentTags(), $only) as $tag) {
if (isset($existing[$tag[0] . '|' . mb_strtolower($tag[1])])) {
continue;
}
@@ -296,8 +432,8 @@ function starterLoadTags($mysqli, $dry_run = false) {
// A template is added whole or not at all - an existing name is left alone
// rather than having its task list topped up.
// ------------------------------
function starterLoadTicketTemplates($mysqli, $dry_run = false) {
$templates = ticketTemplatesImportRead(['format' => TICKET_TEMPLATE_FILE_FORMAT, 'version' => TICKET_TEMPLATE_FILE_VERSION, 'templates' => starterContentTicketTemplates()]);
function starterLoadTicketTemplates($mysqli, $dry_run = false, $only = null) {
$templates = ticketTemplatesImportRead(['format' => TICKET_TEMPLATE_FILE_FORMAT, 'version' => TICKET_TEMPLATE_FILE_VERSION, 'templates' => starterOnly('ticket_templates', starterContentTicketTemplates(), $only)]);
if ($dry_run) {
$existing = starterExistingNames($mysqli, 'ticket_templates', 'ticket_template_name');
return count(array_filter($templates, function ($template) use ($existing) {
@@ -324,8 +460,8 @@ function starterUpdateTicketTemplates() {
// Through the canned response import, as shared responses. One whose name a
// shared one here already has is left alone.
// ------------------------------
function starterLoadCannedResponses($mysqli, $dry_run = false) {
$responses = cannedResponsesImportRead(['format' => CANNED_RESPONSE_FILE_FORMAT, 'version' => CANNED_RESPONSE_FILE_VERSION, 'responses' => starterContentCannedResponses()]);
function starterLoadCannedResponses($mysqli, $dry_run = false, $only = null) {
$responses = cannedResponsesImportRead(['format' => CANNED_RESPONSE_FILE_FORMAT, 'version' => CANNED_RESPONSE_FILE_VERSION, 'responses' => starterOnly('canned_responses', starterContentCannedResponses(), $only)]);
if ($dry_run) {
$existing = [];
$sql = mysqli_query($mysqli, "SELECT canned_response_name FROM canned_responses WHERE canned_response_user_id = 0");
@@ -357,11 +493,11 @@ function starterUpdateCannedResponses() {
// skipped, so loading this pack before the ticket templates still works and
// simply produces a project template with fewer stages.
// ------------------------------
function starterLoadProjectTemplates($mysqli, $dry_run = false) {
function starterLoadProjectTemplates($mysqli, $dry_run = false, $only = null) {
$existing = starterExistingNames($mysqli, 'project_templates', 'project_template_name');
$added = 0;
foreach (starterContentProjectTemplates() as $project_template) {
foreach (starterOnly('project_templates', starterContentProjectTemplates(), $only) as $project_template) {
if (isset($existing[mb_strtolower($project_template['name'])])) {
continue;
}
@@ -397,8 +533,8 @@ function starterLoadProjectTemplates($mysqli, $dry_run = false) {
// Through the vendor template import. One whose name a template here already
// has, archived or not, is left alone.
// ------------------------------
function starterLoadVendorTemplates($mysqli, $dry_run = false) {
$templates = vendorTemplatesImportRead(['format' => VENDOR_TEMPLATE_FILE_FORMAT, 'version' => VENDOR_TEMPLATE_FILE_VERSION, 'templates' => starterContentVendorTemplates()]);
function starterLoadVendorTemplates($mysqli, $dry_run = false, $only = null) {
$templates = vendorTemplatesImportRead(['format' => VENDOR_TEMPLATE_FILE_FORMAT, 'version' => VENDOR_TEMPLATE_FILE_VERSION, 'templates' => starterOnly('vendor_templates', starterContentVendorTemplates(), $only)]);
if ($dry_run) {
$existing = starterExistingNames($mysqli, 'vendor_templates', 'vendor_template_name');
return count(array_filter($templates, function ($template) use ($existing) {
@@ -427,8 +563,8 @@ function starterUpdateVendorTemplates() {
// Through the document template import. One whose name a template here
// already has, archived or not, is left alone.
// ------------------------------
function starterLoadDocumentTemplates($mysqli, $dry_run = false) {
$templates = documentTemplatesImportRead(['format' => DOCUMENT_TEMPLATE_FILE_FORMAT, 'version' => DOCUMENT_TEMPLATE_FILE_VERSION, 'templates' => starterContentDocumentTemplates()]);
function starterLoadDocumentTemplates($mysqli, $dry_run = false, $only = null) {
$templates = documentTemplatesImportRead(['format' => DOCUMENT_TEMPLATE_FILE_FORMAT, 'version' => DOCUMENT_TEMPLATE_FILE_VERSION, 'templates' => starterOnly('document_templates', starterContentDocumentTemplates(), $only)]);
if ($dry_run) {
$existing = starterExistingNames($mysqli, 'document_templates', 'document_template_name');
return count(array_filter($templates, function ($template) use ($existing) {
@@ -457,8 +593,8 @@ function starterUpdateDocumentTemplates() {
// template of that name - added from here when there is none - and it is
// sold as the product of that name when there is one.
// ------------------------------
function starterLoadLicenseTemplates($mysqli, $dry_run = false) {
$templates = softwareTemplatesImportRead(['format' => SOFTWARE_TEMPLATE_FILE_FORMAT, 'version' => SOFTWARE_TEMPLATE_FILE_VERSION, 'templates' => starterContentLicenseTemplates()]);
function starterLoadLicenseTemplates($mysqli, $dry_run = false, $only = null) {
$templates = softwareTemplatesImportRead(['format' => SOFTWARE_TEMPLATE_FILE_FORMAT, 'version' => SOFTWARE_TEMPLATE_FILE_VERSION, 'templates' => starterOnly('license_templates', starterContentLicenseTemplates(), $only)]);
if ($dry_run) {
$existing = starterExistingNames($mysqli, 'software_templates', 'software_template_name');
return count(array_filter($templates, function ($template) use ($existing) {
@@ -487,14 +623,14 @@ function starterUpdateLicenseTemplates() {
// leaves the product uncategorised rather than blocking the load, so this pack
// works whether or not the categories pack has been loaded.
// ------------------------------
function starterLoadProducts($mysqli, $dry_run = false) {
function starterLoadProducts($mysqli, $dry_run = false, $only = null) {
global $session_company_currency;
$existing = starterExistingNames($mysqli, 'products', 'product_name');
$added = 0;
$category_ids = [];
foreach (starterContentProducts() as $product) {
foreach (starterOnly('products', starterContentProducts(), $only) as $product) {
if (isset($existing[mb_strtolower($product[0])])) {
continue;
}