diff --git a/assets/css/jumper.css b/assets/css/jumper.css deleted file mode 100644 index 865f20b8..00000000 --- a/assets/css/jumper.css +++ /dev/null @@ -1,408 +0,0 @@ -#wu-jumper { - box-sizing: border-box; - position: fixed; - top: 25%; - left: 50%; - width: 500px; - margin-left: -250px; - background: #f9f9f9; - z-index: 99999; - border-radius: 5px; - border: solid 1px #ccc; - -webkit-box-shadow: 0 0 5000px 5000px rgba(0, 0, 0, 0.2); - -moz-box-shadow: 0 0 5000px 5000px rgba(0, 0, 0, 0.2); - box-shadow: 0 0 5000px 5000px rgba(0, 0, 0, 0.2); - /* @group Base */ - /* @end */ - /* @group Single Chosen */ - /* @end */ - /* @group Results */ - /* @end */ - /* @group Multi Chosen */ - /* @end */ - /* @end */ - /* @group Disabled Support */ - /* @end */ - /* @group Retina compatibility */ - /* @end */ -} -#wu-jumper .wu-jumper-icon-container::before { - font-family: dashicons-wu, sans-serif !important; - content: "\e900"; - position: absolute; - top: 50%; - right: 15px; - font-size: 36px; - margin-top: -18px; - height: 36px; - line-height: 36px; -} -#wu-jumper * { - box-sizing: border-box; -} -#wu-jumper label { - display: block; - text-transform: uppercase; - padding: 20px; -} -#wu-jumper .selectize-control { - width: 100%; -} -#wu-jumper .wu-chosen-container { - position: relative; - display: inline-block; - vertical-align: middle; - font-size: 13px; - zoom: 1; - -webkit-user-select: none; - -moz-user-select: none; - user-select: none; -} -#wu-jumper .wu-chosen-container-single .wu-chosen-drop { - margin-top: -1px; - border-radius: 0 0 4px 4px; - background-clip: padding-box; - border: solid 1px #ccc; - border-top: none; -} -#wu-jumper .wu-chosen-container .wu-chosen-drop { - position: absolute; - top: 100%; - left: -9999px; - z-index: 1010; - width: 100%; - border-top: 0; - background: #fff; -} -#wu-jumper .wu-chosen-container.wu-chosen-with-drop .wu-chosen-drop { - left: 0; -} -#wu-jumper .wu-chosen-container * { - -webkit-box-sizing: border-box; - -moz-box-sizing: border-box; - box-sizing: border-box; -} -#wu-jumper .wu-chosen-container a { - cursor: pointer; -} -#wu-jumper .wu-chosen-container .search-choice .group-name, -#wu-jumper .wu-chosen-container .wu-chosen-single .group-name { - margin-right: 4px; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - font-weight: normal; - color: #999; -} -#wu-jumper .wu-chosen-container .search-choice .group-name::after, -#wu-jumper .wu-chosen-container .wu-chosen-single .group-name::after { - content: ":"; - padding-left: 2px; - vertical-align: top; -} -#wu-jumper .selectize-control.single .selectize-input::after { - display: none !important; -} -#wu-jumper .selectize-dropdown { - margin-top: 0 !important; - background-color: #fcfcfc; - border: solid 1px #ddd; -} -#wu-jumper .selectize-input { - margin-bottom: -7px; - position: relative; - display: block; - overflow: hidden; - color: #444; - border: none; - box-shadow: none; - background: none; - text-decoration: none; - white-space: nowrap; - line-height: 70px; - padding: 0 15px; -} -#wu-jumper .selectize-input .item { - font-size: 18pt; -} -#wu-jumper .selectize-input input { - font-size: 18pt; -} -#wu-jumper .selectize-input.single::after { - display: none !important; -} -#wu-jumper .wu-chosen-container-single .wu-chosen-default { - color: #999; -} -#wu-jumper .wu-chosen-container-single .wu-chosen-single span { - display: block; - overflow: hidden; - margin-right: 26px; - text-overflow: ellipsis; - white-space: nowrap; -} -#wu-jumper .wu-chosen-container-single .wu-chosen-single-with-deselect span { - margin-right: 38px; -} -#wu-jumper .wu-chosen-container-single .wu-chosen-single abbr { - position: absolute; - top: 6px; - right: 26px; - display: block; - width: 12px; - height: 12px; - font-size: 1px; -} -#wu-jumper .wu-chosen-container-single .wu-chosen-single abbr:hover { - background-position: -42px -10px; -} -#wu-jumper .wu-chosen-container-single.wu-chosen-disabled .wu-chosen-single abbr:hover { - background-position: -42px -10px; -} -#wu-jumper .wu-chosen-container-single .wu-chosen-single div { - position: absolute; - top: 0; - right: 0; - display: block; - width: 18px; - height: 100%; -} -#wu-jumper .wu-chosen-container-single .wu-chosen-single div b { - display: block; - width: 100%; - height: 100%; -} -#wu-jumper .wu-chosen-container-single .wu-chosen-search { - position: relative; - z-index: 1010; - margin: 0; - white-space: nowrap; -} -#wu-jumper .wu-chosen-container-single .wu-chosen-search input[type=text] { - margin: 0; - padding: 4px 20px 4px 5px; - width: 100%; - height: auto; - outline: 0; - box-shadow: none !important; - background-color: #fcfcfc; - border: none; - border-bottom: solid 1px #ddd; - border-top: solid 1px #ddd; - font-size: 1em; - font-family: sans-serif; - line-height: normal; - border-radius: 0; -} -#wu-jumper .wu-chosen-container-single.wu-chosen-container-single-nosearch .wu-chosen-search { - position: absolute; - left: -9999px; -} -#wu-jumper .wu-chosen-container .wu-chosen-results { - color: #444; - position: relative; - overflow-x: hidden; - overflow-y: auto; - margin: 0 4px 4px 0; - padding: 0 0 0 4px; - max-height: 240px; - -webkit-overflow-scrolling: touch; -} -#wu-jumper .wu-chosen-container .wu-chosen-results li { - display: none; - margin: 0; - padding: 15px 6px; - list-style: none; - line-height: 15px; - word-wrap: break-word; - -webkit-touch-callout: none; -} -#wu-jumper .wu-chosen-container .wu-chosen-results li.active-result { - display: list-item; - cursor: pointer; -} -#wu-jumper .wu-chosen-container .wu-chosen-results li.disabled-result { - display: list-item; - color: #ccc; - cursor: default; -} -#wu-jumper .wu-chosen-container .wu-chosen-results li.highlighted { - color: #fff; -} -#wu-jumper .wu-chosen-container .wu-chosen-results li.no-results { - color: #777; - display: list-item; - background: #f4f4f4; -} -#wu-jumper .wu-chosen-container .wu-chosen-results li.group-result { - display: list-item; - font-weight: bold; - cursor: default; -} -#wu-jumper .wu-chosen-container .wu-chosen-results li.group-option { - padding-left: 15px; -} -#wu-jumper .wu-chosen-container .wu-chosen-results li em { - font-style: normal; - text-decoration: underline; -} -#wu-jumper .wu-chosen-container-multi .wu-chosen-choices { - position: relative; - overflow: hidden; - margin: 0; - padding: 0 5px; - width: 100%; - height: auto !important; - height: 1%; - background-color: #fff; - cursor: text; -} -#wu-jumper .wu-chosen-container-multi .wu-chosen-choices li { - float: left; - list-style: none; -} -#wu-jumper .wu-chosen-container-multi .wu-chosen-choices li.search-field { - margin: 0; - padding: 0; - white-space: nowrap; -} -#wu-jumper .wu-chosen-container-multi .wu-chosen-choices li.search-field input[type=text] { - margin: 1px 0; - padding: 0; - height: 25px; - outline: 0; - background: transparent !important; - color: #999; - font-size: 100%; - font-family: sans-serif; - line-height: normal; - border-radius: 0; -} -#wu-jumper .wu-chosen-container-multi .wu-chosen-choices li.search-choice { - position: relative; - margin: 3px 5px 3px 0; - padding: 3px 20px 3px 5px; - max-width: 100%; - border-radius: 3px; - background-color: #eee; - background-size: 100% 19px; - background-repeat: repeat-x; - background-clip: padding-box; - color: #333; - line-height: 13px; - cursor: default; -} -#wu-jumper .wu-chosen-container-multi .wu-chosen-choices li.search-choice span { - word-wrap: break-word; -} -#wu-jumper .wu-chosen-container-multi .wu-chosen-choices li.search-choice-focus .search-choice-close { - background-position: -42px -10px; -} -#wu-jumper .wu-chosen-container-multi .wu-chosen-choices li.search-choice .search-choice-close { - position: absolute; - top: 4px; - right: 3px; - display: block; - width: 12px; - height: 12px; - font-size: 1px; -} -#wu-jumper .wu-chosen-container-multi .wu-chosen-choices li.search-choice .search-choice-close:hover { - background-position: -42px -10px; -} -#wu-jumper .wu-chosen-container-multi .wu-chosen-choices li.search-choice-disabled { - padding-right: 5px; - background-color: #e4e4e4; - color: #666; -} -#wu-jumper .wu-chosen-container-multi .wu-chosen-choices li.search-choice-focus { - background: #d4d4d4; -} -#wu-jumper .wu-chosen-container-multi .wu-chosen-results { - margin: 0; - padding: 0; -} -#wu-jumper .wu-chosen-container-multi .wu-chosen-drop .result-selected { - display: list-item; - color: #ccc; - cursor: default; -} -#wu-jumper .wu-chosen-disabled .wu-chosen-single { - cursor: default; -} -#wu-jumper .wu-chosen-container-active.wu-chosen-with-drop .wu-chosen-single { - -moz-border-radius-bottomright: 0; - border-bottom-right-radius: 0; - -moz-border-radius-bottomleft: 0; - border-bottom-left-radius: 0; -} -#wu-jumper .wu-chosen-container-active.wu-chosen-with-drop .wu-chosen-single div { - border-left: none; - background: transparent; -} -#wu-jumper .wu-chosen-container-active.wu-chosen-with-drop .wu-chosen-single div b { - background-position: -18px 2px; -} -#wu-jumper .wu-chosen-container-active .wu-chosen-choices li.search-field input[type=text] { - color: #222 !important; -} -#wu-jumper .wu-chosen-disabled { - opacity: 0.5 !important; - cursor: default; -} -#wu-jumper .wu-chosen-disabled .wu-chosen-container-multi.wu-chosen-choices li.search-choice .search-choice-close { - cursor: default; -} -@media only screen and (-webkit-min-device-pixel-ratio: 1.5), only screen and (min-resolution: 144dpi), only screen and (min-resolution: 1.5dppx) { - #wu-jumper .wu-chosen-rtl .wu-chosen-search input[type=text], - #wu-jumper .wu-chosen-container-single .wu-chosen-single abbr, - #wu-jumper .wu-chosen-container-single .wu-chosen-single div b, - #wu-jumper .wu-chosen-container-single .wu-chosen-search input[type=text], - #wu-jumper .wu-chosen-container-multi .wu-chosen-choices .search-choice .search-choice-close, - #wu-jumper .wu-chosen-container .wu-chosen-results-scroll-down span, - #wu-jumper .wu-chosen-container .wu-chosen-results-scroll-up span { - background-size: 52px 37px !important; - background-repeat: no-repeat !important; - } -} - -#wu_jumper_select_chosen { /* stylelint-disable-line selector-id-pattern */ - margin: 0; - width: 100% !important; - padding: 0; -} - -#wu_jumper_select_chosen input { /* stylelint-disable-line selector-id-pattern */ - outline: none; - width: 100%; - padding: 20px !important; -} - -span.wu-keys { - margin-right: 20px; -} - -span.wu-keys-key { - display: inline-block; - padding: 0 5px 1px; - border: solid 2px #999; - border-radius: 3px; - margin: 0 2px; -} - -.wu-jumper-loading, -.wu-jumper-redirecting { - border-top: solid 1px #cecece; - padding: 10px; - text-align: center; - display: none; -} - -body.rtl #wu-jumper .wu-chosen-container-single .wu-chosen-single::after { - right: auto; - left: 15px; -} -body.rtl #wu-jumper .wu-chosen-container-single .wu-chosen-single span { - margin-right: 0; - margin-left: 26px; -} \ No newline at end of file diff --git a/assets/js/command-palette.js b/assets/js/command-palette.js new file mode 100644 index 00000000..58a228d6 --- /dev/null +++ b/assets/js/command-palette.js @@ -0,0 +1,335 @@ +/** + * Command Palette Integration for Ultimate Multisite + * + * Registers dynamic commands for searching entities using WordPress Command Palette. + * + * @package WP_Ultimo + * @since 2.1.0 + */ + +(function (wp) { + 'use strict'; + + // Check if wp.commands is available + if (!wp || !wp.commands || !wp.element) { + console.log('[Ultimate Multisite] Command palette not available - wp.commands or wp.element missing'); + return; + } + + console.log('[Ultimate Multisite] Command palette API detected, initializing...'); + + const { useState, useEffect, useRef } = wp.element; + const { __ } = wp.i18n; + const apiFetch = wp.apiFetch; + + // Get configuration from localized script + const config = window.wuCommandPalette || {}; + const entities = config.entities || {}; + const restUrl = config.restUrl || ''; + const networkAdminUrl = config.networkAdminUrl || ''; + const customLinks = config.customLinks || []; + + console.log('[Ultimate Multisite] Configuration loaded:', { + entitiesCount: Object.keys(entities).length, + restUrl: restUrl, + customLinksCount: customLinks.length, + entities: entities + }); + + /** + * Custom hook for searching entities. + * Called by the command palette with search parameter. + * + * @param {Object} params Parameters object. + * @param {string} params.search The search term. + * @return {Object} Commands and loading state. + */ + function useEntitySearch({ search }) { + const [commands, setCommands] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const searchTimeoutRef = useRef(null); + const cacheRef = useRef({}); + + useEffect(() => { + // Clear any pending timeouts + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current); + } + + // Minimum 2 characters required + if (!search || search.length < 2) { + setCommands([]); + setIsLoading(false); + return; + } + + // Check cache + if (cacheRef.current[search]) { + setCommands(cacheRef.current[search]); + setIsLoading(false); + return; + } + + // Debounce: wait 300ms before searching + setIsLoading(true); + + searchTimeoutRef.current = setTimeout(() => { + const searchUrl = restUrl + '?' + new URLSearchParams({ + query: search, + limit: 15 + }).toString(); + + console.log('[Ultimate Multisite] Searching:', searchUrl); + + apiFetch({ + url: searchUrl + }) + .then((response) => { + console.log('[Ultimate Multisite] Search response:', response); + const results = response.results || []; + + const cmds = results.map((result) => { + const cmd = { + name: 'ultimate-multisite/' + result.type + '-' + result.id, + label: result.title, + searchLabel: result.title + ' ' + result.subtitle, + callback: ({ close }) => { + close(); + window.location.href = result.url; + } + }; + + // Add icon if available + const icon = getIcon(result.icon); + if (icon) { + cmd.icon = icon; + } + + return cmd; + }); + + // Cache the results + cacheRef.current[search] = cmds; + + console.log('[Ultimate Multisite] Commands generated:', cmds.length); + setCommands(cmds); + setIsLoading(false); + }) + .catch((error) => { + console.error('[Ultimate Multisite] Search error:', error); + setCommands([]); + setIsLoading(false); + }); + }, 300); + + return () => { + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current); + } + }; + }, [search]); + + return { + commands, + isLoading + }; + } + + /** + * Get icon component for command palette. + * + * WordPress command palette expects icon components from @wordpress/icons package. + * We'll map dashicon names to WordPress icons when available. + * + * @param {string} icon Dashicon name. + * @return {Object|undefined} WordPress icon component or undefined. + */ + function getIcon(icon) { + // Check if @wordpress/icons is available + if (!wp.icons) { + console.log('[Ultimate Multisite] wp.icons not available'); + return undefined; + } + + // Map dashicon names to @wordpress/icons + const iconMap = { + 'admin-users': wp.icons.people, + 'admin-multisite': wp.icons.layout, + 'id-alt': wp.icons.card, + 'money-alt': wp.icons.payment, + 'products': wp.icons.box, + 'networking': wp.icons.globe, + 'tickets-alt': wp.icons.tag, + 'rest-api': wp.icons.code, + 'megaphone': wp.icons.megaphone, + 'feedback': wp.icons.commentContent, + 'dashboard': wp.icons.home, + 'admin-settings': wp.icons.settings, + 'info': wp.icons.info, + 'external': wp.icons.external + }; + + const mappedIcon = iconMap[icon]; + if (!mappedIcon) { + console.log('[Ultimate Multisite] Icon not found:', icon); + } + + return mappedIcon || undefined; + } + + /** + * Register the entity search command loader. + */ + function registerEntitySearchLoader() { + if (!wp.data || !wp.data.dispatch || !wp.commands || !wp.commands.store) { + return; + } + + const { registerCommandLoader } = wp.data.dispatch(wp.commands.store); + + registerCommandLoader({ + name: 'ultimate-multisite/entity-search', + hook: useEntitySearch + }); + + console.log('[Ultimate Multisite] Command loader registered'); + } + + /** + * Register static commands for custom links. + */ + function registerCustomLinks() { + if (!customLinks || customLinks.length === 0) { + return; + } + + const { registerCommand } = wp.data.dispatch(wp.commands.store); + + customLinks.forEach((link, index) => { + const cmd = { + name: 'ultimate-multisite/custom-link-' + index, + label: link.title, + callback: ({ close }) => { + close(); + window.location.href = link.url; + } + }; + + // Add external link icon + const icon = getIcon('external'); + if (icon) { + cmd.icon = icon; + } + + registerCommand(cmd); + }); + } + + /** + * Register static commands for Ultimate Multisite pages. + */ + function registerStaticCommands() { + if (!wp.data || !wp.data.dispatch || !wp.commands || !wp.commands.store) { + return; + } + + const { registerCommand } = wp.data.dispatch(wp.commands.store); + + // Register common Ultimate Multisite pages + const staticCommands = []; + + // Dashboard command + const dashboardCmd = { + name: 'ultimate-multisite/dashboard', + label: __('Ultimate Multisite Dashboard', 'ultimate-multisite'), + callback: ({ close }) => { + close(); + window.location.href = networkAdminUrl + 'admin.php?page=wp-ultimo'; + } + }; + const dashboardIcon = getIcon('dashboard'); + if (dashboardIcon) dashboardCmd.icon = dashboardIcon; + staticCommands.push(dashboardCmd); + + // Settings command + const settingsCmd = { + name: 'ultimate-multisite/settings', + label: __('Ultimate Multisite Settings', 'ultimate-multisite'), + callback: ({ close }) => { + close(); + window.location.href = networkAdminUrl + 'admin.php?page=wp-ultimo-settings'; + } + }; + const settingsIcon = getIcon('admin-settings'); + if (settingsIcon) settingsCmd.icon = settingsIcon; + staticCommands.push(settingsCmd); + + // System info command + const systemInfoCmd = { + name: 'ultimate-multisite/system-info', + label: __('System Information', 'ultimate-multisite'), + callback: ({ close }) => { + close(); + window.location.href = networkAdminUrl + 'admin.php?page=wp-ultimo-system-info'; + } + }; + const systemInfoIcon = getIcon('info'); + if (systemInfoIcon) systemInfoCmd.icon = systemInfoIcon; + staticCommands.push(systemInfoCmd); + + // Register list pages for each entity type + Object.keys(entities).forEach((slug) => { + const entity = entities[slug]; + + const cmd = { + name: 'ultimate-multisite/list-' + slug, + label: entity.label_plural || entity.label + 's', + callback: ({ close }) => { + close(); + window.location.href = networkAdminUrl + 'admin.php?page=wu-' + slug.replace('_', '-') + 's'; + } + }; + + // Add icon if available + const icon = getIcon(entity.icon); + if (icon) { + cmd.icon = icon; + } + + staticCommands.push(cmd); + }); + + // Register all static commands + staticCommands.forEach((command) => { + try { + registerCommand(command); + } catch (error) { + console.error('Error registering command:', command.name, error); + } + }); + } + + /** + * Initialize command palette integration. + */ + function init() { + // Wait for DOM to be ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + return; + } + + // Register entity search loader (dynamic commands) + registerEntitySearchLoader(); + + // Register static commands + registerStaticCommands(); + + // Register custom links + registerCustomLinks(); + } + + // Initialize + init(); + +})(window.wp); diff --git a/assets/js/jumper.js b/assets/js/jumper.js deleted file mode 100644 index a9ed0067..00000000 --- a/assets/js/jumper.js +++ /dev/null @@ -1,165 +0,0 @@ -/* eslint-disable no-undef */ -(function($) { - - $(document).ready(function() { - - // Adds WUChosen.js to our custom select input - const $jumper = $('#wu-jumper-select').selectize({ - create: false, - maxItems: 1, - optgroupField: 'group', - optgroupValueField: 'value', - searchField: ['text', 'name', 'display_name', 'domain', 'title', 'desc', 'code'], - render: { - option(option) { - - if (typeof option.model === 'undefined') { - - option.model = 'jumper-link'; - - } // end if; - - if (typeof option.text === 'undefined') { - - option.text = option.reference_code || option.name || option.title || option.display_name || option.code; - - } // end if; - - if (typeof option.group === 'undefined') { - - option.group = option.model; - - } // end if; - - const template_html = jQuery('#wu-template-' + option.model).length ? - jQuery('#wu-template-' + option.model).html() : - jQuery('#wu-template-default').html(); - - const template = _.template(template_html, { - interpolate: /\{\{(.+?)\}\}/g, - }); - - return template(option); - - }, - }, - load(query, callback) { - - if (! query.length) { - - return callback(); - - } // end if; - - $('#wu-jumper .wu-jumper-loading').show(); - - jQuery.ajax({ - // eslint-disable-next-line no-undef - url: wu_jumper_vars.ajaxurl, - type: 'POST', - data: { - action: 'wu_search', - model: 'all', - number: 99, - query: { - search: '*' + query + '*', - }, - }, - error() { - - callback(); - - }, - success(res) { - - $('#wu-jumper .wu-jumper-loading').hide(); - - callback(res); - - }, - }); - - }, - }); - - const is_local_url = function(url) { - - return url.toLowerCase().indexOf(wu_jumper_vars.base_url) >= 0 || url.toLowerCase().indexOf(wu_jumper_vars.network_base_url) >= 0; - - }; // end is_local_url - - // Every time the value changes, we need to redirect the user - $jumper.on('change', function() { - - // Check if we need to open this in a new tab - if (is_local_url($(this).val())) { - - window.location.href = $(this).val(); - - $(this).parent().parent().find('.wu-jumper-redirecting').show(); - - } else { - - window.open($(this).val(), '_blank'); - - $($jumper.parent()).hide(); - - } // end if; - - }); - - // Closes on clicking other elements - $(document).on('click', ':not(#wu-jumper-button-trigger)', function(e) { - - const target = e.target; - - if ($(target).attr('id') === 'wu-jumper-button-trigger' || $(target).parent().attr('id') === 'wu-jumper-button-trigger') { - - return; - - } // end if; - - if (! $(target).is($jumper.parent()) && ! $(target).parents().is($jumper.parent())) { - - $($jumper.parent().parent()).hide(); - - } // end if; - - }); - - const trigger_key = wu_jumper_vars.trigger_key.charAt(0); - - // Our bar is hidden by default, we need to display it when a certain shortcut is pressed - Mousetrap.bind(['command+option+' + trigger_key, 'ctrl+alt+' + trigger_key], function(e) { - - e.preventDefault(); - - open_jumper(); - - }); // end mousetrap; - - $(document).on('click', '#wu-jumper-button-trigger', function(e) { - - e.preventDefault(); - - open_jumper(); - - }); - - /** - * Actually opens the jumper. - */ - function open_jumper() { - - $('#wu-jumper').show(); - - $('#wu-jumper').find('input').focus(); - - return false; - - } // end open_jumper; - - }); - -}(jQuery)); - diff --git a/inc/apis/class-command-palette-rest-controller.php b/inc/apis/class-command-palette-rest-controller.php new file mode 100644 index 00000000..1ed19528 --- /dev/null +++ b/inc/apis/class-command-palette-rest-controller.php @@ -0,0 +1,266 @@ +namespace, + '/' . $this->rest_base . '/search', + [ + [ + 'methods' => \WP_REST_Server::READABLE, + 'callback' => [$this, 'search'], + 'permission_callback' => [$this, 'search_permissions_check'], + 'args' => $this->get_search_params(), + ], + ] + ); + } + + /** + * Get the query params for search. + * + * @since 2.1.0 + * @return array + */ + public function get_search_params(): array { + + return [ + 'query' => [ + 'description' => __('Search query string.', 'ultimate-multisite'), + 'type' => 'string', + 'required' => true, + 'sanitize_callback' => 'sanitize_text_field', + 'validate_callback' => function ($param) { + return strlen($param) >= 2; + }, + ], + 'entity_type' => [ + 'description' => __('Entity type to search (optional).', 'ultimate-multisite'), + 'type' => 'string', + 'required' => false, + 'sanitize_callback' => 'sanitize_key', + ], + 'limit' => [ + 'description' => __('Maximum number of results to return.', 'ultimate-multisite'), + 'type' => 'integer', + 'required' => false, + 'default' => 15, + 'minimum' => 1, + 'maximum' => 20, + 'sanitize_callback' => 'absint', + ], + ]; + } + + /** + * Check permissions for search endpoint. + * + * @since 2.1.0 + * + * @param \WP_REST_Request $request The request object. + * @return bool|\WP_Error + */ + public function search_permissions_check($request): bool { + unset($request); + + if (! current_user_can('manage_network')) { + return new \WP_Error( + 'rest_forbidden', + __('You do not have permission to search.', 'ultimate-multisite'), + ['status' => 403] + ); + } + + return true; + } + + /** + * Handle search request. + * + * @since 2.1.0 + * + * @param \WP_REST_Request $request The request object. + * @return \WP_REST_Response|\WP_Error + */ + public function search($request) { + + $query = $request->get_param('query'); + $entity_type = $request->get_param('entity_type'); + $limit = $request->get_param('limit') ?: 15; + + // Minimum query length + if (strlen($query) < 2) { + return rest_ensure_response( + [ + 'results' => [], + 'message' => __('Query must be at least 2 characters.', 'ultimate-multisite'), + ] + ); + } + + $results = []; + + // Get registered entities from Command Palette Manager + $manager = \WP_Ultimo\UI\Command_Palette_Manager::get_instance(); + $entities = $manager->get_registered_entities(); + + // If no entities registered, return empty results + if (empty($entities)) { + return rest_ensure_response( + [ + 'results' => [], + 'total' => 0, + 'message' => __('No searchable entities registered yet.', 'ultimate-multisite'), + ] + ); + } + + // If entity_type is specified, search only that type + if (! empty($entity_type) && isset($entities[ $entity_type ])) { + $results = $this->search_entity_type($entity_type, $query, $limit); + } else { + // Search all entity types + $entity_count = count($entities); + $per_type_limit = max(1, floor($limit / $entity_count)); + + foreach ($entities as $slug => $config) { + $entity_results = $this->search_entity_type($slug, $query, $per_type_limit); + $results = array_merge($results, $entity_results); + + // Stop if we've reached the limit + if (count($results) >= $limit) { + break; + } + } + + // Trim to limit + $results = array_slice($results, 0, $limit); + } + + /** + * Filter command palette search results. + * + * @since 2.1.0 + * + * @param array $results Search results. + * @param string $query Search query. + * @param string $entity_type Entity type (empty if searching all). + */ + $results = apply_filters('wu_command_palette_search_results', $results, $query, $entity_type); + + return rest_ensure_response( + [ + 'results' => $results, + 'total' => count($results), + ] + ); + } + + /** + * Search a specific entity type. + * + * @since 2.1.0 + * + * @param string $entity_slug Entity slug. + * @param string $query Search query. + * @param int $limit Maximum results. + * @return array + */ + protected function search_entity_type(string $entity_slug, string $query, int $limit): array { + + // Get the manager class for this entity + $manager_class = $this->get_manager_class($entity_slug); + + if (! $manager_class || ! class_exists($manager_class)) { + return []; + } + + $manager = $manager_class::get_instance(); + + if (! $manager || ! method_exists($manager, 'search_for_command_palette')) { + return []; + } + + // Check user capability + $config = $manager->get_command_palette_config(); + + if (! empty($config['capability']) && ! current_user_can($config['capability'])) { + return []; + } + + return $manager->search_for_command_palette($query, $limit); + } + + /** + * Get the manager class name for an entity slug. + * + * @since 2.1.0 + * + * @param string $entity_slug Entity slug. + * @return string|null + */ + protected function get_manager_class(string $entity_slug): ?string { + + $manager_name = str_replace(' ', '_', ucwords(str_replace(['_', '-'], ' ', $entity_slug))); + + $class_name = "\\WP_Ultimo\\Managers\\{$manager_name}_Manager"; + + return class_exists($class_name) ? $class_name : null; + } +} diff --git a/inc/apis/trait-command-palette.php b/inc/apis/trait-command-palette.php new file mode 100644 index 00000000..2d89ceb9 --- /dev/null +++ b/inc/apis/trait-command-palette.php @@ -0,0 +1,339 @@ +command_palette_enabled) { + return; + } + + // Register this entity with the Command Palette Manager + add_action('init', [$this, 'register_with_command_palette_manager'], 20); + } + + /** + * Register this entity with the Command Palette Manager. + * + * @since 2.1.0 + * @return void + */ + public function register_with_command_palette_manager(): void { + + $manager = \WP_Ultimo\UI\Command_Palette_Manager::get_instance(); + + if (! $manager) { + return; + } + + $config = $this->get_command_palette_config(); + + if (empty($config)) { + return; + } + + $manager->register_entity_type($this->slug, $config); + } + + /** + * Get the command palette configuration for this entity. + * Managers can override this method to customize their configuration. + * + * @since 2.1.0 + * @return array + */ + public function get_command_palette_config(): array { + + $display_name = $this->get_entity_display_name(); + + return [ + 'label' => $display_name, + 'label_plural' => $this->get_entity_display_name_plural(), + 'icon' => $this->get_entity_icon(), + 'edit_url_pattern' => $this->get_edit_url_pattern(), + 'search_fields' => $this->get_search_fields(), + 'capability' => "wu_read_{$this->slug}", + ]; + } + + /** + * Get the display name for this entity (singular). + * + * @since 2.1.0 + * @return string + */ + protected function get_entity_display_name(): string { + + $name = str_replace(['_', '-'], ' ', $this->slug); + + return ucfirst($name); + } + + /** + * Get the display name for this entity (plural). + * + * @since 2.1.0 + * @return string + */ + protected function get_entity_display_name_plural(): string { + + return $this->get_entity_display_name() . 's'; + } + + /** + * Get the dashicon for this entity. + * + * @since 2.1.0 + * @return string + */ + protected function get_entity_icon(): string { + + $icons = [ + 'customer' => 'admin-users', + 'site' => 'admin-multisite', + 'membership' => 'id-alt', + 'payment' => 'money-alt', + 'product' => 'products', + 'domain' => 'networking', + 'discount_code' => 'tickets-alt', + 'webhook' => 'rest-api', + 'broadcast' => 'megaphone', + 'checkout_form' => 'feedback', + ]; + + return $icons[ $this->slug ] ?? 'admin-generic'; + } + + /** + * Get the edit URL pattern for this entity. + * + * @since 2.1.0 + * @return string + */ + protected function get_edit_url_pattern(): string { + + return "admin.php?page=wp-ultimo-edit-{$this->slug}&id=%d"; + } + + /** + * Get the fields to search for this entity. + * + * @since 2.1.0 + * @return array + */ + protected function get_search_fields(): array { + + $default_fields = ['name', 'display_name', 'title']; + + // Entity-specific search fields + $entity_fields = [ + 'customer' => ['display_name', 'email', 'user_login'], + 'site' => ['title', 'domain', 'path'], + 'membership' => ['reference_code', 'customer_id'], + 'payment' => ['reference_code', 'customer_id'], + 'product' => ['name', 'slug'], + 'domain' => ['domain', 'primary_domain'], + 'discount_code' => ['code', 'name'], + 'webhook' => ['name', 'webhook_url'], + 'broadcast' => ['title', 'slug'], + 'checkout_form' => ['name', 'slug'], + ]; + + return $entity_fields[ $this->slug ] ?? $default_fields; + } + + /** + * Search entities for the command palette. + * This method is called by the REST controller. + * + * @since 2.1.0 + * + * @param string $query The search query. + * @param int $limit Maximum number of results to return. + * @return array Array of search results. + */ + public function search_for_command_palette(string $query, int $limit = 15): array { + + if (strlen($query) < 2) { + return []; + } + + // Use the existing model query function + $getter_function = "wu_get_{$this->slug}s"; + + if (! function_exists($getter_function)) { + return []; + } + + $results = call_user_func( + $getter_function, + [ + 'search' => "*{$query}*", + 'number' => $limit, + ] + ); + + if (empty($results)) { + return []; + } + + return array_map([$this, 'format_result_for_command_palette'], $results); + } + + /** + * Format a single result for the command palette. + * + * @since 2.1.0 + * + * @param object $item The entity object. + * @return array Formatted result. + */ + protected function format_result_for_command_palette($item): array { + + $config = $this->get_command_palette_config(); + + $title = $this->get_item_title($item); + $subtitle = $this->get_item_subtitle($item); + $url = $this->get_item_url($item, $config['edit_url_pattern']); + + return [ + 'id' => $item->get_id(), + 'type' => $this->slug, + 'title' => $title, + 'subtitle' => $subtitle, + 'url' => $url, + 'icon' => $config['icon'], + ]; + } + + /** + * Get the display title for an item. + * + * @since 2.1.0 + * + * @param object $item The entity object. + * @return string + */ + protected function get_item_title($item): string { + + if (method_exists($item, 'get_display_name')) { + return $item->get_display_name(); + } + + if (method_exists($item, 'get_title')) { + return $item->get_title(); + } + + if (method_exists($item, 'get_name')) { + return $item->get_name(); + } + + return sprintf('#%d', $item->get_id()); + } + + /** + * Get the subtitle/description for an item. + * + * @since 2.1.0 + * + * @param object $item The entity object. + * @return string + */ + protected function get_item_subtitle($item): string { + + $parts = []; + + // Add ID + // Translators: %d the id of the item. + $parts[] = sprintf(__('ID: %d', 'ultimate-multisite'), $item->get_id()); + + // Entity-specific subtitles + switch ($this->slug) { + case 'customer': + if (method_exists($item, 'get_email_address')) { + $parts[] = $item->get_email_address(); + } + break; + + case 'site': + if (method_exists($item, 'get_domain')) { + $parts[] = $item->get_domain(); + } + break; + + case 'membership': + case 'payment': + if (method_exists($item, 'get_reference_code')) { + $parts[] = $item->get_reference_code(); + } + break; + + case 'domain': + if (method_exists($item, 'get_domain')) { + $parts[] = $item->get_domain(); + } + break; + + case 'discount_code': + if (method_exists($item, 'get_code')) { + $parts[] = $item->get_code(); + } + break; + } + + return implode(' • ', array_filter($parts)); + } + + /** + * Get the edit URL for an item. + * + * @since 2.1.0 + * + * @param object $item The entity object. + * @param string $pattern URL pattern with %d placeholder. + * @return string + */ + protected function get_item_url($item, string $pattern): string { + + $url = sprintf($pattern, $item->get_id()); + + return network_admin_url($url); + } +} diff --git a/inc/class-ajax.php b/inc/class-ajax.php index 71cd4a04..0af45ac0 100644 --- a/inc/class-ajax.php +++ b/inc/class-ajax.php @@ -27,16 +27,6 @@ class Ajax implements \WP_Ultimo\Interfaces\Singleton { * @since 2.0.0 */ public function init(): void { - /* - * Load search endpoints. - */ - add_action('wu_ajax_wu_search', [$this, 'search_models']); - - /* - * Adds the Selectize templates to the admin_footer. - */ - add_action('in_admin_footer', [$this, 'render_selectize_templates']); - /* * Load search endpoints. */ @@ -77,341 +67,4 @@ public function refresh_list_table(): void { do_action('wu_list_table_fetch_ajax_results', $table_id); } - - /** - * Search models using our ajax endpoint. - * - * @since 2.0.0 - * @return void - */ - public function search_models(): void { - - /** - * Fires before the processing of the search request. - * - * @since 2.0.0 - */ - do_action('wu_before_search_models'); - - if (wu_request('model') === 'all') { - $this->search_all_models(); - - return; - } - - $args = wp_parse_args( - $_REQUEST, // phpcs:ignore WordPress.Security.NonceVerification.Recommended - [ - 'model' => 'membership', - 'query' => [], - 'exclude' => [], // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_exclude - ] - ); - - $query = array_merge( - $args['query'], - [ - 'number' => -1, - ] - ); - - if ($args['exclude']) { - if (is_string($args['exclude'])) { - $args['exclude'] = explode(',', $args['exclude']); // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_exclude - - $args['exclude'] = array_map('trim', $args['exclude']); // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_exclude - } - - $query['id__not_in'] = $args['exclude']; - } - - if (wu_get_isset($args, 'include')) { - if (is_string($args['include'])) { - $args['include'] = explode(',', $args['include']); - - $args['include'] = array_map('trim', $args['include']); - } - - $query['id__in'] = $args['include']; - } - - /* - * Deal with site - */ - if ('site' === $args['model']) { - if (wu_get_isset($query, 'id__in')) { - $query['blog_id__in'] = $query['id__in']; - - unset($query['id__in']); - } - - if (wu_get_isset($query, 'id__not_in')) { - $query['blog_id__not_in'] = $query['id__not_in']; - - unset($query['id__not_in']); - } - } - - $results = []; - - if ('user' === $args['model']) { - $results = $this->search_wordpress_users($query); - } elseif ('page' === $args['model']) { - $results = get_posts( - [ - 'post_type' => 'page', - 'post_status' => 'publish', - 'numberposts' => -1, - 'exclude' => $query['id__not_in'] ?? '', // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_exclude - ] - ); - } elseif ('setting' === $args['model']) { - $results = $this->search_wp_ultimo_setting($query); - } else { - $model_func = 'wu_get_' . strtolower((string) $args['model']) . 's'; - - if (function_exists($model_func)) { - $results = $model_func($query); - } - } - - // Try search by hash if do not have any result - if (empty($results)) { - $model_func = 'wu_get_' . strtolower((string) $args['model']) . '_by_hash'; - - if (function_exists($model_func)) { - $result = $model_func(trim((string) $query['search'], '*')); - - $results = $result ? [$result] : []; - } - } - - wp_send_json($results); - - exit; - } - - /** - * Search all models for Jumper. - * - * @since 2.0.0 - * @return void - */ - public function search_all_models(): void { - - $query = array_merge( - wu_request('query', []), - [ - 'number' => 10000, - ] - ); - - $results_user = array_map( - function ($item) { - - $item->model = 'user'; - - $item->group = 'Users'; - - $item->value = network_admin_url("user-edit.php?user_id={$item->ID}"); - - return $item; - }, - $this->search_wordpress_users($query) - ); - - $results_settings = array_map( - function ($item) { - - $item['model'] = 'setting'; - - $item['group'] = 'Settings'; - - $item['value'] = $item['url']; - - return $item; - }, - $this->search_wp_ultimo_setting($query) - ); - - $data = array_merge($results_user, $results_settings); - - /** - * Allow plugin developers to add more search models functions. - * - * @since 2.0.0 - */ - $data_sources = apply_filters( - 'wu_search_models_functions', - [ - 'wu_get_customers', - 'wu_get_products', - 'wu_get_plans', - 'wu_get_domains', - 'wu_get_sites', - 'wu_get_memberships', - 'wu_get_payments', - 'wu_get_broadcasts', - 'wu_get_checkout_forms', - ] - ); - - foreach ($data_sources as $function) { - $results = call_user_func($function, $query); - - array_map( - function ($item) { - - $url = str_replace('_', '-', (string) $item->model); - - $item->value = wu_network_admin_url( - "wp-ultimo-edit-{$url}", - [ - 'id' => $item->get_id(), - ] - ); - - $item->group = ucwords((string) $item->model) . 's'; - - return $item; - }, - $results - ); - - $discount_codes = array_map( - function ($item) { - - $discount = $item->to_array(); - - $discount['value'] = wu_network_admin_url( - 'wp-ultimo-edit-discount-code', - [ - 'id' => $discount['id'], - ] - ); - - $discount['group'] = 'Discount Codes'; - - return $discount; - }, - wu_get_discount_codes($query) - ); - - $data = array_merge($data, $results, $discount_codes); - } - - wp_send_json($data); - } - - /** - * Search for Ultimate Multisite settings to help customers find them. - * - * @since 2.0.0 - * - * @param array $query Query arguments. - */ - public function search_wp_ultimo_setting($query): array { - - $sections = \WP_Ultimo\Settings::get_instance()->get_sections(); - - $all_fields = []; - - foreach ($sections as $section_slug => $section) { - $section['fields'] = array_map( - function ($item) use ($section, $section_slug) { - - $item['section'] = $section_slug; - - $item['section_title'] = wu_get_isset($section, 'title', ''); - - $item['url'] = wu_network_admin_url( - 'wp-ultimo-settings', - [ - 'tab' => $section_slug, - ] - ) . '#' . $item['setting_id']; - - return $item; - }, - $section['fields'] - ); - - $all_fields = array_merge($all_fields, $section['fields']); - } - - $_settings = \Arrch\Arrch::find( - $all_fields, - [ - 'sort_key' => 'title', - 'where' => [ - ['setting_id', '~', trim((string) $query['search'], '*')], - ['type', '!=', 'header'], - ], - ] - ); - - return array_values($_settings); - } - - /** - * Handles the special case of searching native WP users. - * - * @since 2.0.0 - * - * @param array $query Query arguments. - * @return array - */ - public function search_wordpress_users($query) { - - $results = get_users( - [ - 'blog_id' => 0, - 'search' => '*' . $query['search'] . '*', - 'search_columns' => [ - 'ID', - 'user_login', - 'user_email', - 'user_url', - 'user_nicename', - 'display_name', - ], - ] - ); - - $results = array_map( - function ($item) { - - $item->data->user_pass = ''; - - $item->data->avatar = get_avatar( - $item->data->user_email, - 40, - 'identicon', - '', - [ - 'force_display' => true, - 'class' => 'wu-rounded-full wu-mr-3', - ] - ); - - return $item->data; - }, - $results - ); - - return $results; - } - - /** - * Adds the selectize templates to the admin footer. - * - * @since 2.0.0 - * @return void - */ - public function render_selectize_templates(): void { - - if (current_user_can('manage_network')) { - wu_get_template('ui/selectize-templates'); - } - } } diff --git a/inc/class-wp-ultimo.php b/inc/class-wp-ultimo.php index 59c49438..31d1ce67 100644 --- a/inc/class-wp-ultimo.php +++ b/inc/class-wp-ultimo.php @@ -450,9 +450,14 @@ protected function load_extra_components(): void { WP_Ultimo\Debug\Debug::get_instance(); /* - * Loads the Jumper UI + * Loads the Command Palette integration */ - WP_Ultimo\UI\Jumper::get_instance(); + WP_Ultimo\UI\Command_Palette_Manager::get_instance(); + + /* + * Loads the Command Palette REST controller + */ + WP_Ultimo\Apis\Command_Palette_Rest_Controller::get_instance(); /* * Loads the Template Previewer diff --git a/inc/managers/class-broadcast-manager.php b/inc/managers/class-broadcast-manager.php index 6c46fecc..68f329c6 100644 --- a/inc/managers/class-broadcast-manager.php +++ b/inc/managers/class-broadcast-manager.php @@ -28,6 +28,7 @@ class Broadcast_Manager extends Base_Manager { use \WP_Ultimo\Apis\Rest_Api; use \WP_Ultimo\Apis\WP_CLI; use \WP_Ultimo\Apis\MCP_Abilities; + use \WP_Ultimo\Apis\Command_Palette; use \WP_Ultimo\Traits\Singleton; /** @@ -60,6 +61,8 @@ public function init(): void { $this->enable_mcp_abilities(); + $this->enable_command_palette(); + /** * Add unseen broadcast notices to the panel. */ diff --git a/inc/managers/class-checkout-form-manager.php b/inc/managers/class-checkout-form-manager.php index 8c611594..02457ef4 100644 --- a/inc/managers/class-checkout-form-manager.php +++ b/inc/managers/class-checkout-form-manager.php @@ -25,6 +25,7 @@ class Checkout_Form_Manager extends Base_Manager { use \WP_Ultimo\Apis\Rest_Api; use \WP_Ultimo\Apis\WP_CLI; + use \WP_Ultimo\Apis\Command_Palette; use \WP_Ultimo\Traits\Singleton; /** @@ -54,5 +55,7 @@ public function init(): void { $this->enable_rest_api(); $this->enable_wp_cli(); + + $this->enable_command_palette(); } } diff --git a/inc/managers/class-customer-manager.php b/inc/managers/class-customer-manager.php index c4c25463..91c1e332 100644 --- a/inc/managers/class-customer-manager.php +++ b/inc/managers/class-customer-manager.php @@ -28,6 +28,7 @@ class Customer_Manager extends Base_Manager { use \WP_Ultimo\Apis\Rest_Api; use \WP_Ultimo\Apis\WP_CLI; use \WP_Ultimo\Apis\MCP_Abilities; + use \WP_Ultimo\Apis\Command_Palette; use \WP_Ultimo\Traits\Singleton; /** @@ -60,6 +61,8 @@ public function init(): void { $this->enable_mcp_abilities(); + $this->enable_command_palette(); + add_action( 'init', function () { diff --git a/inc/managers/class-discount-code-manager.php b/inc/managers/class-discount-code-manager.php index ec0f2e96..9ee288ce 100644 --- a/inc/managers/class-discount-code-manager.php +++ b/inc/managers/class-discount-code-manager.php @@ -27,6 +27,7 @@ class Discount_Code_Manager extends Base_Manager { use \WP_Ultimo\Apis\Rest_Api; use \WP_Ultimo\Apis\WP_CLI; use \WP_Ultimo\Apis\MCP_Abilities; + use \WP_Ultimo\Apis\Command_Palette; use \WP_Ultimo\Traits\Singleton; /** @@ -59,6 +60,8 @@ public function init(): void { $this->enable_mcp_abilities(); + $this->enable_command_palette(); + add_action('wu_gateway_payment_processed', [$this, 'maybe_add_use_on_payment_received']); } diff --git a/inc/managers/class-domain-manager.php b/inc/managers/class-domain-manager.php index 4273338b..f40757cc 100644 --- a/inc/managers/class-domain-manager.php +++ b/inc/managers/class-domain-manager.php @@ -29,6 +29,7 @@ class Domain_Manager extends Base_Manager { use \WP_Ultimo\Apis\Rest_Api; use \WP_Ultimo\Apis\WP_CLI; use \WP_Ultimo\Apis\MCP_Abilities; + use \WP_Ultimo\Apis\Command_Palette; use \WP_Ultimo\Traits\Singleton; /** @@ -135,6 +136,8 @@ public function init(): void { $this->enable_mcp_abilities(); + $this->enable_command_palette(); + $this->set_cookie_domain(); add_action('plugins_loaded', [$this, 'load_integrations']); diff --git a/inc/managers/class-membership-manager.php b/inc/managers/class-membership-manager.php index fb5c416e..ba8cb9e5 100644 --- a/inc/managers/class-membership-manager.php +++ b/inc/managers/class-membership-manager.php @@ -27,6 +27,7 @@ class Membership_Manager extends Base_Manager { use \WP_Ultimo\Apis\Rest_Api; use \WP_Ultimo\Apis\WP_CLI; use \WP_Ultimo\Apis\MCP_Abilities; + use \WP_Ultimo\Apis\Command_Palette; use \WP_Ultimo\Traits\Singleton; const LOG_FILE_NAME = 'memberships'; @@ -60,6 +61,8 @@ public function init(): void { $this->enable_mcp_abilities(); + $this->enable_command_palette(); + add_action( 'init', function () { diff --git a/inc/managers/class-payment-manager.php b/inc/managers/class-payment-manager.php index ac2861a2..9d340d80 100644 --- a/inc/managers/class-payment-manager.php +++ b/inc/managers/class-payment-manager.php @@ -31,6 +31,7 @@ class Payment_Manager extends Base_Manager { use \WP_Ultimo\Apis\Rest_Api; use \WP_Ultimo\Apis\WP_CLI; use \WP_Ultimo\Apis\MCP_Abilities; + use \WP_Ultimo\Apis\Command_Palette; use \WP_Ultimo\Traits\Singleton; const LOG_FILE_NAME = 'payments'; @@ -64,6 +65,8 @@ public function init(): void { $this->enable_mcp_abilities(); + $this->enable_command_palette(); + $this->register_forms(); add_action( diff --git a/inc/managers/class-product-manager.php b/inc/managers/class-product-manager.php index 89151c45..6a7f1e09 100644 --- a/inc/managers/class-product-manager.php +++ b/inc/managers/class-product-manager.php @@ -27,6 +27,7 @@ class Product_Manager extends Base_Manager { use \WP_Ultimo\Apis\Rest_Api; use \WP_Ultimo\Apis\WP_CLI; use \WP_Ultimo\Apis\MCP_Abilities; + use \WP_Ultimo\Apis\Command_Palette; use \WP_Ultimo\Traits\Singleton; /** @@ -58,5 +59,7 @@ public function init(): void { $this->enable_wp_cli(); $this->enable_mcp_abilities(); + + $this->enable_command_palette(); } } diff --git a/inc/managers/class-site-manager.php b/inc/managers/class-site-manager.php index 63233b38..c6b09b66 100644 --- a/inc/managers/class-site-manager.php +++ b/inc/managers/class-site-manager.php @@ -29,6 +29,7 @@ class Site_Manager extends Base_Manager { use \WP_Ultimo\Apis\Rest_Api; use \WP_Ultimo\Apis\WP_CLI; use \WP_Ultimo\Apis\MCP_Abilities; + use \WP_Ultimo\Apis\Command_Palette; use \WP_Ultimo\Traits\Singleton; /** @@ -61,6 +62,8 @@ public function init(): void { $this->enable_mcp_abilities(); + $this->enable_command_palette(); + add_action('after_setup_theme', [$this, 'additional_thumbnail_sizes']); add_action('wp_ajax_wu_get_screenshot', [$this, 'get_site_screenshot']); diff --git a/inc/managers/class-webhook-manager.php b/inc/managers/class-webhook-manager.php index 492eff17..e1db0eb7 100644 --- a/inc/managers/class-webhook-manager.php +++ b/inc/managers/class-webhook-manager.php @@ -27,6 +27,7 @@ class Webhook_Manager extends Base_Manager { use \WP_Ultimo\Apis\Rest_Api; use \WP_Ultimo\Apis\WP_CLI; use \WP_Ultimo\Apis\MCP_Abilities; + use \WP_Ultimo\Apis\Command_Palette; use \WP_Ultimo\Traits\Singleton; /** @@ -75,6 +76,8 @@ public function init(): void { $this->enable_mcp_abilities(); + $this->enable_command_palette(); + add_action('init', [$this, 'register_webhook_listeners']); add_action('wp_ajax_wu_send_test_event', [$this, 'send_test_event']); diff --git a/inc/ui/class-command-palette-manager.php b/inc/ui/class-command-palette-manager.php new file mode 100644 index 00000000..04f1d1e1 --- /dev/null +++ b/inc/ui/class-command-palette-manager.php @@ -0,0 +1,247 @@ +='); + + /** + * Filter whether command palette is available. + * + * @since 2.1.0 + * + * @param bool $is_available Whether command palette is available. + */ + return apply_filters('wu_is_command_palette_available', $is_available); + } + + /** + * Check if we should load command palette in current context. + * + * @since 2.1.0 + * @return bool + */ + protected function should_load_command_palette(): bool { + + // Only in admin + if (! is_admin()) { + return false; + } + + // Check feature availability + if (! $this->is_command_palette_available()) { + return false; + } + + // Check user capability + if (! current_user_can('manage_network')) { + return false; + } + + return true; + } + + /** + * Register an entity type for command palette search. + * + * @since 2.1.0 + * + * @param string $slug Entity slug (e.g., 'customer', 'site'). + * @param array $config Entity configuration. + * @return void + */ + public function register_entity_type(string $slug, array $config): void { + + $this->registered_entities[ $slug ] = $config; + } + + /** + * Get all registered entity types. + * + * @since 2.1.0 + * @return array + */ + public function get_registered_entities(): array { + + return $this->registered_entities; + } + + /** + * Enqueue command palette scripts and styles. + * + * @since 2.1.0 + * @return void + */ + public function enqueue_scripts(): void { + + if (! $this->should_load_command_palette()) { + return; + } + + // Enqueue command palette integration + wp_enqueue_script( + 'wu-command-palette', + wu_get_asset('command-palette.js', 'js'), + ['wp-commands', 'wp-element', 'wp-i18n', 'wp-api-fetch', 'wp-components', 'wp-icons'], + wu_get_version(), + true + ); + + // Pass configuration to JavaScript + wp_localize_script( + 'wu-command-palette', + 'wuCommandPalette', + [ + 'entities' => $this->registered_entities, + 'restUrl' => rest_url('ultimate-multisite/v1/command-palette/search'), + 'nonce' => wp_create_nonce('wp_rest'), + 'networkAdminUrl' => network_admin_url(), + 'customLinks' => $this->get_custom_links(), + 'i18n' => [ + 'searchPlaceholder' => __('Search anything...', 'ultimate-multisite'), + 'noResults' => __('No results found', 'ultimate-multisite'), + 'minChars' => __('Type at least 2 characters to search', 'ultimate-multisite'), + ], + ] + ); + } + + /** + * Get custom links from settings. + * + * @since 2.1.0 + * @return array + */ + protected function get_custom_links(): array { + + $saved_links = wu_get_setting('jumper_custom_links', ''); + + if (empty($saved_links)) { + return []; + } + + $custom_links = []; + $lines = explode(PHP_EOL, (string) $saved_links); + + foreach ($lines as $line) { + $line = trim($line); + + if (empty($line)) { + continue; + } + + // Format: Title : URL + $parts = explode(':', $line, 2); + + if (count($parts) === 2) { + $title = trim($parts[0]); + $url = trim($parts[1]); + + if (! empty($title) && ! empty($url)) { + $custom_links[] = [ + 'title' => $title, + 'url' => $url, + ]; + } + } + } + + return $custom_links; + } + + /** + * Add command palette settings. + * + * @since 2.1.0 + * @return void + */ + public function add_settings(): void { + wu_register_settings_section( + 'tools', + [ + 'title' => __('Tools', 'ultimate-multisite'), + 'desc' => __('Tools and utilities for managing your network.', 'ultimate-multisite'), + 'icon' => 'dashicons-wu-tools', + ] + ); + + wu_register_settings_field( + 'tools', + 'command_palette_header', + [ + 'title' => __('Command Palette', 'ultimate-multisite'), + 'desc' => __('Quick navigation and search using WordPress Command Palette (Ctrl/Cmd+K).', 'ultimate-multisite'), + 'type' => 'header', + ] + ); + + wu_register_settings_field( + 'tools', + 'jumper_custom_links', + [ + 'title' => __('Custom Links', 'ultimate-multisite'), + 'desc' => __('Add custom links to the Command Palette. Add one per line, with the format "Title : URL".', 'ultimate-multisite'), + 'placeholder' => __('My Custom Link : https://example.com', 'ultimate-multisite'), + 'type' => 'textarea', + 'html_attr' => [ + 'rows' => 4, + ], + ] + ); + } +} diff --git a/inc/ui/class-jumper.php b/inc/ui/class-jumper.php deleted file mode 100644 index db15aa42..00000000 --- a/inc/ui/class-jumper.php +++ /dev/null @@ -1,570 +0,0 @@ - $page, - 'jumper' => $this, - ] - ); - } - - /** - * Loads the necessary elements to display the Jumper. - * - * @since 2.0.0 - * @return void - */ - public function load_jumper(): void { - - if ($this->is_jumper_enabled() && is_admin()) { - add_action('wu_header_right', [$this, 'add_jumper_trigger']); - - add_action('admin_init', [$this, 'rebuild_menu']); - - add_action('admin_enqueue_scripts', [$this, 'enqueue_scripts']); - - add_action('admin_enqueue_scripts', [$this, 'enqueue_styles']); - - add_action('admin_footer', [$this, 'output']); - - add_filter('update_footer', [$this, 'add_jumper_footer_message'], 200); - - add_action('wu_after_save_settings', [$this, 'clear_jump_cache_on_save']); - - add_filter('wu_link_list', [$this, 'add_wp_ultimo_extra_links']); - - add_filter('wu_link_list', [$this, 'add_user_custom_links']); - } - } - - /** - * Clear the jumper menu cache on settings save - * - * We need to do this to make sure that we clear the menu when the admin - * adds a new custom menu item. - * - * @since 2.0.0 - * - * @param array $settings Settings being saved. - * @return void - */ - public function clear_jump_cache_on_save($settings): void { - - if (isset($settings['jumper_custom_links'])) { - delete_site_transient($this->transient_key); - } - } - - /** - * Rebuilds the jumper menu via a trigger URL. - * - * @since 2.0.0 - * @return void - */ - public function rebuild_menu(): void { - - if (isset($_GET[ $this->reset_slug ]) && isset($_GET['nonce']) && current_user_can('manage_network') && wp_verify_nonce(sanitize_text_field(wp_unslash($_GET['nonce'])), 'reset_password')) { - delete_site_transient($this->transient_key); - - wp_safe_redirect(network_admin_url()); - - exit; - } - } - - /** - * Retrieves the custom links added by the super admin - * - * @since 2.0.0 - * @return array - */ - public function get_user_custom_links() { - - $treated_lines = []; - - $saved_links = wu_get_setting('jumper_custom_links'); - - $lines = explode(PHP_EOL, (string) $saved_links); - - foreach ($lines as $line) { - $link_elements = explode(':', $line, 2); - - if (count($link_elements) === 2) { - $title = trim($link_elements[1]); - - $treated_lines[ $title ] = trim($link_elements[0]); - } - } - - return $treated_lines; - } - - /** - * Add the custom links to the Jumper menu - * - * @since 2.0.0 - * - * @param array $links Jumper links already saved. - * @return array - */ - public function add_user_custom_links($links) { - - $custom_links = $this->get_user_custom_links(); - - if ( ! empty($custom_links)) { - $links[ __('Custom Links', 'ultimate-multisite') ] = $custom_links; - } - - return $links; - } - - /** - * Add Ultimate Multisite settings links to the Jumper menu. - * - * @since 2.0.0 - * - * @param array $links Ultimate Multisite settings array. - * @return array - */ - public function add_wp_ultimo_extra_links($links) { - - if (isset($links['WP Ultimo'])) { - $settings_tabs = [ - 'general' => __('General', 'ultimate-multisite'), - 'network' => __('Network Settings', 'ultimate-multisite'), - 'gateways' => __('Payment Gateways', 'ultimate-multisite'), - 'domain_mapping' => __('Domain Mapping & SSL', 'ultimate-multisite'), - 'emails' => __('Emails', 'ultimate-multisite'), - 'styling' => __('Styling', 'ultimate-multisite'), - 'tools' => __('Tools', 'ultimate-multisite'), - 'advanced' => __('Advanced', 'ultimate-multisite'), - 'activation' => __('Activation & Support', 'ultimate-multisite'), - ]; - - foreach ($settings_tabs as $tab => $tab_label) { - $url = network_admin_url('admin.php?page=wp-ultimo-settings&wu-tab=' . $tab); - - // translators: The placeholder represents the title of the Settings tab. - $links['WP Ultimo'][ $url ] = sprintf(__('Settings: %s', 'ultimate-multisite'), $tab_label); - } - - $links['WP Ultimo'][ network_admin_url('admin.php?page=wp-ultimo-settings&wu-tab=tools') ] = __('Settings: Webhooks', 'ultimate-multisite'); - - $links['WP Ultimo'][ network_admin_url('admin.php?page=wp-ultimo-system-info&wu-tab=logs') ] = __('System Info: Logs', 'ultimate-multisite'); - - /** - * Adds Main Site Dashboard - */ - if (isset($links[ __('Sites') ])) { // phpcs:ignore WordPress.WP.I18n.MissingArgDomain - $main_site_url = get_admin_url(get_current_site()->site_id); - - $links[ __('Sites') ][ $main_site_url ] = __('Main Site Dashboard', 'ultimate-multisite'); // phpcs:ignore WordPress.WP.I18n.MissingArgDomain - } - } - - return $links; - } - - /** - * Get the trigger key defined by the user. - * - * @since 2.0.0 - */ - public function get_defined_trigger_key(): string { - - return substr((string) wu_get_setting('jumper_key', 'g'), 0, 1); - } - - /** - * Get the trigger key combination depending on the OS - * - * - For Win & Linux: ctrl + alt + key defined by user; - * - For Mac: command + option + key defined by user. - * - * @since 2.0.0 - * - * @param string $os OS to get the key combination for. Options: win or osx. - * @return array - */ - public function get_keys($os = 'win') { - - $trigger_key = $this->get_defined_trigger_key(); - - $keys = [ - 'win' => ['ctrl', 'alt', $trigger_key], - 'osx' => ['command', 'option', $trigger_key], - ]; - - return $keys[ $os ] ?? $keys['win']; - } - - /** - * Changes the helper footer message about the Jumper and its trigger - * - * @since 2.0.0 - * - * @param string $text The default WordPress right footer message. - * @return string - */ - public function add_jumper_footer_message($text) { - - if ( ! wu_get_setting('jumper_display_tip', true)) { - return $text; - } - - $os = isset($_SERVER['HTTP_USER_AGENT']) && stristr(sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT'])), 'mac') ? 'osx' : 'win'; - - $keys = $this->get_keys($os); - - $html = ''; - - foreach ($keys as $key) { - $html .= '' . $key . '+'; - } - - $html = trim($html, '+'); - - // translators: the %s placeholder is the key combination to trigger the Jumper. - return '' . sprintf(__('Quick Tip: Use %s to jump between pages.', 'ultimate-multisite'), $html) . '' . $text; - } - - /** - * Enqueues the JavaScript files necessary to make the jumper work. - * - * @since 2.0.0 - * @return void - */ - public function enqueue_scripts(): void { - - wp_register_script('wu-mousetrap', wu_get_asset('mousetrap.js', 'js/lib'), ['jquery'], wu_get_version(), true); - - wp_register_script('wu-jumper', wu_get_asset('jumper.js', 'js'), ['jquery', 'wu-selectize', 'wu-mousetrap', 'underscore'], wu_get_version(), true); - - wp_localize_script( - 'wu-jumper', - 'wu_jumper_vars', - [ - 'not_found_message' => __('Nothing found for', 'ultimate-multisite'), - 'trigger_key' => $this->get_defined_trigger_key(), - 'network_base_url' => network_admin_url(), - 'ajaxurl' => wu_ajax_url(), - 'base_url' => get_admin_url(get_current_site()->site_id), - ] - ); - - wp_enqueue_script('wu-jumper'); - - wp_enqueue_style('wu-admin'); - } - - /** - * Enqueues the CSS files necessary to make the jumper work. - * - * @since 2.0.0 - * @return void - */ - public function enqueue_styles(): void { - - wp_enqueue_style('wu-jumper', wu_get_asset('jumper.css', 'css'), [], wu_get_version()); - } - - /** - * Outputs the actual HTML markup of the Jumper. - * - * @since 2.0.0 - * @return void - */ - public function output(): void { - - wu_get_template( - 'ui/jumper', - [ - 'menu_groups' => $this->get_link_list(), - ] - ); - } - - /** - * Get the full page URL for admin pages. - * - * @since 2.0.0 - * - * @param string $url URL of the menu item. - */ - public function get_menu_page_url($url): string { - - $final_url = menu_page_url($url, false); - - return str_replace(admin_url(), network_admin_url(), $final_url); - } - - /** - * Returns the URL of a jumper menu item - * - * If the URL is an absolute URL, returns the full-url. - * If the URL is relative, we return the full URL using WordPress url functions. - * - * @since 2.0.0 - * - * @param string $url URL of the menu item. - * @return string - */ - public function get_target_url($url) { - - if (str_contains($url, 'http')) { - return $url; - } - - if (str_contains($url, '.php')) { - return network_admin_url($url); - } - - return $this->get_menu_page_url($url); - } - - /** - * Builds the list of links based on the $menu and $submenu globals. - * - * @since 2.0.0 - * - * @return array - */ - public function build_link_list() { - - return Logger::track_time( - 'jumper', - __('Regenerating Jumper menu items', 'ultimate-multisite'), - function () { - - global $menu, $submenu; - - // This variable is going to carry our options - $choices = []; - - // Prevent first run bug - if ( ! is_array($menu) || ! is_array($submenu)) { - return []; - } - - // Loop all submenus so que can get our final - foreach ($submenu as $menu_name => $submenu_items) { - $title = $this->search_recursive($menu_name, $menu); - - $string = wu_get_isset($title, 0, ''); - - $title = preg_replace('/[0-9]+/', '', wp_strip_all_tags($string)); - - // If parent does not exists, skip - if ( ! empty($title) && is_array($submenu_items)) { - - // We have to loop now each submenu - foreach ($submenu_items as $submenu_item) { - $url = $this->get_target_url($submenu_item[2]); - - // Add to our choices the admin urls - } - } - } - - $choices = apply_filters('wu_link_list', $choices); - - set_site_transient($this->transient_key, $choices, 10 * MINUTE_IN_SECONDS); - - return $choices; - } - ); - } - - /** - * Gets the cached menu list saved. - * - * @since 2.0.0 - * @return array - */ - public function get_saved_menu() { - - $saved_menu = get_site_transient($this->transient_key); - - return $saved_menu ?: []; - } - - /** - * Returns the link list. - * - * @since 2.0.0 - * @return array - */ - public function get_link_list() { - - $should_rebuild_menu = ! get_site_transient($this->transient_key); - - return $should_rebuild_menu && is_network_admin() ? $this->build_link_list() : $this->get_saved_menu(); - } - - /** - * Filter the Ultimate Multisite settings to add Jumper options - * - * @since 2.0.0 - * - * @return void - */ - public function add_settings(): void { - - wu_register_settings_section( - 'tools', - [ - 'title' => __('Tools', 'ultimate-multisite'), - 'desc' => __('Tools', 'ultimate-multisite'), - 'icon' => 'dashicons-wu-tools', - ] - ); - - wu_register_settings_field( - 'tools', - 'tools_header', - [ - 'title' => __('Jumper', 'ultimate-multisite'), - 'desc' => __('Spotlight-like search bar that allows you to easily access everything on your network.', 'ultimate-multisite'), - 'type' => 'header', - ] - ); - - wu_register_settings_field( - 'tools', - 'enable_jumper', - [ - 'title' => __('Enable Jumper', 'ultimate-multisite'), - 'desc' => __('Turn this option on to make the Jumper available on your network.', 'ultimate-multisite'), - 'type' => 'toggle', - 'default' => 1, - ] - ); - - wu_register_settings_field( - 'tools', - 'jumper_key', - [ - 'title' => __('Trigger Key', 'ultimate-multisite'), - 'desc' => __('Change the keyboard key used in conjunction with ctrl + alt (or cmd + option), to trigger the Jumper box.', 'ultimate-multisite'), - 'type' => 'text', - 'default' => 'g', - 'require' => [ - 'enable_jumper' => 1, - ], - ] - ); - - wu_register_settings_field( - 'tools', - 'jumper_custom_links', - [ - 'title' => __('Custom Links', 'ultimate-multisite'), - 'desc' => __('Use this textarea to add custom links to the Jumper. Add one per line, with the format "Title : url".', 'ultimate-multisite'), - 'placeholder' => __('Tile of Custom Link : http://link.com', 'ultimate-multisite'), - 'type' => 'textarea', - 'html_attr' => [ - 'rows' => 4, - ], - 'require' => [ - 'enable_jumper' => 1, - ], - ] - ); - } - - /** - * Helper function to recursively seach an array. - * - * @since 2.0.0 - * - * @param string $needle String to seach recursively. - * @param array $haystack Array to search. - * @return mixed - */ - public function search_recursive($needle, $haystack) { - - foreach ($haystack as $key => $value) { - $current_key = $key; - - if ($needle === $value || (is_array($value) && $this->search_recursive($needle, $value) !== false)) { - return $value; - } - } - - return false; - } -}