diff --git a/CHANGELOG.md b/CHANGELOG.md index ff918f634..6147d4237 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1195,7 +1195,7 @@ If you are coming from 26.08 or earlier, the database update still has to be run - Billing: the hourly rate and net terms a new client starts with, a tax rate for new lines when there is no default (made and made the default), invoice and quote numbering, and - if you like - Stripe, with its keys put to Stripe before they are saved. - Support: business days and hours, the standard response times for each priority as the default SLAs (Urgent answered in 1 business hour and put right in 4, High 2 and 8, Medium 4 and 16, Low 8 and 40 - one with the same hours is used when there is one, and only priorities with none get them), ticket numbering and the client portal on or off. Open tickets are stamped afresh with the hours and response times saved. - Your team: everyone who signs in, and Add someone - a name, an email and a role. ITFlow makes their password: they are emailed their sign-in details, as Admin > Users does, or, with no email set up yet, it is shown to you once to give them. They change it once signed in. - - Starter content: the packs from Maintenance > Starter Content, each with how much it adds, ticked to add. Your first client: a name, website and contact, made as the Clients page makes one - or the demo data, to look around first. + - Starter content: the packs from Maintenance > Starter Content, each with how much it adds, ticked to add - and Pick which under each, listing what it would add to choose one by one: every item starts ticked, categories grouped by type, tags by what they tag and products by category, with All and None, and the pack's count says how many are picked. Your first client: a name, website and contact, made as the Clients page makes one - or the demo data, to look around first. - The dashboard shows Finish setting up, with how far it has got, a chip for each step and Carry on, to admins until every step is done or skipped. Its × stops it showing there. Admin > Get started opens it any time, with how many steps are done or skipped beside it, and a summary at the end lists every step with where it stands. - The setup wizard's last step says Get started comes next. @@ -2002,7 +2002,7 @@ If you are coming from 26.08 or earlier, the database update still has to be run - Contract prepaid hours: database 3.4.4 adds to `contract_templates` and `contracts` `{prefix}_monthly_hours` and `{prefix}_hours_alert` decimal(8,2) (0.00), and `contracts.contract_hours_alerted_at` datetime, plus two tables. `contract_blocks`: `contract_block_contract_id` (ON DELETE CASCADE), `_hours`, `_price` (NULL for none), `_bought`, `_expires` (NULL for never), `_month` (char(7), for a month's included hours - UNIQUE with the contract, so a month is made once), `_invoice_id`, `_note`, `_created_by`, `_created_at`. `contract_block_uses`: `contract_block_use_block_id` (ON DELETE CASCADE), `_ticket_id`, `_hours`, `_full` (on the use that covered the rest of a ticket), `_created_by`, `_created_at`; a use of 0 hours keeps a ticket that was put back out of the draw. New `functions/contract_hours.php`: `contractHoursReady()`, `contractHoursText()`, `contractHoursWords()`, `contractBlocks()`, `contractHoursSummary()`, `contractHoursLeftFor()`, `contractHasHours()`, `contractClientHours()`, `contractHoursEnsureMonthly()`, `contractHoursSync()` (after a save, Make active, Renew or a countersignature that makes a draft active), `contractHoursTicketUses()`, `contractHoursUses()`, `contractHoursDraw()` (one at a time per client, under `GET_LOCK`), `contractHoursDoneOn()`, `contractHoursRecheck()`, `contractHoursPutBack()`, `contractHoursAlertReset()`, `contractHoursAlertCheck()`, `contractHoursAlertSend()`, `contractHoursFromInput()`, `contractHoursAdd()` (the invoice through `billingInvoiceCreate()` and `billingItemSave()`), `contractHoursBlockDelete()`, `contractBlockRow()` and `contractHoursNightly()`. `billingUnbilledSql()` leaves out a ticket with a full use; `billingUnbilledTickets()` adds `worked` (the hours the time logged makes) and `prepaid`, and `hours` is now what is left after them; `billingUnbilledByClient()` adds `prepaid`. `contractFromInput()` takes `monthly_hours` and `hours_alert`, `contractTermsSetSql()` writes them once 3.4.4 has run, and `contractUpdate()` logs them and clears `contract_hours_alerted_at` when the level changes. `contractsNightly()` returns `drawn` too, logged by `cron/nightly_tasks.php`. `ticketDeleteForGood()` deletes the ticket's uses. `CONTRACT_PLACEHOLDERS` adds Monthly Hours, and `contractDocumentHtml()` adds an Included hours row only when there are some, so documents without them hash as before. `agent/post/contract.php` adds `add_contract_hours` and `draw_contract_hours` (POST, with a `contract_id`, a `client_id` or a `ticket_id`) and `delete_contract_block` and `put_back_contract_hours` (GET with the token), all with Sales Modify. New `agent/modals/contract/contract_hours_add.php`. `emailPreviewContractCatalog()` adds `contract_hours_low`. New CSS: `.itflow-contract-hours-left` and `.itflow-hours-bar` (a ``, as the portal's CSP allows no inline styles). - Contract billing and SLAs: database 3.4.5 adds `contracts.contract_recurring_invoice_id` int (0, keyed) and `contract_recurring_stopped_at` datetime, set when the contract pauses its recurring invoice so it does so once, and sets a rate of 0.00 on contracts and templates to NULL (`contractFromInput()` saves 0 as NULL too). New `functions/contract_billing.php`: `contractBillingReady()`, `contractTermsContracts()` (a client's contracts that can be in force, kept for the request), `contractInForceOn()`, `contractTermsOn()` (the one started last that sets a rate, net terms or a priority's SLA), `contractsInForce()`, `contractSlaFor()`, `contractAfterHours()` (from `getSlaSettings()` and `getBusinessHolidays()`), `contractTicketRate()` (source after_hours, contract, client or default, and a note), `contractClientRate()`, `contractNetTerms()`, `contractNetTermsToday()`, `contractRecurringRow()`, `contractForRecurring()`, `contractRecurringChoices()`, `contractRecurringShouldRun()`, `contractRecurringAmountText()`, `contractRecurringStart()` (skips missed runs with `recurringInvoiceCatchUp()`), `contractRecurringStop()`, `contractRecurringFollow()`, `contractRecurringLink()`, `contractRecurringUnlink()`, `contractRecurringFromInput()`, `contractRecurringFirstDate()`, `contractRecurringCreate()` and `contractRecurringNightly()`. `getTicketSlaId()` asks `contractSlaFor()` first, and takes `$with_contract` (false for the client's own - `contractSlaRows()` uses it). `billingUnbilledTickets()` prices each ticket with `contractTicketRate()` and adds `rate_source` and `rate_note`; `billingUnbilledByClient()` adds `rates`, `rate_note` and `rate_source`; `billingTimeInvoice()` with no price bills each ticket at its own rate, and `combine` makes one line per rate. `billingInvoiceCreate()`, `quoteConvert()`, `recurringInvoiceGenerate()`, New Invoice and Copy in `agent/post/invoice.php` take `contractNetTerms()`. `contractsNightly()` returns `paused` too, logged by `cron/nightly_tasks.php`. `agent/post/contract.php` adds `make_contract_recurring` and `link_contract_recurring` (POST) and `unlink_contract_recurring` (GET with the token), all Sales Modify, and Make active, Renew, Cancel and a countersignature that makes a draft active call `contractRecurringFollow()`; deleting a recurring invoice unlinks its contract. New `agent/modals/contract/contract_recurring.php`. The prepaid hours handlers pass the agent's name unescaped, as the history and logs escape it. - Client portal invites and sign-ups: database 3.4.6 adds `portal_invites` - `invite_kind` (invite or signup), `invite_email`, `invite_name`, `invite_billing`, `invite_technical`, `invite_token_hash` (the SHA-256 of the link's token), `invite_expires_at`, `invite_sent_at`, `invite_sent_count`, `invite_created_by` (an agent), `invite_created_by_contact_id` (a primary contact), `invite_created_at`, `invite_accepted_at`, `invite_contact_id`, `invite_cancelled_at` and `invite_client_id` (ON DELETE CASCADE), keyed on the client and address and on the contact - plus `clients.client_portal_signup` and `settings.config_client_portal_signup` tinyint(1) (0). New `functions/portal_invite.php`: `PORTAL_SIGNUP_LINK_HOURS` (24), `PORTAL_INVITE_MAX` (50), `PORTAL_SIGNUP_IP_LIMIT` (20), `portalInvitesReady()` (keeps every page and the nightly job as they were before 3.4.6 has run), `portalInviteParse()`, `portalInviteWho()` (new, contact, member or elsewhere), `portalInviteRow()`, `portalInviteBy()`, `portalInviteState()`, `portalInviteUrl()`, `portalInviteNewToken()`, `portalInviteByLink()` (hash_equals), `portalInviteMail()`, `portalInviteSend()`, `portalInviteResend()`, `portalInviteCancel()`, `portalInvitesWaiting()`, `portalInviteStateLabel()`, `portalContactJoined()`, `portalInviteAccept()` (spends the link with one guarded UPDATE first, so a second press or a race does nothing), `portalMailSignupJoined()`, `portalMailInviteAccepted()`, `portalSignupOn()`, `portalSignupDomains()`, `portalSignupClients()`, `portalSignupOpen()`, `portalSignupClientFor()`, `portalSignupRequest()` (logged as `Client Login` Sign Up Requested, Sign Up Sent and Sign Up Refused), `portalMailSignupExists()`, `portalSignupSet()` and `portalInvitesNightly()`, which `cron/nightly_tasks.php` runs and logs. `portalContactAccess()` and `portalSignInMethods()` move from `client/functions.php` to `functions/portal.php`, as cron and the agent side use them now. `agent/post/contact.php` adds `send_portal_invites` (POST) and `resend_portal_invite` and `cancel_portal_invite` (GET with the token), and `agent/post/client.php` `set_client_portal_signup` (POST), all Client Modify. `agent/modals/contact/contact_invite.php`, an unused stub (Welcome Letter, Big Wig) with no handler, is replaced by the Invite people window. `agent/contacts.php` adds `role=invites`. `client/post.php` adds `portal_invites_send`, `portal_invite_resend` and `portal_invite_cancel` (the primary contact's). New `client/invite.php`, `client/join.php` (`?invite=&token=`, Referrer-Policy no-referrer) and `client/signup.php` (a form token in the session and a hidden field robots fill in). `edit_client_portal_settings` saves `config_client_portal_signup` once 3.4.6 has run. `emailPreviewInviteCatalog()` adds `portal_join_invite`, `portal_join_invite_colleague`, `portal_join_accepted`, `portal_signup`, `portal_signup_exists` and `portal_signup_joined`. `auditLogActionStyle()` adds Invite and the Sign Up actions. New CSS: `.itflow-join-box`. -- Get started: database 3.4.7 adds `settings.config_getting_started` tinyint(1) (1 in db.sql, so a new install has it; the update sets it to 0 for one already running), `config_getting_started_skipped` varchar(255) (the steps skipped, comma separated) and `config_getting_started_opened_at` datetime (when it was first opened - it opens by itself on signing in only until then). New `functions/getting_started.php`: `GETTING_STARTED_STEPS`, `GETTING_STARTED_SLAS`, `gettingStartedReady()` (nothing shows before 3.4.7 has run), `gettingStartedState()`, `gettingStartedUrl()`, `gettingStartedCronCommand()`, `gettingStartedBackupJob()`, `gettingStartedSteps()` (each step's done, skipped and status, from the settings), `gettingStartedProgress()`, `gettingStartedAfter()`, `gettingStartedSkip()`, `gettingStartedOpened()`, `gettingStartedDismiss()`, `gettingStartedFirstVisit()` (login.php takes the first admin there, after MFA enforcement and not instead of a `last_visited` page), `gettingStartedCard()` (the dashboard's card), `gettingStartedBackupSave()` (the backup job's row in `cron_jobs`, seeded from the registry when the dispatcher hasn't yet), `gettingStartedApplySlas()` and `gettingStartedDaysText()`. New `admin/getting_started.php` (a page of its own, in the setup wizard's frame; `?step=`, and `?cron_status=1` answers in JSON), its steps in `admin/getting_started/` and `admin/post/getting_started.php`: `getting_started_cron_on`, `getting_started_mail` (continue or test), `getting_started_backups`, `getting_started_billing`, `getting_started_support`, `getting_started_team`, `getting_started_content`, `getting_started_skip` and `getting_started_dismiss`. The first client posts `add_client` to `agent/post.php` and the demo data `load_demo_data`, both of which return to the step. Admin > Mail's check of a sending or receiving server moves from a closure in `admin/post/settings_mail.php` to `settingsMailServerCheck($server, $before, $post)` in `functions/settings.php`, which both use. New `js/getting_started.js` (`data-gs-reveal`, `data-gs-show`, `data-gs-cron-poll`), and `js/app.js` only starts DataTables where it is loaded, so setup and Get started, which load app.js on its own, no longer log an error to the console. New CSS: `.itflow-gs-*`. +- Get started: database 3.4.7 adds `settings.config_getting_started` tinyint(1) (1 in db.sql, so a new install has it; the update sets it to 0 for one already running), `config_getting_started_skipped` varchar(255) (the steps skipped, comma separated) and `config_getting_started_opened_at` datetime (when it was first opened - it opens by itself on signing in only until then). New `functions/getting_started.php`: `GETTING_STARTED_STEPS`, `GETTING_STARTED_SLAS`, `gettingStartedReady()` (nothing shows before 3.4.7 has run), `gettingStartedState()`, `gettingStartedUrl()`, `gettingStartedCronCommand()`, `gettingStartedBackupJob()`, `gettingStartedSteps()` (each step's done, skipped and status, from the settings), `gettingStartedProgress()`, `gettingStartedAfter()`, `gettingStartedSkip()`, `gettingStartedOpened()`, `gettingStartedDismiss()`, `gettingStartedFirstVisit()` (login.php takes the first admin there, after MFA enforcement and not instead of a `last_visited` page), `gettingStartedCard()` (the dashboard's card), `gettingStartedBackupSave()` (the backup job's row in `cron_jobs`, seeded from the registry when the dispatcher hasn't yet), `gettingStartedApplySlas()` and `gettingStartedDaysText()`. New `admin/getting_started.php` (a page of its own, in the setup wizard's frame; `?step=`, and `?cron_status=1` answers in JSON), its steps in `admin/getting_started/` and `admin/post/getting_started.php`: `getting_started_cron_on`, `getting_started_mail` (continue or test), `getting_started_backups`, `getting_started_billing`, `getting_started_support`, `getting_started_team`, `getting_started_content`, `getting_started_skip` and `getting_started_dismiss`. The first client posts `add_client` to `agent/post.php` and the demo data `load_demo_data`, both of which return to the step. Admin > Mail's check of a sending or receiving server moves from a closure in `admin/post/settings_mail.php` to `settingsMailServerCheck($server, $before, $post)` in `functions/settings.php`, which both use. In `admin/post/starter_content_model.php`, `starterContentLoad()` and each `starterLoad*()` take `$only`, the items to load by `starterItemKey()` (null for the whole pack, as Maintenance > Starter Content loads it), and new `starterContentRows()`, `starterItemKey()` (a category's or tag's type with its name), `starterItemName()`, `starterItemGroup()`, `starterOnly()` and `starterContentItems()` (what a pack would add, judged here as each loader judges it); `getting_started_content` takes `picked[pack]` and `items[pack][]`. New `js/getting_started.js` (`data-gs-reveal`, `data-gs-show`, `data-gs-cron-poll`, `data-gs-pack`), and `js/app.js` only starts DataTables where it is loaded, so setup and Get started, which load app.js on its own, no longer log an error to the console. New CSS: `.itflow-gs-*`. ### Library Updates diff --git a/admin/getting_started/content.php b/admin/getting_started/content.php index 3a5b5d9cd..840a290e9 100644 --- a/admin/getting_started/content.php +++ b/admin/getting_started/content.php @@ -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); ?>
@@ -21,19 +23,54 @@ $gs_pack_status = starterContentStatus($mysqli);
$gs_pack) { - $gs_status = $gs_pack_status[$gs_key]; - $gs_all_here = $gs_status['missing'] === 0; ?> - + $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"); ?> +
+
+ data-gs-pack-check> + +
+ +
+ +
+ Pick which + +
+ + + +
+
+
+ $gs_item) { + if ($gs_item['group'] !== '' && $gs_item['group'] !== $gs_group) { + $gs_group = $gs_item['group']; ?> +
+ +
+ + +
+ +
+
+
+ +
+
+

Nothing you already have is changed. Each pack can be added later, or brought up to date, in Maintenance > Starter Content.

diff --git a/admin/post/getting_started.php b/admin/post/getting_started.php index f8652020e..fd9cbcafe 100644 --- a/admin/post/getting_started.php +++ b/admin/post/getting_started.php @@ -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 ' . $added . ' things to start from: ' . escapeHtml(implode(', ', $summary))); diff --git a/admin/post/starter_content_model.php b/admin/post/starter_content_model.php index 780f6ff88..ce859e9be 100644 --- a/admin/post/starter_content_model.php +++ b/admin/post/starter_content_model.php @@ -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; } diff --git a/css/itflow_custom.css b/css/itflow_custom.css index 5a4120525..bd2f015e0 100644 --- a/css/itflow_custom.css +++ b/css/itflow_custom.css @@ -8347,12 +8347,9 @@ a.itflow-portal-request:focus-visible { font-weight: 400; font-size: .9rem; } -.itflow-gs-packs .list-group-item { +.itflow-gs-packs label[for] { cursor: pointer; } -.itflow-gs-packs .list-group-item.is-here { - cursor: default; -} .itflow-gs-packs .list-group-item.is-here .itflow-gs-pack-icon, .itflow-gs-packs .list-group-item.is-here .fw-semibold { opacity: .6; @@ -8363,6 +8360,43 @@ a.itflow-portal-request:focus-visible { -webkit-box-orient: vertical; overflow: hidden; } +/* Pick which - what a pack would add, each ticked, in a box that scrolls */ +.itflow-gs-pick > summary { + color: var(--bs-link-color); + cursor: pointer; + width: max-content; +} +.itflow-gs-pick-list { + max-height: 16rem; + overflow-y: auto; + padding: .5rem .75rem; + border: 1px solid var(--bs-border-color); + border-radius: .375rem; + background: var(--bs-body-bg); +} +/* Two across when there is room, reading along the rows, each group under its heading - inside the box that scrolls */ +.itflow-gs-pick-cols { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(14rem, 100%), 1fr)); + column-gap: 1.5rem; + row-gap: .15rem; +} +.itflow-gs-pick-list .form-check { + margin-bottom: 0; + min-width: 0; +} +.itflow-gs-pick-group { + grid-column: 1 / -1; + font-size: .75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: .06em; + color: var(--bs-secondary-color); + margin: .5rem 0 .25rem; +} +.itflow-gs-pick-group:first-child { + margin-top: 0; +} /* "or", across the card between two ways of doing a step */ .itflow-gs-or { display: flex; diff --git a/js/getting_started.js b/js/getting_started.js index 5aed52c8a..8555c963f 100644 --- a/js/getting_started.js +++ b/js/getting_started.js @@ -8,6 +8,9 @@ * [data-gs-cron-poll=url] asks that address every 10 seconds whether cron * has been heard from, and shows the step again * once it has + * [data-gs-pack] a starter content pack's tick and its Pick which + * list kept in step - ticking the pack ticks every + * item, and its count says how many are ticked * form sent once, the button pressed showing it is on * its way */ @@ -64,6 +67,37 @@ setInterval(ask, 10000); } + // A pack and the items under its Pick which + function initPack(pack) { + var check = pack.querySelector('[data-gs-pack-check]'); + var items = Array.prototype.slice.call(pack.querySelectorAll('[data-gs-item]')); + var count = pack.querySelector('[data-gs-pack-count]'); + if (!check || !items.length) { + return; + } + var of = items.length; + var more = count && count.dataset.gsMore === '1'; + function draw() { + var ticked = items.filter(function (item) { return item.checked; }).length; + check.checked = ticked > 0; + check.indeterminate = ticked > 0 && ticked < of; + if (count) { + count.textContent = ticked === of ? of + (more ? ' more to add' : ' to add') : (ticked ? ticked + ' of ' + of + ' to add' : 'None picked'); + } + } + function setAll(on) { + items.forEach(function (item) { item.checked = on; }); + draw(); + } + check.addEventListener('change', function () { setAll(check.checked); }); + items.forEach(function (item) { item.addEventListener('change', draw); }); + var all = pack.querySelector('[data-gs-pick-all]'); + var none = pack.querySelector('[data-gs-pick-none]'); + if (all) { all.addEventListener('click', function () { setAll(true); }); } + if (none) { none.addEventListener('click', function () { setAll(false); }); } + draw(); + } + function initBusy(form) { form.addEventListener('submit', function (e) { if (e.defaultPrevented) { @@ -87,6 +121,7 @@ document.querySelectorAll('[data-gs-reveal]').forEach(initReveal); document.querySelectorAll('[data-gs-show]').forEach(initShow); document.querySelectorAll('[data-gs-cron-poll]').forEach(initCronPoll); + document.querySelectorAll('[data-gs-pack]').forEach(initPack); document.querySelectorAll('form').forEach(initBusy); }