From 868ee9e8792b2df5230792ed66d0b9a33e185a8a Mon Sep 17 00:00:00 2001 From: Shea Bunge Date: Thu, 24 Jul 2025 13:03:09 +1000 Subject: [PATCH 01/15] Bootstrap new admin menu for snippets table uplift. --- config/webpack-js.ts | 3 +- package-lock.json | 4 +- package.json | 2 +- src/code-snippets.php | 6 +- src/composer.lock | 10 +- src/css/manage-legacy.scss | 213 ++++++++++++++++ src/css/{manage => manage-legacy}/_cloud.scss | 0 src/css/manage.scss | 213 ---------------- .../components/SnippetForm/page/Notices.tsx | 2 +- .../SnippetsTable/SnippetsTable.tsx | 4 + src/js/components/SnippetsTable/index.ts | 1 + .../{ => common}/DismissableNotice.tsx | 0 src/js/edit.ts | 4 + src/js/edit.tsx | 12 - src/js/hooks/useRestAPI.tsx | 6 +- src/js/hooks/useSnippetForm.tsx | 2 +- src/js/hooks/useSnippetsList.tsx | 2 +- src/js/manage-legacy.ts | 5 + src/js/manage.ts | 7 +- src/js/types/Snippet.ts | 3 - src/js/utils/bootstrap.tsx | 34 +++ src/js/utils/hooks.ts | 21 -- src/js/utils/snippets.ts | 10 +- .../admin-menus/class-manage-menu-legacy.php | 231 ++++++++++++++++++ src/php/admin-menus/class-manage-menu.php | 168 +++---------- src/php/class-admin.php | 3 +- src/php/class-plugin.php | 12 +- src/php/views/manage.php | 2 +- src/php/views/partials/cloud-search.php | 2 +- src/php/views/partials/list-table-notices.php | 2 +- src/php/views/partials/list-table.php | 2 +- src/readme.txt | 2 +- 32 files changed, 570 insertions(+), 418 deletions(-) create mode 100644 src/css/manage-legacy.scss rename src/css/{manage => manage-legacy}/_cloud.scss (100%) create mode 100644 src/js/components/SnippetsTable/SnippetsTable.tsx create mode 100644 src/js/components/SnippetsTable/index.ts rename src/js/components/{ => common}/DismissableNotice.tsx (100%) create mode 100644 src/js/edit.ts delete mode 100644 src/js/edit.tsx create mode 100644 src/js/manage-legacy.ts create mode 100644 src/js/utils/bootstrap.tsx delete mode 100644 src/js/utils/hooks.ts create mode 100644 src/php/admin-menus/class-manage-menu-legacy.php diff --git a/config/webpack-js.ts b/config/webpack-js.ts index b8a0d1fb..c46f80b9 100644 --- a/config/webpack-js.ts +++ b/config/webpack-js.ts @@ -24,9 +24,10 @@ const babelConfig = { export const jsWebpackConfig: Configuration = { entry: { - edit: { import: `${SOURCE_DIR}/edit.tsx`, dependOn: 'editor' }, + edit: { import: `${SOURCE_DIR}/edit.ts`, dependOn: 'editor' }, editor: `${SOURCE_DIR}/editor.ts`, manage: `${SOURCE_DIR}/manage.ts`, + 'manage-legacy': `${SOURCE_DIR}/manage-legacy.ts`, mce: `${SOURCE_DIR}/mce.ts`, prism: `${SOURCE_DIR}/prism.ts`, settings: { import: `${SOURCE_DIR}/settings.ts`, dependOn: 'editor' } diff --git a/package-lock.json b/package-lock.json index 0ff4790b..f8ca4bee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "code-snippets", - "version": "3.7.0-beta.1", + "version": "3.8.0-dev.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "code-snippets", - "version": "3.7.0-beta.1", + "version": "3.8.0-dev.1", "license": "GPL-2.0-or-later", "dependencies": { "@codemirror/fold": "^0.19.4", diff --git a/package.json b/package.json index b13552b4..6c04dc79 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "code-snippets", "description": "Manage code snippets running on a WordPress-powered site through a graphical interface.", "homepage": "https://codesnippets.pro", - "version": "3.7.0-beta.1", + "version": "3.8.0-dev.1", "main": "src/dist/edit.js", "directories": { "test": "tests" diff --git a/src/code-snippets.php b/src/code-snippets.php index 70841f72..3eac6ba8 100644 --- a/src/code-snippets.php +++ b/src/code-snippets.php @@ -8,11 +8,11 @@ * License: GPL-2.0-or-later * License URI: license.txt * Text Domain: code-snippets - * Version: 3.7.0-beta.1 + * Version: 3.8.0-dev.1 * Requires PHP: 7.4 * Requires at least: 5.0 * - * @version 3.7.0-beta.1 + * @version 3.8.0-dev.1 * @package Code_Snippets * @author Shea Bunge * @copyright 2012-2024 Code Snippets Pro @@ -37,7 +37,7 @@ * * @const string */ - define( 'CODE_SNIPPETS_VERSION', '3.7.0-beta.1' ); + define( 'CODE_SNIPPETS_VERSION', '3.8.0-dev.1' ); /** * The full path to the main file of this plugin. diff --git a/src/composer.lock b/src/composer.lock index b256075a..3113a000 100644 --- a/src/composer.lock +++ b/src/composer.lock @@ -156,16 +156,16 @@ "packages-dev": [ { "name": "dealerdirect/phpcodesniffer-composer-installer", - "version": "v1.1.1", + "version": "v1.1.2", "source": { "type": "git", "url": "https://github.com/PHPCSStandards/composer-installer.git", - "reference": "6e0fa428497bf560152ee73ffbb8af5c6a56b0dd" + "reference": "e9cf5e4bbf7eeaf9ef5db34938942602838fc2b1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/composer-installer/zipball/6e0fa428497bf560152ee73ffbb8af5c6a56b0dd", - "reference": "6e0fa428497bf560152ee73ffbb8af5c6a56b0dd", + "url": "https://api.github.com/repos/PHPCSStandards/composer-installer/zipball/e9cf5e4bbf7eeaf9ef5db34938942602838fc2b1", + "reference": "e9cf5e4bbf7eeaf9ef5db34938942602838fc2b1", "shasum": "" }, "require": { @@ -248,7 +248,7 @@ "type": "thanks_dev" } ], - "time": "2025-06-27T17:24:01+00:00" + "time": "2025-07-17T20:45:56+00:00" }, { "name": "phpcompatibility/php-compatibility", diff --git a/src/css/manage-legacy.scss b/src/css/manage-legacy.scss new file mode 100644 index 00000000..d7a9972c --- /dev/null +++ b/src/css/manage-legacy.scss @@ -0,0 +1,213 @@ +/** + * Custom styling for the snippets table + */ + +@use 'sass:map'; +@use 'sass:color'; +@use 'common/theme'; +@use 'common/badges'; +@use 'common/switch'; +@use 'common/select'; +@use 'manage-legacy/cloud'; + +.column-name, +.column-type { + .dashicons { + font-size: 16px; + inline-size: 16px; + block-size: 16px; + vertical-align: middle; + } + + .dashicons-clock { + vertical-align: middle; + } +} + +.active-snippet .column-name > a { + font-weight: 600; +} + +.active-snippet { + td, th { + background-color: rgba(#78c8e6, 0.06); + } + + th.check-column { + border-inline-start: 2px solid #2ea2cc; + } +} + +.column-priority input { + appearance: none; + background: none; + border: none; + box-shadow: none; + inline-size: 4em; + color: #666; + text-align: center; + + &:hover, &:focus, &:active { + color: #000; + background-color: #f5f5f5; + background-color: rgb(0 0 0 / 10%); + border-radius: 6px; + } + + &:disabled { + color: inherit; + } +} + +.clear-filters { + vertical-align: baseline !important; +} + +.snippets { + td.column-id { + text-align: center; + } + + tr { + background: #fff; + } + + ol, ul { + margin: 0 0 1.5em 1.5em; + } + + ul { + list-style: disc; + } + + th.sortable a, th.sorted a { + display: flex; + flex-direction: row; + } + + .row-actions { + color: #ddd; + position: relative; + inset-inline-start: 0; + } + + .column-activate { + padding-inline-end: 0 !important; + } + + .clear-filters { + vertical-align: middle; + } + + tfoot th.check-column { + padding: 13px 0 0 3px; + } + + thead th.check-column, + tfoot th.check-column, + .inactive-snippet th.check-column { + padding-inline-start: 5px; + } + + .active-snippet, .inactive-snippet { + td, th { + padding: 10px 9px; + border: none; + box-shadow: inset 0 -1px 0 rgb(0 0 0 / 10%); + } + } + + tr.active-snippet + tr.inactive-snippet th, + tr.active-snippet + tr.inactive-snippet td { + border-block-start: 1px solid rgb(0 0 0 / 3%); + box-shadow: inset 0 1px 0 rgb(0 0 0 / 2%), inset 0 -1px 0 #e1e1e1; + } + + &, #all-snippets-table, #search-snippets-table { + a.delete:hover { + border-block-end: 1px solid #f00; + color: #f00; + } + } + + #wpbody-content & .column-name { + white-space: nowrap; /* prevents wrapping of snippet title */ + } +} + +td.column-description { + max-inline-size: 700px; + + pre { + white-space: unset; + } +} + +.inactive-snippet { + @include theme.link-colors(#579); +} + +@media screen and (width <= 782px) { + p.search-box { + float: inline-start; + position: initial; + margin: 1em 0 0; + block-size: auto; + } +} + +.wp-list-table .is-expanded td.column-activate.activate { + /* fix for mobile layout */ + display: table-cell !important; +} + +.nav-tab-wrapper + .subsubsub, p.search-box { + margin: 10px 0 0; +} + +.snippet-type-description { + border-block-end: 1px solid #ccc; + margin: 0; + padding: 1em 0; +} + +.code-snippets-notice a.notice-dismiss { + text-decoration: none; +} + +.refresh-button-container { + display: flex; + align-items: center; + justify-content: flex-start; + margin-block: 15px -39px; + gap: 7px; +} + +#refresh-button { + inline-size: 30px; + padding: 0; + font-size: 20px; + line-height: 1.4; +} + +.wrap h2.nav-tab-wrapper { + display: flex; + flex-flow: row wrap-reverse; + gap: 0.5em; + + .nav-tab { + display: flex; + flex-flow: row wrap; + align-items: center; + gap: 8px; + margin: 0; + } +} + +@media screen and (width <= 1190px) { + .nav-tab { + .snippet-label { + display: none; + } + } +} diff --git a/src/css/manage/_cloud.scss b/src/css/manage-legacy/_cloud.scss similarity index 100% rename from src/css/manage/_cloud.scss rename to src/css/manage-legacy/_cloud.scss diff --git a/src/css/manage.scss b/src/css/manage.scss index 82a88322..e69de29b 100644 --- a/src/css/manage.scss +++ b/src/css/manage.scss @@ -1,213 +0,0 @@ -/** - * Custom styling for the snippets table - */ - -@use 'sass:map'; -@use 'sass:color'; -@use 'common/theme'; -@use 'common/badges'; -@use 'common/switch'; -@use 'common/select'; -@use 'manage/cloud'; - -.column-name, -.column-type { - .dashicons { - font-size: 16px; - inline-size: 16px; - block-size: 16px; - vertical-align: middle; - } - - .dashicons-clock { - vertical-align: middle; - } -} - -.active-snippet .column-name > a { - font-weight: 600; -} - -.active-snippet { - td, th { - background-color: rgba(#78c8e6, 0.06); - } - - th.check-column { - border-inline-start: 2px solid #2ea2cc; - } -} - -.column-priority input { - appearance: none; - background: none; - border: none; - box-shadow: none; - inline-size: 4em; - color: #666; - text-align: center; - - &:hover, &:focus, &:active { - color: #000; - background-color: #f5f5f5; - background-color: rgb(0 0 0 / 10%); - border-radius: 6px; - } - - &:disabled { - color: inherit; - } -} - -.clear-filters { - vertical-align: baseline !important; -} - -.snippets { - td.column-id { - text-align: center; - } - - tr { - background: #fff; - } - - ol, ul { - margin: 0 0 1.5em 1.5em; - } - - ul { - list-style: disc; - } - - th.sortable a, th.sorted a { - display: flex; - flex-direction: row; - } - - .row-actions { - color: #ddd; - position: relative; - inset-inline-start: 0; - } - - .column-activate { - padding-inline-end: 0 !important; - } - - .clear-filters { - vertical-align: middle; - } - - tfoot th.check-column { - padding: 13px 0 0 3px; - } - - thead th.check-column, - tfoot th.check-column, - .inactive-snippet th.check-column { - padding-inline-start: 5px; - } - - .active-snippet, .inactive-snippet { - td, th { - padding: 10px 9px; - border: none; - box-shadow: inset 0 -1px 0 rgb(0 0 0 / 10%); - } - } - - tr.active-snippet + tr.inactive-snippet th, - tr.active-snippet + tr.inactive-snippet td { - border-block-start: 1px solid rgb(0 0 0 / 3%); - box-shadow: inset 0 1px 0 rgb(0 0 0 / 2%), inset 0 -1px 0 #e1e1e1; - } - - &, #all-snippets-table, #search-snippets-table { - a.delete:hover { - border-block-end: 1px solid #f00; - color: #f00; - } - } - - #wpbody-content & .column-name { - white-space: nowrap; /* prevents wrapping of snippet title */ - } -} - -td.column-description { - max-inline-size: 700px; - - pre { - white-space: unset; - } -} - -.inactive-snippet { - @include theme.link-colors(#579); -} - -@media screen and (width <= 782px) { - p.search-box { - float: inline-start; - position: initial; - margin: 1em 0 0; - block-size: auto; - } -} - -.wp-list-table .is-expanded td.column-activate.activate { - /* fix for mobile layout */ - display: table-cell !important; -} - -.nav-tab-wrapper + .subsubsub, p.search-box { - margin: 10px 0 0; -} - -.snippet-type-description { - border-block-end: 1px solid #ccc; - margin: 0; - padding: 1em 0; -} - -.code-snippets-notice a.notice-dismiss { - text-decoration: none; -} - -.refresh-button-container { - display: flex; - align-items: center; - justify-content: flex-start; - margin-block: 15px -39px; - gap: 7px; -} - -#refresh-button { - inline-size: 30px; - padding: 0; - font-size: 20px; - line-height: 1.4; -} - -.wrap h2.nav-tab-wrapper { - display: flex; - flex-flow: row wrap-reverse; - gap: 0.5em; - - .nav-tab { - display: flex; - flex-flow: row wrap; - align-items: center; - gap: 8px; - margin: 0; - } -} - -@media screen and (width <= 1190px) { - .nav-tab { - .snippet-label { - display: none; - } - } -} diff --git a/src/js/components/SnippetForm/page/Notices.tsx b/src/js/components/SnippetForm/page/Notices.tsx index 7b8523f4..5773752b 100644 --- a/src/js/components/SnippetForm/page/Notices.tsx +++ b/src/js/components/SnippetForm/page/Notices.tsx @@ -1,7 +1,7 @@ import React from 'react' import { __, sprintf } from '@wordpress/i18n' import { useSnippetForm } from '../../../hooks/useSnippetForm' -import { DismissibleNotice } from '../../DismissableNotice' +import { DismissibleNotice } from '../../common/DismissableNotice' export const Notices: React.FC = () => { const { currentNotice, setCurrentNotice, snippet, setSnippet } = useSnippetForm() diff --git a/src/js/components/SnippetsTable/SnippetsTable.tsx b/src/js/components/SnippetsTable/SnippetsTable.tsx new file mode 100644 index 00000000..71c97d6d --- /dev/null +++ b/src/js/components/SnippetsTable/SnippetsTable.tsx @@ -0,0 +1,4 @@ +import React from "react" + +export const SnippetsTable: React.FC = () => +
TODO
diff --git a/src/js/components/SnippetsTable/index.ts b/src/js/components/SnippetsTable/index.ts new file mode 100644 index 00000000..b8db6e57 --- /dev/null +++ b/src/js/components/SnippetsTable/index.ts @@ -0,0 +1 @@ +export * from './SnippetsTable' diff --git a/src/js/components/DismissableNotice.tsx b/src/js/components/common/DismissableNotice.tsx similarity index 100% rename from src/js/components/DismissableNotice.tsx rename to src/js/components/common/DismissableNotice.tsx diff --git a/src/js/edit.ts b/src/js/edit.ts new file mode 100644 index 00000000..0bbf0d2f --- /dev/null +++ b/src/js/edit.ts @@ -0,0 +1,4 @@ +import { SnippetForm } from './components/SnippetForm' +import { loadComponent } from './utils/bootstrap' + +loadComponent('edit-snippet-form-container', SnippetForm) diff --git a/src/js/edit.tsx b/src/js/edit.tsx deleted file mode 100644 index 8968cc23..00000000 --- a/src/js/edit.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import React from 'react' -import { createRoot } from 'react-dom/client' -import { SnippetForm } from './components/SnippetForm' - -const container = document.getElementById('edit-snippet-form-container') - -if (container) { - const root = createRoot(container) - root.render() -} else { - console.error('Could not find snippet edit form container.') -} diff --git a/src/js/hooks/useRestAPI.tsx b/src/js/hooks/useRestAPI.tsx index fc79de06..4e945095 100644 --- a/src/js/hooks/useRestAPI.tsx +++ b/src/js/hooks/useRestAPI.tsx @@ -1,6 +1,6 @@ import React, { useMemo } from 'react' import axios from 'axios' -import { createContextHook } from '../utils/hooks' +import { createContextHook } from '../utils/bootstrap' import { REST_API_AXIOS_CONFIG } from '../utils/restAPI' import { buildSnippetsAPI } from '../utils/snippets/api' import type { SnippetsAPI } from '../utils/snippets/api' @@ -36,13 +36,13 @@ const buildRestAPI = (axiosInstance: AxiosInstance): RestAPI => ({ get: (url: string): Promise => debugRequest('GET', url, axiosInstance.get, never>(url)), - post: (url: string, data?: object): Promise => + post: (url: string, data?: D): Promise => debugRequest('POST', url, axiosInstance.post, typeof data>(url, data), data), del: (url: string): Promise => debugRequest('DELETE', url, axiosInstance.delete, never>(url)), - put: (url: string, data?: object): Promise => + put: (url: string, data?: D): Promise => debugRequest('PUT', url, axiosInstance.put, typeof data>(url, data), data) }) diff --git a/src/js/hooks/useSnippetForm.tsx b/src/js/hooks/useSnippetForm.tsx index 458b9f39..645d5a0f 100644 --- a/src/js/hooks/useSnippetForm.tsx +++ b/src/js/hooks/useSnippetForm.tsx @@ -1,6 +1,6 @@ import { isAxiosError } from 'axios' import React, { useCallback, useMemo, useState } from 'react' -import { createContextHook } from '../utils/hooks' +import { createContextHook } from '../utils/bootstrap' import { isLicensed } from '../utils/screen' import { isProSnippet } from '../utils/snippets/snippets' import type { Dispatch, PropsWithChildren, SetStateAction } from 'react' diff --git a/src/js/hooks/useSnippetsList.tsx b/src/js/hooks/useSnippetsList.tsx index 06325cc4..f65c6566 100644 --- a/src/js/hooks/useSnippetsList.tsx +++ b/src/js/hooks/useSnippetsList.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useState } from 'react' -import { createContextHook } from '../utils/hooks' +import { createContextHook } from '../utils/bootstrap' import { isNetworkAdmin } from '../utils/screen' import { useRestAPI } from './useRestAPI' import type { PropsWithChildren } from 'react' diff --git a/src/js/manage-legacy.ts b/src/js/manage-legacy.ts new file mode 100644 index 00000000..634cad9b --- /dev/null +++ b/src/js/manage-legacy.ts @@ -0,0 +1,5 @@ +import { handleShowCloudPreview, handleSnippetActivationSwitches, handleSnippetPriorityChanges } from './services/manage' + +handleSnippetActivationSwitches() +handleSnippetPriorityChanges() +handleShowCloudPreview() diff --git a/src/js/manage.ts b/src/js/manage.ts index 634cad9b..e75a9281 100644 --- a/src/js/manage.ts +++ b/src/js/manage.ts @@ -1,5 +1,4 @@ -import { handleShowCloudPreview, handleSnippetActivationSwitches, handleSnippetPriorityChanges } from './services/manage' +import { SnippetsTable } from './components/SnippetsTable' +import { loadComponent } from './utils/bootstrap' -handleSnippetActivationSwitches() -handleSnippetPriorityChanges() -handleShowCloudPreview() +loadComponent('snippets-table-container', SnippetsTable) diff --git a/src/js/types/Snippet.ts b/src/js/types/Snippet.ts index 20761992..dfdec93f 100644 --- a/src/js/types/Snippet.ts +++ b/src/js/types/Snippet.ts @@ -1,5 +1,3 @@ -import type { ConditionGroups } from './ConditionGroups' - export interface Snippet { readonly id: number readonly name: string @@ -14,7 +12,6 @@ export interface Snippet { readonly modified?: string readonly conditionId: number readonly code_error?: readonly [string, number] | null - readonly conditions: ConditionGroups } export type SnippetCodeType = 'php' | 'html' | 'css' | 'js' diff --git a/src/js/utils/bootstrap.tsx b/src/js/utils/bootstrap.tsx new file mode 100644 index 00000000..978a7429 --- /dev/null +++ b/src/js/utils/bootstrap.tsx @@ -0,0 +1,34 @@ +import { createContext, useContext } from 'react' +import { createRoot } from 'react-dom/client' +import type { Context, FunctionComponent } from 'react' +import React from 'react' + +export const loadComponent = (containerId: string, Component: FunctionComponent): void => { + const container = document.getElementById('snippets-table-container') + + if (container) { + const root = createRoot(container) + root.render() + } else { + console.error(`Could not find ${containerId.replace(/-_/, ' ')}.`) + } +} + +export const createContextHook = (name: string): [ + Context, + () => T +] => { + const contextValue = createContext(undefined) + + const useContextHook = (): T => { + const value = useContext(contextValue) + + if (value === undefined) { + throw Error(`use${name} can only be used within a ${name} context provider.`) + } + + return value + } + + return [contextValue, useContextHook] +} diff --git a/src/js/utils/hooks.ts b/src/js/utils/hooks.ts deleted file mode 100644 index 3a52d8c2..00000000 --- a/src/js/utils/hooks.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { createContext, useContext } from 'react' -import type { Context } from 'react' - -export const createContextHook = (name: string): [ - Context, - () => T -] => { - const contextValue = createContext(undefined) - - const useContextHook = (): T => { - const value = useContext(contextValue) - - if (value === undefined) { - throw Error(`use${name} can only be used within a ${name} context provider.`) - } - - return value - } - - return [contextValue, useContextHook] -} diff --git a/src/js/utils/snippets.ts b/src/js/utils/snippets.ts index 123d826c..63c167f3 100644 --- a/src/js/utils/snippets.ts +++ b/src/js/utils/snippets.ts @@ -14,7 +14,8 @@ const defaults: Omit = { active: false, network: isNetworkAdmin(), shared_network: null, - priority: 10 + priority: 10, + conditionId: 0 } const isAbsInt = (value: unknown): value is number => @@ -44,10 +45,13 @@ export const createSnippetObject = (fields: unknown = undefined): Snippet => { scope: 'scope' in fields && isValidScope(fields.scope) ? fields.scope : defaults.scope, modified: 'modified' in fields && 'string' === typeof fields.modified ? fields.modified : defaults.modified, active: 'active' in fields && 'boolean' === typeof fields.active ? fields.active : defaults.active, - network: 'network' in fields && isBooleanOrUndefined(fields.network) ? fields.network : defaults.network, + network: 'network' in fields && 'boolean' === typeof fields.network ? fields.network : defaults.network, shared_network: 'shared_network' in fields && isBooleanOrUndefined(fields.shared_network) ? fields.shared_network : defaults.shared_network, - priority: 'priority' in fields && 'number' === typeof fields.priority ? fields.priority : defaults.priority + priority: 'priority' in fields && 'number' === typeof fields.priority ? fields.priority : defaults.priority, + conditionId: 'conditionId' in fields && isAbsInt(fields.conditionId) ? fields.conditionId + : 'condition_id' in fields && isAbsInt(fields.condition_id) ? fields.condition_id + : defaults.conditionId } } diff --git a/src/php/admin-menus/class-manage-menu-legacy.php b/src/php/admin-menus/class-manage-menu-legacy.php new file mode 100644 index 00000000..dd3a688f --- /dev/null +++ b/src/php/admin-menus/class-manage-menu-legacy.php @@ -0,0 +1,231 @@ +load(); + + $this->cloud_search_list_table = new Cloud_Search_List_Table(); + $this->cloud_search_list_table->prepare_items(); + + $this->list_table = new List_Table(); + $this->list_table->prepare_items(); + } + + /** + * Render the menu + */ + public function render() { + $this->render_view( 'manage' ); + } + + /** + * Enqueue scripts and stylesheets for the admin page. + */ + public function enqueue_assets() { + $plugin = code_snippets(); + + wp_enqueue_style( + 'code-snippets-manage-legacy', + plugins_url( 'dist/manage-legacy.css', $plugin->file ), + [], + $plugin->version + ); + + wp_enqueue_script( + 'code-snippets-manage-legacy-js', + plugins_url( 'dist/manage-legacy.js', $plugin->file ), + [ 'wp-i18n' ], + $plugin->version, + true + ); + + wp_set_script_translations( 'code-snippets-manage-legacy-js', 'code-snippets' ); + + if ( 'cloud' === $this->get_current_type() || 'cloud_search' === $this->get_current_type() ) { + Front_End::enqueue_all_prism_themes(); + } + } + + /** + * Get the currently displayed snippet type. + * + * @return string + */ + protected function get_current_type(): string { + $types = Plugin::get_types(); + $current_type = isset( $_GET['type'] ) ? sanitize_key( wp_unslash( $_GET['type'] ) ) : 'all'; + return isset( $types[ $current_type ] ) ? $current_type : 'all'; + } + + /** + * Print the status and error messages + * + * @return void + */ + protected function print_messages() { + $this->render_view( 'partials/list-table-notices' ); + } + + /** + * Handles saving the user's snippets per page preference + * + * @param mixed $status Current screen option status. + * @param string $option The screen option name. + * @param mixed $value Screen option value. + * + * @return mixed + */ + public function save_screen_option( $status, string $option, $value ) { + return 'snippets_per_page' === $option ? $value : $status; + } + + /** + * Update the priority value for a snippet. + * + * @param Snippet $snippet Snippet to update. + * + * @return void + */ + private function update_snippet_priority( Snippet $snippet ) { + global $wpdb; + $table = code_snippets()->db->get_table_name( $snippet->network ); + + $wpdb->update( + $table, + array( 'priority' => $snippet->priority ), + array( 'id' => $snippet->id ), + array( '%d' ), + array( '%d' ) + ); + + clean_snippets_cache( $table ); + } + + /** + * Handle AJAX requests + */ + public function ajax_callback() { + check_ajax_referer( 'code_snippets_manage_ajax' ); + + if ( ! isset( $_POST['field'], $_POST['snippet'] ) ) { + wp_send_json_error( + array( + 'type' => 'param_error', + 'message' => 'incomplete request', + ) + ); + } + + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + $snippet_data = array_map( 'sanitize_text_field', json_decode( wp_unslash( $_POST['snippet'] ), true ) ); + + $snippet = new Snippet( $snippet_data ); + $field = sanitize_key( $_POST['field'] ); + + if ( 'priority' === $field ) { + + if ( ! isset( $snippet_data['priority'] ) || ! is_numeric( $snippet_data['priority'] ) ) { + wp_send_json_error( + array( + 'type' => 'param_error', + 'message' => 'missing snippet priority data', + ) + ); + } + + $this->update_snippet_priority( $snippet ); + + } elseif ( 'active' === $field ) { + + if ( ! isset( $snippet_data['active'] ) ) { + wp_send_json_error( + array( + 'type' => 'param_error', + 'message' => 'missing snippet active data', + ) + ); + } + + if ( $snippet->shared_network ) { + $active_shared_snippets = get_option( 'active_shared_network_snippets', array() ); + + if ( in_array( $snippet->id, $active_shared_snippets, true ) !== $snippet->active ) { + + $active_shared_snippets = $snippet->active ? + array_merge( $active_shared_snippets, array( $snippet->id ) ) : + array_diff( $active_shared_snippets, array( $snippet->id ) ); + + update_option( 'active_shared_network_snippets', $active_shared_snippets ); + clean_active_snippets_cache( code_snippets()->db->ms_table ); + } + } elseif ( $snippet->active ) { + $result = activate_snippet( $snippet->id, $snippet->network ); + if ( is_string( $result ) ) { + wp_send_json_error( + array( + 'type' => 'action_error', + 'message' => $result, + ) + ); + } + } else { + deactivate_snippet( $snippet->id, $snippet->network ); + } + } + + wp_send_json_success(); + } +} diff --git a/src/php/admin-menus/class-manage-menu.php b/src/php/admin-menus/class-manage-menu.php index 4ac2b438..17dd318d 100644 --- a/src/php/admin-menus/class-manage-menu.php +++ b/src/php/admin-menus/class-manage-menu.php @@ -14,18 +14,14 @@ class Manage_Menu extends Admin_Menu { /** - * Instance of the list table class. - * - * @var List_Table + * Handle for JavaScript asset file. */ - public List_Table $list_table; + const JS_HANDLE = 'code-snippets-manage-menu'; /** - * Instance of the cloud list table class for search results. - * - * @var Cloud_Search_List_Table + * Handle for CSS asset file. */ - public Cloud_Search_List_Table $cloud_search_list_table; + const CSS_HANDLE = 'code-snippets-manage'; /** * Class constructor @@ -167,19 +163,13 @@ public function register_compact_menu() { } /** - * Executed when the admin page is loaded + * Executed when the admin page is loaded. */ public function load() { parent::load(); - $contextual_help = new Contextual_Help( 'manage' ); + $contextual_help = new Contextual_Help( 'edit' ); $contextual_help->load(); - - $this->cloud_search_list_table = new Cloud_Search_List_Table(); - $this->cloud_search_list_table->prepare_items(); - - $this->list_table = new List_Table(); - $this->list_table->prepare_items(); } /** @@ -190,45 +180,52 @@ public function enqueue_assets() { $rtl = is_rtl() ? '-rtl' : ''; wp_enqueue_style( - 'code-snippets-manage', + self::CSS_HANDLE, plugins_url( "dist/manage$rtl.css", $plugin->file ), - [], + [ + 'wp-components', + ], $plugin->version ); wp_enqueue_script( - 'code-snippets-manage-js', + self::JS_HANDLE, plugins_url( 'dist/manage.js', $plugin->file ), - [ 'wp-i18n' ], + [ + 'react', + 'react-dom', + 'wp-url', + 'wp-i18n', + 'wp-components', + ], $plugin->version, true ); - wp_set_script_translations( 'code-snippets-manage-js', 'code-snippets' ); + Front_End::enqueue_all_prism_themes(); - if ( 'cloud' === $this->get_current_type() || 'cloud_search' === $this->get_current_type() ) { - Front_End::enqueue_all_prism_themes(); - } - } + wp_set_script_translations( self::JS_HANDLE, 'code-snippets' ); + $plugin->localize_script( self::JS_HANDLE ); - /** - * Get the currently displayed snippet type. - * - * @return string - */ - protected function get_current_type(): string { - $types = Plugin::get_types(); - $current_type = isset( $_GET['type'] ) ? sanitize_key( wp_unslash( $_GET['type'] ) ) : 'all'; - return isset( $types[ $current_type ] ) ? $current_type : 'all'; + wp_localize_script( + self::JS_HANDLE, + 'CODE_SNIPPETS_MANAGE', + [ + 'pageTitleActions' => $plugin->is_compact_menu() ? $this->page_title_action_links( [ 'add', 'import', 'settings' ] ) : [], + ] + ); } /** - * Print the status and error messages + * Render the snippets table interface. * * @return void */ - protected function print_messages() { - $this->render_view( 'partials/list-table-notices' ); + public function render() { + printf( + '
%s
', + esc_html__( 'Loading snippets table…', 'code-snippets' ) + ); } /** @@ -243,101 +240,4 @@ protected function print_messages() { public function save_screen_option( $status, string $option, $value ) { return 'snippets_per_page' === $option ? $value : $status; } - - /** - * Update the priority value for a snippet. - * - * @param Snippet $snippet Snippet to update. - * - * @return void - */ - private function update_snippet_priority( Snippet $snippet ) { - global $wpdb; - $table = code_snippets()->db->get_table_name( $snippet->network ); - - $wpdb->update( - $table, - array( 'priority' => $snippet->priority ), - array( 'id' => $snippet->id ), - array( '%d' ), - array( '%d' ) - ); - - clean_snippets_cache( $table ); - } - - /** - * Handle AJAX requests - */ - public function ajax_callback() { - check_ajax_referer( 'code_snippets_manage_ajax' ); - - if ( ! isset( $_POST['field'], $_POST['snippet'] ) ) { - wp_send_json_error( - array( - 'type' => 'param_error', - 'message' => 'incomplete request', - ) - ); - } - - // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized - $snippet_data = array_map( 'sanitize_text_field', json_decode( wp_unslash( $_POST['snippet'] ), true ) ); - - $snippet = new Snippet( $snippet_data ); - $field = sanitize_key( $_POST['field'] ); - - if ( 'priority' === $field ) { - - if ( ! isset( $snippet_data['priority'] ) || ! is_numeric( $snippet_data['priority'] ) ) { - wp_send_json_error( - array( - 'type' => 'param_error', - 'message' => 'missing snippet priority data', - ) - ); - } - - $this->update_snippet_priority( $snippet ); - - } elseif ( 'active' === $field ) { - - if ( ! isset( $snippet_data['active'] ) ) { - wp_send_json_error( - array( - 'type' => 'param_error', - 'message' => 'missing snippet active data', - ) - ); - } - - if ( $snippet->shared_network ) { - $active_shared_snippets = get_option( 'active_shared_network_snippets', array() ); - - if ( in_array( $snippet->id, $active_shared_snippets, true ) !== $snippet->active ) { - - $active_shared_snippets = $snippet->active ? - array_merge( $active_shared_snippets, array( $snippet->id ) ) : - array_diff( $active_shared_snippets, array( $snippet->id ) ); - - update_option( 'active_shared_network_snippets', $active_shared_snippets ); - clean_active_snippets_cache( code_snippets()->db->ms_table ); - } - } elseif ( $snippet->active ) { - $result = activate_snippet( $snippet->id, $snippet->network ); - if ( is_string( $result ) ) { - wp_send_json_error( - array( - 'type' => 'action_error', - 'message' => $result, - ) - ); - } - } else { - deactivate_snippet( $snippet->id, $snippet->network ); - } - } - - wp_send_json_success(); - } } diff --git a/src/php/class-admin.php b/src/php/class-admin.php index b6ffa63e..58392726 100644 --- a/src/php/class-admin.php +++ b/src/php/class-admin.php @@ -41,7 +41,8 @@ public function __construct() { * Initialise classes */ public function load_classes() { - $this->menus['manage'] = new Manage_Menu(); + $this->menus['manage_new'] = new Manage_Menu(); + $this->menus['manage'] = new Manage_Menu_Legacy(); $this->menus['edit'] = new Edit_Menu(); $this->menus['import'] = new Import_Menu(); diff --git a/src/php/class-plugin.php b/src/php/class-plugin.php index a89103ba..efba5854 100644 --- a/src/php/class-plugin.php +++ b/src/php/class-plugin.php @@ -175,6 +175,10 @@ public function get_menu_slug( string $menu = '' ): string { $cloud = array( 'cloud', 'cloud-snippets' ); $welcome = array( 'welcome', 'getting-started', 'code-snippets' ); + if ( 'manage-legacy' === $menu ) { + return 'snippets-legacy'; + } + if ( in_array( $menu, $edit, true ) ) { return 'edit-snippet'; } elseif ( in_array( $menu, $add, true ) ) { @@ -359,10 +363,10 @@ public function localize_script( string $handle ) { 'localToken' => $this->cloud_api->get_local_token(), ], 'urls' => [ - 'plugin' => esc_url_raw( plugins_url( '', PLUGIN_FILE ) ), - 'manage' => esc_url_raw( $this->get_menu_url() ), - 'edit' => esc_url_raw( $this->get_menu_url( 'edit' ) ), - 'addNew' => esc_url_raw( $this->get_menu_url( 'add' ) ), + 'plugin' => esc_url_raw( plugins_url( '', PLUGIN_FILE ) ), + 'manage' => esc_url_raw( $this->get_menu_url() ), + 'edit' => esc_url_raw( $this->get_menu_url( 'edit' ) ), + 'addNew' => esc_url_raw( $this->get_menu_url( 'add' ) ), ], ] ); diff --git a/src/php/views/manage.php b/src/php/views/manage.php index 4533bfdd..d5298ceb 100644 --- a/src/php/views/manage.php +++ b/src/php/views/manage.php @@ -13,7 +13,7 @@ /** * Loaded from the manage menu class. * - * @var Manage_Menu $this + * @var Manage_Menu_Legacy $this */ if ( ! defined( 'ABSPATH' ) ) { diff --git a/src/php/views/partials/cloud-search.php b/src/php/views/partials/cloud-search.php index 078399ed..3be462b9 100644 --- a/src/php/views/partials/cloud-search.php +++ b/src/php/views/partials/cloud-search.php @@ -11,7 +11,7 @@ /** * Loaded from manage menu. * - * @var Manage_Menu $this + * @var Manage_Menu_Legacy $this */ $search_query = isset( $_REQUEST['cloud_search'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['cloud_search'] ) ) : ''; diff --git a/src/php/views/partials/list-table-notices.php b/src/php/views/partials/list-table-notices.php index e8dbc99d..8e3de635 100644 --- a/src/php/views/partials/list-table-notices.php +++ b/src/php/views/partials/list-table-notices.php @@ -11,7 +11,7 @@ /** * Loaded from the manage menu. * - * @var Manage_Menu $this + * @var Manage_Menu_Legacy $this */ if ( defined( 'CODE_SNIPPETS_SAFE_MODE' ) && CODE_SNIPPETS_SAFE_MODE ) { diff --git a/src/php/views/partials/list-table.php b/src/php/views/partials/list-table.php index a9569386..cf02265b 100644 --- a/src/php/views/partials/list-table.php +++ b/src/php/views/partials/list-table.php @@ -11,7 +11,7 @@ /** * Loaded from the manage menu. * - * @var Manage_Menu $this + * @var Manage_Menu_Legacy $this */ ?> diff --git a/src/readme.txt b/src/readme.txt index 6eedcee3..79d53be3 100644 --- a/src/readme.txt +++ b/src/readme.txt @@ -4,7 +4,7 @@ Donate link: https://codesnippets.pro Tags: code, snippets, multisite, php, css License: GPL-2.0-or-later License URI: license.txt -Stable tag: 3.7.0-beta.1 +Stable tag: 3.8.0-dev.1 Tested up to: 6.7.2 An easy, clean and simple way to enhance your site with code snippets. From 846bb819cfa1359bb13ecfd6a873dba13bdf894a Mon Sep 17 00:00:00 2001 From: Shea Bunge Date: Thu, 24 Jul 2025 18:40:32 +1000 Subject: [PATCH 02/15] Fold shortcodes utils into component file. --- .../EditorSidebar/actions/ShortcodeInfo.tsx | 13 +++++++++++-- src/js/utils/shortcodes.ts | 10 ---------- 2 files changed, 11 insertions(+), 12 deletions(-) delete mode 100644 src/js/utils/shortcodes.ts diff --git a/src/js/components/EditorSidebar/actions/ShortcodeInfo.tsx b/src/js/components/EditorSidebar/actions/ShortcodeInfo.tsx index 1e0aa0cc..2d1ad820 100644 --- a/src/js/components/EditorSidebar/actions/ShortcodeInfo.tsx +++ b/src/js/components/EditorSidebar/actions/ShortcodeInfo.tsx @@ -2,11 +2,20 @@ import React, { useState } from 'react' import { ExternalLink } from '@wordpress/components' import { __ } from '@wordpress/i18n' import { useSnippetForm } from '../../../hooks/useSnippetForm' -import { buildShortcodeTag } from '../../../utils/shortcodes' import { CopyToClipboardButton } from '../../common/CopyToClipboardButton' -import type { ShortcodeAtts } from '../../../utils/shortcodes' import type { Dispatch, SetStateAction} from 'react' +type ShortcodeAtts = Record + +const buildShortcodeTag = (tag: string, atts: ShortcodeAtts): string => + `[${[ + tag, + ...Object.entries(atts) + .filter(([, value]) => Boolean(value)) + .map(([att, value]) => + 'boolean' === typeof value ? att : `${att}=${JSON.stringify(value)}`) + ].filter(Boolean).join(' ')}]` + const SHORTCODE_TAG = 'code_snippet' interface ShortcodeOptions { diff --git a/src/js/utils/shortcodes.ts b/src/js/utils/shortcodes.ts deleted file mode 100644 index 56542c89..00000000 --- a/src/js/utils/shortcodes.ts +++ /dev/null @@ -1,10 +0,0 @@ -export type ShortcodeAtts = Record - -export const buildShortcodeTag = (tag: string, atts: ShortcodeAtts): string => - `[${[ - tag, - ...Object.entries(atts) - .filter(([, value]) => Boolean(value)) - .map(([att, value]) => - 'boolean' === typeof value ? att : `${att}=${JSON.stringify(value)}`) - ].filter(Boolean).join(' ')}]` From 46ec4a75473933c29692b334af230a1cefd6048f Mon Sep 17 00:00:00 2001 From: Shea Bunge Date: Thu, 24 Jul 2025 18:41:01 +1000 Subject: [PATCH 03/15] Consoldate legacy manage styles into a single file. --- src/css/manage-legacy.scss | 342 +++++++++++++++++++++++++++++- src/css/manage-legacy/_cloud.scss | 340 ----------------------------- 2 files changed, 341 insertions(+), 341 deletions(-) delete mode 100644 src/css/manage-legacy/_cloud.scss diff --git a/src/css/manage-legacy.scss b/src/css/manage-legacy.scss index d7a9972c..11055c8b 100644 --- a/src/css/manage-legacy.scss +++ b/src/css/manage-legacy.scss @@ -8,7 +8,6 @@ @use 'common/badges'; @use 'common/switch'; @use 'common/select'; -@use 'manage-legacy/cloud'; .column-name, .column-type { @@ -211,3 +210,344 @@ td.column-description { } } } + +/** Cloud */ + + +.cloud-legend-tooltip { + h3 { + font-size: 16px; + color: #fff; + text-align: center; + } + + td { + vertical-align: top; + } +} + +.cloud-search-info { + text-align: justify; + + small { + color: #646970; + float: inline-end; + } +} + +.thickbox-code-viewer { + min-block-size: 250px; + background-color: hsl(0deg 0% 96.5%); + padding: 20px; + border-radius: 10px; +} + +#snippet-code-thickbox { + display: block; + inline-size: 100%; +} + +.no-results { + font-size: 15px; +} + +.dashicons.cloud-synced { + color: theme.$cloud; +} + +.dashicons.cloud-downloaded { + color: #e91e63; +} + +.dashicons.cloud-not-downloaded { + color: theme.$outline; +} + +.dashicons.cloud-update { + color: theme.$cloud-update; +} + +.cloud_update a { + color: theme.$cloud-update !important; + text-decoration: underline; +} + +.updated.column-updated span { + text-decoration: dotted underline; +} + +td.column-name { + .cloud-icon { + margin-inline-end: 3px; + } +} + +td.column-download { + display: flex; + gap: 0.5em; + flex-flow: column; + text-align: center; +} + +.cloud-snippet-download { + color: theme.$accent !important; +} + +.cloud-snippet-downloaded, .cloud-snippet-preview-style { + color: #616161 !important; +} + +.cloud-snippet-update { + color: theme.$cloud-update !important; +} + +#cloud-search-form { + margin-block: 30px; + text-align: center; +} + +.input-group { + position: relative; + display: flex; + flex-wrap: wrap; + align-items: stretch; + max-inline-size: 900px; + margin: 0 auto; +} + +#cloud_search { + display: block; + padding: 0.375rem 0.75rem; + font-size: 1rem; + color: #495057; + background-clip: padding-box; + border-radius: 0; + transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out; + position: relative; + flex: 1 1 auto; + inline-size: 1%; + margin-block-end: 0; + + &:focus { + outline: 0; + border: 1px solid #8c8f94; + box-shadow: none; + } +} + +#cloud-select-prepend { + margin-inline-end: -3px; + border-start-end-radius: 0; + border-end-end-radius: 0; + position: relative; + z-index: 2; + color: theme.$accent; + border-color: theme.$accent; + background-color: #f6f7f7; + + &:hover { + background-color: #f0f0f1; + border-color: #0a4b78; + color: #0a4b78; + } +} + +#cloud-search-submit { + padding: 0 15px; + margin-inline-start: -3px; + display: flex; + justify-content: center; + align-items: center; +} + +.cloud-search { + margin-inline-start: 5px; +} + +.bundle-group { + margin-block-start: 10px; + justify-content: space-between; + display: flex; + gap: 5px; + flex-wrap: nowrap; +} + +#cloud-bundles { + color: #495057; + display: flex; + flex: 1 1 auto; + font-size: 1rem; + padding: 0.375rem 0.75rem; + position: relative; + inline-size: 50%; +} + +#cloud-bundle-show { + inline-size: 10%; +} + +#cloud-bundle-run { + inline-size: 15%; +} + +#bundle_share_name { + color: #495057; + font-size: 1rem; + inline-size: 25%; +} + +.heading-box { + max-inline-size: 900px; + margin: auto; + padding-block-end: 1rem; +} + +.cloud-search-heading { + font-size: 23px; + font-weight: 400; + padding: 9px 0 4px; + line-height: 1.3; + text-align: center; + margin-block-end: 0; +} + +.cloud-search-card .badge { + block-size: 34px; +} + +.cloud-badge.ai-icon { + font-size: 12px; + padding: 3px; + margin-inline-start: 5px; + color: #b22222; +} + +.cloud-search-card-bottom { + min-block-size: 40px; +} + +#cloud-search-results .cloud-snippets #the-list { + display: flex; + flex-wrap: wrap; + justify-content: center; +} + +.cloud-snippets .plugin-card { + display: flex; + flex-direction: column; + justify-content: space-between; +} + +.cloud-connect-wrap { + display: flex; + justify-content: space-between; + align-items: center; + max-block-size: 35px; + margin: 0 3px; + float: inline-end; + gap: 5px; +} + + +.cloud-table > tbody > tr { + block-size: 80px; + box-shadow: inset 0 -1px 0 rgb(0 0 0 / 10%); +} + +.cloud-table > tbody > tr > td { + max-inline-size: 250px; +} + +.cloud-table tbody .active-snippet .column-name { + font-weight: 400; + max-inline-size: 400px; + white-space: normal !important; +} + +.cloud-table td .no-results { + margin-block-start: 15px; + color: #e32121; + text-align: center; +} + +.cloud-status-dot { + block-size: 10px; + inline-size: 10px; + background-color: #ce0000; + border-radius: 50%; + + + .cloud-connect-active & { + background-color: #25a349; + } +} + +.cloud-connect-text { + color: #ce0000; + + .cloud-connect-active & { + color: #2e7d32; + } +} + +.thumbs-up { + inline-size: 1.25rem; /* 20px */ + block-size: 1.25rem; /* 20px */ + transform-origin: bottom left; + + &:hover { + stroke: #059669; + fill: #6ee7b7; + } +} + +.voted-info { + display: inline-flex; + gap: 3px; + align-items: center; + margin-block-end: 6px !important; + + &:hover { + .thumbs-up { + stroke: #059669; + fill: #6ee7b7; + animation: thumb 1s ease-in-out infinite; + } + } +} + +.plugin-card-bottom { + overflow: visible !important; +} + +.beta-test-notice { + margin-block-start: 20px; +} + +.highlight-yellow { + background: #ffee58; + padding: 3px; + border-radius: 3px; +} + +@keyframes thumb { + 0% { + transform: rotate(0) + } + + 33% { + transform: rotate(7deg) + } + + 66% { + transform: rotate(-15deg) + } + + 90% { + transform: rotate(5deg) + } + + 100% { + transform: rotate(0) + } +} diff --git a/src/css/manage-legacy/_cloud.scss b/src/css/manage-legacy/_cloud.scss deleted file mode 100644 index e72fab37..00000000 --- a/src/css/manage-legacy/_cloud.scss +++ /dev/null @@ -1,340 +0,0 @@ -@use '../common/theme'; -@use '../common/tooltips'; - -.cloud-legend-tooltip { - h3 { - font-size: 16px; - color: #fff; - text-align: center; - } - - td { - vertical-align: top; - } -} - -.cloud-search-info { - text-align: justify; - - small { - color: #646970; - float: inline-end; - } -} - -.thickbox-code-viewer { - min-block-size: 250px; - background-color: hsl(0deg 0% 96.5%); - padding: 20px; - border-radius: 10px; -} - -#snippet-code-thickbox { - display: block; - inline-size: 100%; -} - -.no-results { - font-size: 15px; -} - -.dashicons.cloud-synced { - color: theme.$cloud; -} - -.dashicons.cloud-downloaded { - color: #e91e63; -} - -.dashicons.cloud-not-downloaded { - color: theme.$outline; -} - -.dashicons.cloud-update { - color: theme.$cloud-update; -} - -.cloud_update a { - color: theme.$cloud-update !important; - text-decoration: underline; -} - -.updated.column-updated span { - text-decoration: dotted underline; -} - -td.column-name { - .cloud-icon { - margin-inline-end: 3px; - } -} - -td.column-download { - display: flex; - gap: 0.5em; - flex-flow: column; - text-align: center; -} - -.cloud-snippet-download { - color: theme.$accent !important; -} - -.cloud-snippet-downloaded, .cloud-snippet-preview-style { - color: #616161 !important; -} - -.cloud-snippet-update { - color: theme.$cloud-update !important; -} - -#cloud-search-form { - margin-block: 30px; - text-align: center; -} - -.input-group { - position: relative; - display: flex; - flex-wrap: wrap; - align-items: stretch; - max-inline-size: 900px; - margin: 0 auto; -} - -#cloud_search { - display: block; - padding: 0.375rem 0.75rem; - font-size: 1rem; - color: #495057; - background-clip: padding-box; - border-radius: 0; - transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out; - position: relative; - flex: 1 1 auto; - inline-size: 1%; - margin-block-end: 0; - - &:focus { - outline: 0; - border: 1px solid #8c8f94; - box-shadow: none; - } -} - -#cloud-select-prepend { - margin-inline-end: -3px; - border-start-end-radius: 0; - border-end-end-radius: 0; - position: relative; - z-index: 2; - color: theme.$accent; - border-color: theme.$accent; - background-color: #f6f7f7; - - &:hover { - background-color: #f0f0f1; - border-color: #0a4b78; - color: #0a4b78; - } -} - -#cloud-search-submit { - padding: 0 15px; - margin-inline-start: -3px; - display: flex; - justify-content: center; - align-items: center; -} - -.cloud-search { - margin-inline-start: 5px; -} - -.bundle-group { - margin-block-start: 10px; - justify-content: space-between; - display: flex; - gap: 5px; - flex-wrap: nowrap; -} - -#cloud-bundles { - color: #495057; - display: flex; - flex: 1 1 auto; - font-size: 1rem; - padding: 0.375rem 0.75rem; - position: relative; - inline-size: 50%; -} - -#cloud-bundle-show { - inline-size: 10%; -} - -#cloud-bundle-run { - inline-size: 15%; -} - -#bundle_share_name { - color: #495057; - font-size: 1rem; - inline-size: 25%; -} - -.heading-box { - max-inline-size: 900px; - margin: auto; - padding-block-end: 1rem; -} - -.cloud-search-heading { - font-size: 23px; - font-weight: 400; - padding: 9px 0 4px; - line-height: 1.3; - text-align: center; - margin-block-end: 0; -} - -.cloud-search-card .badge { - block-size: 34px; -} - -.cloud-badge.ai-icon { - font-size: 12px; - padding: 3px; - margin-inline-start: 5px; - color: #b22222; -} - -.cloud-search-card-bottom { - min-block-size: 40px; -} - -#cloud-search-results .cloud-snippets #the-list { - display: flex; - flex-wrap: wrap; - justify-content: center; -} - -.cloud-snippets .plugin-card { - display: flex; - flex-direction: column; - justify-content: space-between; -} - -.cloud-connect-wrap { - display: flex; - justify-content: space-between; - align-items: center; - max-block-size: 35px; - margin: 0 3px; - float: inline-end; - gap: 5px; -} - - -.cloud-table > tbody > tr { - block-size: 80px; - box-shadow: inset 0 -1px 0 rgb(0 0 0 / 10%); -} - -.cloud-table > tbody > tr > td { - max-inline-size: 250px; -} - -.cloud-table tbody .active-snippet .column-name { - font-weight: 400; - max-inline-size: 400px; - white-space: normal !important; -} - -.cloud-table td .no-results { - margin-block-start: 15px; - color: #e32121; - text-align: center; -} - -.cloud-status-dot { - block-size: 10px; - inline-size: 10px; - background-color: #ce0000; - border-radius: 50%; - - - .cloud-connect-active & { - background-color: #25a349; - } -} - -.cloud-connect-text { - color: #ce0000; - - .cloud-connect-active & { - color: #2e7d32; - } -} - -.thumbs-up { - inline-size: 1.25rem; /* 20px */ - block-size: 1.25rem; /* 20px */ - transform-origin: bottom left; - - &:hover { - stroke: #059669; - fill: #6ee7b7; - } -} - -.voted-info { - display: inline-flex; - gap: 3px; - align-items: center; - margin-block-end: 6px !important; - - &:hover { - .thumbs-up { - stroke: #059669; - fill: #6ee7b7; - animation: thumb 1s ease-in-out infinite; - } - } -} - -.plugin-card-bottom { - overflow: visible !important; -} - -.beta-test-notice { - margin-block-start: 20px; -} - -.highlight-yellow { - background: #ffee58; - padding: 3px; - border-radius: 3px; -} - -@keyframes thumb { - 0% { - transform: rotate(0) - } - - 33% { - transform: rotate(7deg) - } - - 66% { - transform: rotate(-15deg) - } - - 90% { - transform: rotate(5deg) - } - - 100% { - transform: rotate(0) - } -} From a69aa7390de703603a7ec58c3d1af1cc706f308f Mon Sep 17 00:00:00 2001 From: Shea Bunge Date: Thu, 24 Jul 2025 18:41:56 +1000 Subject: [PATCH 04/15] Only output request debug messages when WP_DEBUG is enabled. --- src/js/hooks/useRestAPI.tsx | 12 ++++++++---- src/js/types/Window.ts | 1 + src/php/class-plugin.php | 1 + 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/js/hooks/useRestAPI.tsx b/src/js/hooks/useRestAPI.tsx index 4e945095..ece0ef5f 100644 --- a/src/js/hooks/useRestAPI.tsx +++ b/src/js/hooks/useRestAPI.tsx @@ -26,10 +26,14 @@ const debugRequest = async ( doRequest: Promise>, data?: D ): Promise => { - console.debug(`${method} ${url}`, ...data ? [data] : []) - const response = await doRequest - console.debug('Response', response) - return response.data + if (window.CODE_SNIPPETS?.debug) { + console.debug(`${method} ${url}`, ...data ? [data] : []) + const response = await doRequest + console.debug('Response', response) + return response.data + } else { + return (await doRequest).data + } } const buildRestAPI = (axiosInstance: AxiosInstance): RestAPI => ({ diff --git a/src/js/types/Window.ts b/src/js/types/Window.ts index f32f2a18..027f0505 100644 --- a/src/js/types/Window.ts +++ b/src/js/types/Window.ts @@ -18,6 +18,7 @@ declare global { readonly code_snippets_editor_settings: EditorOption[] CODE_SNIPPETS_PRISM?: typeof Prism readonly CODE_SNIPPETS?: { + debug: boolean isLicensed: boolean restAPI: { base: string diff --git a/src/php/class-plugin.php b/src/php/class-plugin.php index efba5854..973281d0 100644 --- a/src/php/class-plugin.php +++ b/src/php/class-plugin.php @@ -354,6 +354,7 @@ public function localize_script( string $handle ) { $handle, 'CODE_SNIPPETS', [ + 'debug' => defined( 'WP_DEBUG' ) && WP_DEBUG, 'isLicensed' => $this->licensing->is_licensed(), 'isCloudConnected' => Cloud_API::is_cloud_connection_available(), 'restAPI' => [ From 9f236c82404e48502c1131a13eb35e5bb2196916 Mon Sep 17 00:00:00 2001 From: Shea Bunge Date: Thu, 24 Jul 2025 18:43:14 +1000 Subject: [PATCH 05/15] Clean up leftover conditions code. --- src/js/components/common/SubmitButton.tsx | 3 + src/js/utils/snippets.ts | 80 ----------------------- src/js/utils/snippets/api.ts | 14 ++-- src/js/utils/snippets/objects.ts | 79 ++++++---------------- src/js/utils/snippets/snippets.ts | 7 +- src/php/snippet-ops.php | 8 --- 6 files changed, 28 insertions(+), 163 deletions(-) delete mode 100644 src/js/utils/snippets.ts diff --git a/src/js/components/common/SubmitButton.tsx b/src/js/components/common/SubmitButton.tsx index 5c6bfeac..86ee371b 100644 --- a/src/js/components/common/SubmitButton.tsx +++ b/src/js/components/common/SubmitButton.tsx @@ -7,6 +7,7 @@ export interface SubmitButtonProps extends Omit = ({ text, name = 'submit', primary, + secondary, small, large, wrap, @@ -34,6 +36,7 @@ export const SubmitButton: React.FC = ({ 'button', { 'button-primary': primary, + 'button-secondary': secondary, 'button-small': small, 'button-large': large }, diff --git a/src/js/utils/snippets.ts b/src/js/utils/snippets.ts deleted file mode 100644 index 63c167f3..00000000 --- a/src/js/utils/snippets.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { SNIPPET_TYPE_SCOPES } from '../types/Snippet' -import { isNetworkAdmin } from './screen' -import type { Snippet, SnippetScope, SnippetType } from '../types/Snippet' - -const PRO_TYPES: SnippetType[] = ['css', 'js'] - -const defaults: Omit = { - id: 0, - name: '', - code: '', - desc: '', - scope: 'global', - modified: '', - active: false, - network: isNetworkAdmin(), - shared_network: null, - priority: 10, - conditionId: 0 -} - -const isAbsInt = (value: unknown): value is number => - 'number' === typeof value && 0 < value - -const isBooleanOrUndefined = (value: unknown): value is boolean | undefined => - 'boolean' === typeof value || value === undefined - -const parseStringArray = (value: unknown): string[] | undefined => - Array.isArray(value) ? value.filter(entry => 'string' === typeof entry) : undefined - -export const isValidScope = (scope: unknown): scope is SnippetScope => - 'string' === typeof scope && Object.values(SNIPPET_TYPE_SCOPES).some(typeScopes => - typeScopes.some(typeScope => typeScope === scope)) - -export const createSnippetObject = (fields: unknown = undefined): Snippet => { - if ('object' !== typeof fields || null === fields) { - return { ...defaults, tags: [] } - } - - return { - id: 'id' in fields && isAbsInt(fields.id) ? fields.id : defaults.id, - name: 'name' in fields && 'string' === typeof fields.name ? fields.name : defaults.name, - desc: 'desc' in fields && 'string' === typeof fields.desc ? fields.desc : defaults.desc, - code: 'code' in fields && 'string' === typeof fields.code ? fields.code : defaults.code, - tags: 'tags' in fields ? parseStringArray(fields.tags) ?? [] : [], - scope: 'scope' in fields && isValidScope(fields.scope) ? fields.scope : defaults.scope, - modified: 'modified' in fields && 'string' === typeof fields.modified ? fields.modified : defaults.modified, - active: 'active' in fields && 'boolean' === typeof fields.active ? fields.active : defaults.active, - network: 'network' in fields && 'boolean' === typeof fields.network ? fields.network : defaults.network, - shared_network: 'shared_network' in fields && isBooleanOrUndefined(fields.shared_network) - ? fields.shared_network : defaults.shared_network, - priority: 'priority' in fields && 'number' === typeof fields.priority ? fields.priority : defaults.priority, - conditionId: 'conditionId' in fields && isAbsInt(fields.conditionId) ? fields.conditionId - : 'condition_id' in fields && isAbsInt(fields.condition_id) ? fields.condition_id - : defaults.conditionId - } -} - -export const getSnippetType = (snippetOrScope: Snippet | SnippetScope): SnippetType => { - const scope = 'string' === typeof snippetOrScope ? snippetOrScope : snippetOrScope.scope - - switch (true) { - case scope.endsWith('-css'): - return 'css' - - case scope.endsWith('-js'): - return 'js' - - case scope.endsWith('content'): - return 'html' - - default: - return 'php' - } -} - -export const isProSnippet = (snippet: Snippet | SnippetScope): boolean => - PRO_TYPES.includes(getSnippetType(snippet)) - -export const isProType = (type: SnippetType): boolean => - PRO_TYPES.includes(type) diff --git a/src/js/utils/snippets/api.ts b/src/js/utils/snippets/api.ts index 635c3240..5094d8f0 100644 --- a/src/js/utils/snippets/api.ts +++ b/src/js/utils/snippets/api.ts @@ -1,6 +1,6 @@ import { addQueryArgs } from '@wordpress/url' import { REST_SNIPPETS_BASE } from '../restAPI' -import { createSnippetObject, isCondition } from './snippets' +import { createSnippetObject } from './snippets' import type { RestAPI } from '../../hooks/useRestAPI' import type { SnippetSchema, WritableSnippetSchema } from '../../types/schema/SnippetSchema' import type { Snippet } from '../../types/Snippet' @@ -10,7 +10,7 @@ export interface SnippetsAPI { fetchAll: (network?: boolean | null) => Promise fetch: (snippetId: number, network?: boolean | null) => Promise create: (snippet: Snippet) => Promise - update: (snippet: Snippet) => Promise + update: (snippet: Pick & Partial) => Promise delete: (snippet: Pick) => Promise activate: (snippet: Pick) => Promise deactivate: (snippet: Pick) => Promise @@ -36,16 +36,10 @@ const mapToSchema = ({ active, network, conditionId, - conditions -}: Snippet): WritableSnippetSchema => ({ +}: Partial): WritableSnippetSchema => ({ name, desc, - code: isCondition({ scope }) - ? JSON.stringify( - Object.values(conditions) - .map(group => group && Object.values(group)) - .filter(group => Array.isArray(group) && group.length > 0)) - : code, + code, tags, scope, priority, diff --git a/src/js/utils/snippets/objects.ts b/src/js/utils/snippets/objects.ts index 37e0e50a..09c9c272 100644 --- a/src/js/utils/snippets/objects.ts +++ b/src/js/utils/snippets/objects.ts @@ -1,6 +1,5 @@ -import { SNIPPET_TYPE_SCOPES } from '../../types/Snippet' import type { Snippet, SnippetScope } from '../../types/Snippet' -import type { ConditionGroups } from '../../types/ConditionGroups' +import { SNIPPET_TYPE_SCOPES } from '../../types/Snippet' const isAbsInt = (value: unknown): value is number => 'number' === typeof value && 0 < value @@ -12,62 +11,22 @@ export const isValidScope = (scope: unknown): scope is SnippetScope => 'string' === typeof scope && Object.values(SNIPPET_TYPE_SCOPES).some(typeScopes => typeScopes.some(typeScope => typeScope === scope)) -const isValidCondition = (condition: unknown): condition is ConditionGroups => - 'object' === typeof condition && null !== condition && Object.values(condition) - .every((group: unknown) => 'object' === typeof group && null !== group && - Object.values(group).every((rule: unknown) => 'object' === typeof rule && null !== rule)) - -const generateObjectKeys = (items: Record, transformItem?: (item: T) => T): Record => - Object.fromEntries( - Object.values(items).map((item, index) => - [`_${index}`, transformItem ? transformItem(item) : item] - )) - -const parseConditionGroups = (condition: ConditionGroups) => - generateObjectKeys(condition, group => group && generateObjectKeys(group)) - -const parseConditions = (parsed: Snippet, fields: object): Partial => { - if ('conditions' in fields && isValidCondition(fields.conditions)) { - return { conditions: parseConditionGroups(fields.conditions) } - } - - if ('condition' === parsed.scope && '' !== parsed.code.trim()) { - try { - const parsedRules: unknown = JSON.parse(parsed.code) - - if (isValidCondition(parsedRules)) { - return { conditions: parseConditionGroups(parsedRules), code: '' } - } - } catch (error) { - console.error('Failed to parse condition rules JSON.', parsed.code, error) +export const parseSnippetObject = (fields: unknown, defaults: Snippet): Snippet => + 'object' !== typeof fields || null === fields + ? defaults + : { + id: 'id' in fields && isAbsInt(fields.id) ? fields.id : defaults.id, + name: 'name' in fields && 'string' === typeof fields.name ? fields.name : defaults.name, + desc: 'desc' in fields && 'string' === typeof fields.desc ? fields.desc : defaults.desc, + code: 'code' in fields && 'string' === typeof fields.code ? fields.code : defaults.code, + tags: 'tags' in fields ? parseStringArray(fields.tags) ?? defaults.tags : defaults.tags, + scope: 'scope' in fields && isValidScope(fields.scope) ? fields.scope : defaults.scope, + modified: 'modified' in fields && 'string' === typeof fields.modified ? fields.modified : defaults.modified, + active: 'active' in fields && 'boolean' === typeof fields.active ? fields.active : defaults.active, + network: 'network' in fields && 'boolean' === typeof fields.network ? fields.network : defaults.network, + shared_network: 'shared_network' in fields && 'boolean' === typeof fields.shared_network && fields.shared_network || + defaults.shared_network, + priority: 'priority' in fields && 'number' === typeof fields.priority ? fields.priority : defaults.priority, + conditionId: 'condition_id' in fields && isAbsInt(fields.condition_id) ? fields.condition_id + : 'conditionId' in fields && isAbsInt(fields.conditionId) ? fields.conditionId : defaults.conditionId } - } - - return {} -} - -export const parseSnippetObject = (fields: unknown, defaults: Snippet): Snippet => { - if ('object' !== typeof fields || null === fields) { - return defaults - } - - const parsed: Snippet = { - id: 'id' in fields && isAbsInt(fields.id) ? fields.id : defaults.id, - name: 'name' in fields && 'string' === typeof fields.name ? fields.name : defaults.name, - desc: 'desc' in fields && 'string' === typeof fields.desc ? fields.desc : defaults.desc, - code: 'code' in fields && 'string' === typeof fields.code ? fields.code : defaults.code, - tags: 'tags' in fields ? parseStringArray(fields.tags) ?? defaults.tags : defaults.tags, - scope: 'scope' in fields && isValidScope(fields.scope) ? fields.scope : defaults.scope, - modified: 'modified' in fields && 'string' === typeof fields.modified ? fields.modified : defaults.modified, - active: 'active' in fields && 'boolean' === typeof fields.active ? fields.active : defaults.active, - network: 'network' in fields && 'boolean' === typeof fields.network ? fields.network : defaults.network, - shared_network: 'shared_network' in fields && 'boolean' === typeof fields.shared_network && fields.shared_network || - defaults.shared_network, - priority: 'priority' in fields && 'number' === typeof fields.priority ? fields.priority : defaults.priority, - conditionId: 'condition_id' in fields && isAbsInt(fields.condition_id) ? fields.condition_id - : 'conditionId' in fields && isAbsInt(fields.conditionId) ? fields.conditionId : defaults.conditionId, - conditions: defaults.conditions - } - - return { ...parsed, ...parseConditions(parsed, fields) } -} diff --git a/src/js/utils/snippets/snippets.ts b/src/js/utils/snippets/snippets.ts index 72998e07..20a0fd74 100644 --- a/src/js/utils/snippets/snippets.ts +++ b/src/js/utils/snippets/snippets.ts @@ -20,7 +20,7 @@ const defaults: Omit = { } export const createSnippetObject = (fields: unknown = null): Snippet => - parseSnippetObject(fields, { ...defaults, tags: [], conditions: {} }) + parseSnippetObject(fields, { ...defaults, tags: [] }) export const getSnippetType = ({ scope }: Pick): SnippetType => { switch (true) { @@ -43,10 +43,7 @@ export const getSnippetType = ({ scope }: Pick): SnippetType = export const validateSnippet = (snippet: Snippet): undefined | string => { const missingTitle = '' === snippet.name.trim() - - const missingCode = isCondition(snippet) - ? !snippet.conditions - : '' === snippet.code.trim() + const missingCode = '' === snippet.code.trim() switch (true) { case missingCode && missingTitle: diff --git a/src/php/snippet-ops.php b/src/php/snippet-ops.php index ea5bc625..65420aa8 100644 --- a/src/php/snippet-ops.php +++ b/src/php/snippet-ops.php @@ -675,14 +675,6 @@ function execute_active_snippets(): bool { continue; } - if ( $snippet['condition_id'] ) { - $condition_id = intval( $snippet['condition_id'] ); - - if ( isset( $conditions[ $condition_id ] ) && ! $conditions[ $condition_id ] ) { - continue; - } - } - execute_snippet( $code, $snippet_id ); } } From 320ef3d23acc8a6a89c3aa160763661d288ec26e Mon Sep 17 00:00:00 2001 From: Shea Bunge Date: Thu, 24 Jul 2025 18:43:29 +1000 Subject: [PATCH 06/15] Initial implementation of snippets table in React. --- src/css/manage.scss | 81 ++++++++++ .../SnippetForm/fields/SnippetTypeInput.tsx | 14 +- .../SnippetsTable/SnippetTypeTabs.tsx | 65 ++++++++ .../SnippetsTable/SnippetsListTable.tsx | 151 ++++++++++++++++++ .../SnippetsTable/SnippetsTable.tsx | 41 ++++- .../components/SnippetsTable/TableColumns.tsx | 117 ++++++++++++++ .../components/common/ListTable/ListTable.tsx | 119 ++++++++++++++ .../common/ListTable/TableHeadings.tsx | 102 ++++++++++++ .../common/ListTable/TableItems.tsx | 62 +++++++ .../components/common/ListTable/TableNav.tsx | 116 ++++++++++++++ src/js/components/common/ListTable/index.ts | 1 + src/js/types/Snippet.ts | 7 +- src/js/utils/snippets/snippets.ts | 8 + src/php/admin-menus/class-manage-menu.php | 10 ++ 14 files changed, 882 insertions(+), 12 deletions(-) create mode 100644 src/js/components/SnippetsTable/SnippetTypeTabs.tsx create mode 100644 src/js/components/SnippetsTable/SnippetsListTable.tsx create mode 100644 src/js/components/SnippetsTable/TableColumns.tsx create mode 100644 src/js/components/common/ListTable/ListTable.tsx create mode 100644 src/js/components/common/ListTable/TableHeadings.tsx create mode 100644 src/js/components/common/ListTable/TableItems.tsx create mode 100644 src/js/components/common/ListTable/TableNav.tsx create mode 100644 src/js/components/common/ListTable/index.ts diff --git a/src/css/manage.scss b/src/css/manage.scss index e69de29b..836280ce 100644 --- a/src/css/manage.scss +++ b/src/css/manage.scss @@ -0,0 +1,81 @@ +@use 'common/badges'; +@use 'common/switch'; +@use 'common/select'; + +.wrap h2.nav-tab-wrapper { + display: flex; + flex-flow: row wrap-reverse; + gap: 0.5em; +} + +.nav-tab { + display: flex; + flex-flow: row wrap; + align-items: center; + gap: 8px; + margin: 0; + + @media screen and (width <= 1190px) { + .snippet-label { + display: none; + } + } +} + +.nav-tab-wrapper + .subsubsub, p.search-box { + margin: 10px 0 0; +} + +.wrap .subsubsub { + li::after { + content: ' | '; + } + + li:last-child::after { + content: ''; + } +} + +.priority-column input { + appearance: none; + background: none; + border: none; + box-shadow: none; + inline-size: 4em; + color: #666; + text-align: center; + + &:hover, &:focus, &:active { + color: #000; + background-color: rgb(0 0 0 / 10%); + border-radius: 6px; + appearance: unset; + } + + &:disabled { + color: inherit; + } +} + +.wp-list-table { + td.column-id { + text-align: center; + } + + tr { + background: #fff; + } + + ol, ul { + margin: 0 0 1.5em 1.5em; + } + + ul { + list-style: disc; + } + + th.sortable a, th.sorted a { + display: flex; + flex-direction: row; + } +} diff --git a/src/js/components/SnippetForm/fields/SnippetTypeInput.tsx b/src/js/components/SnippetForm/fields/SnippetTypeInput.tsx index 9891749d..4185d6a6 100644 --- a/src/js/components/SnippetForm/fields/SnippetTypeInput.tsx +++ b/src/js/components/SnippetForm/fields/SnippetTypeInput.tsx @@ -3,9 +3,9 @@ import classnames from 'classnames' import { __, _x } from '@wordpress/i18n' import Select from 'react-select' import { useSnippetForm } from '../../../hooks/useSnippetForm' -import { SNIPPET_TYPE_SCOPES } from '../../../types/Snippet' +import { SNIPPET_TYPE_SCOPES, SNIPPET_TYPES } from '../../../types/Snippet' import { isLicensed } from '../../../utils/screen' -import { getSnippetType, isProType } from '../../../utils/snippets/snippets' +import { getSnippetType, isProType, SNIPPET_TYPE_LABELS } from '../../../utils/snippets/snippets' import { Badge } from '../../common/Badge' import type { Dispatch, SetStateAction } from 'react' import type { SnippetCodeType, SnippetType } from '../../../types/Snippet' @@ -23,13 +23,9 @@ const EDITOR_MODES: Record = { html: 'application/x-httpd-php' } -const OPTIONS: SelectOption[] = [ - { value: 'php', label: __('Functions', 'code-snippets') }, - { value: 'html', label: __('Content', 'code-snippets') }, - { value: 'css', label: __('Styles', 'code-snippets') }, - { value: 'js', label: __('Scripts', 'code-snippets') }, - { value: 'cond', label: __('Conditions', 'code-snippets') } -] +const OPTIONS: SelectOption[] = + SNIPPET_TYPES.map(type => + ({ value: type, label: SNIPPET_TYPE_LABELS[type] })) const SnippetTypeOption: React.FC> = ({ label, value }) =>
diff --git a/src/js/components/SnippetsTable/SnippetTypeTabs.tsx b/src/js/components/SnippetsTable/SnippetTypeTabs.tsx new file mode 100644 index 00000000..68413e54 --- /dev/null +++ b/src/js/components/SnippetsTable/SnippetTypeTabs.tsx @@ -0,0 +1,65 @@ +import React, { MouseEventHandler } from 'react' +import classnames from 'classnames' +import { SNIPPET_TYPES, SnippetType } from '../../types/Snippet' +import { addQueryArgs } from '@wordpress/url' +import { SNIPPET_TYPE_LABELS } from '../../utils/snippets/snippets' +import { Badge } from '../common/Badge' +import { __ } from '@wordpress/i18n' + +const updateQueryParam = (name: string, value?: string) => { + if ('URLSearchParams' in window) { + const searchParams = new URLSearchParams(window.location.search) + + if (value) { + searchParams.set(name, value) + } else { + searchParams.delete(name) + } + + const newUrl = window.location.toString().replace(window.location.search, `?${searchParams}`) + console.log(window.location.search, searchParams.toString(), newUrl) + window.history.pushState({}, document.title, newUrl) + } +} + +interface TabProps { + type?: SnippetType + activeTab?: SnippetType + setActiveTab: (tab?: SnippetType) => void +} + +const Tab: React.FC = ({ type, activeTab, setActiveTab }) => { + const tabName = type ?? 'all' + + const handleClick: MouseEventHandler = event => { + event.preventDefault() + setActiveTab(type) + updateQueryParam('type', type) + } + + return ( + + + {type ? SNIPPET_TYPE_LABELS[type] : __('All Snippets', 'code-snippets')} + + {type && } + + ) +} + +export interface SnippetTypeTabsProps { + activeTab?: SnippetType + setActiveTab: (tab?: SnippetType) => void +} + +export const SnippetTypeTabs: React.FC = ({ activeTab, setActiveTab }) => +

+ + + {SNIPPET_TYPES.map(type => + )} +

diff --git a/src/js/components/SnippetsTable/SnippetsListTable.tsx b/src/js/components/SnippetsTable/SnippetsListTable.tsx new file mode 100644 index 00000000..192e5e0d --- /dev/null +++ b/src/js/components/SnippetsTable/SnippetsListTable.tsx @@ -0,0 +1,151 @@ +import { __, _x, sprintf } from '@wordpress/i18n' +import { useSnippetsList } from '../../hooks/useSnippetsList' +import { Snippet, SNIPPET_STATUSES, SnippetStatus, SnippetType } from '../../types/Snippet' +import { getSnippetType } from '../../utils/snippets/snippets' +import { ListTable, ListTableBulkAction } from '../common/ListTable' +import React, { useMemo, useState } from 'react' +import { addQueryArgs } from '@wordpress/url' +import { SubmitButton } from '../common/SubmitButton' +import { TableColumns } from './TableColumns' + +const VIEW_LABELS: Record = { + all: __('All', 'code-snippets'), + active: __('Active', 'code-snippets'), + inactive: __('Inactive', 'code-snippets'), + recently_activated: __('Recently Activate', 'code-snippets') +} + +const actions: ListTableBulkAction[] = [ + { + name: __('Activate', 'code-snippets'), + apply: () => Promise.resolve() + }, + { + name: __('Deactivate', 'code-snippets'), + apply: () => Promise.resolve() + }, + { + name: __('Clone', 'code-snippets'), + apply: () => Promise.resolve() + }, + { + name: __('Export', 'code-snippets'), + apply: () => Promise.resolve() + }, + { + name: __('Export code', 'code-snippets'), + apply: () => Promise.resolve() + }, + { + name: __('Delete', 'code-snippets'), + apply: () => Promise.resolve() + } +] + +interface TableViewsProps { + currentStatus?: SnippetStatus + setCurrentStatus: (status?: SnippetStatus) => void +} + +const TableViews: React.FC = ({ currentStatus, setCurrentStatus }) => { + + return ( + + ) +} + +interface ExtraTableNavProps { + which: 'top' | 'bottom' + currentTag?: string + currentStatus?: SnippetStatus + visibleSnippets: Snippet[] + setCurrentTag: (tag?: string) => void +} + +const ExtraTableNav: React.FC = ({ which, currentTag, setCurrentTag, currentStatus, visibleSnippets }) => { + + const tagsList: Set | undefined = useMemo( + () => + 'top' === which + ? visibleSnippets.reduce((tags, snippet) => { + snippet.tags.forEach(tag => tags.add(tag)) + return tags + }, new Set()) + : undefined, + [which, visibleSnippets]) + + return ( + <> + {tagsList ? +
+ +
+ : null} + + {'recently_activated' === currentStatus + ?
+ +
+ : null} + + ) +} + +export interface SnippetsListTableProps { + currentType?: SnippetType + currentStatus?: SnippetStatus + setCurrentStatus: (status?: SnippetStatus) => void +} + +export const SnippetsListTable: React.FC = ({ currentType, currentStatus, setCurrentStatus }) => { + const { snippetsList } = useSnippetsList() + const [currentTag, setCurrentTag] = useState() + + const visibleSnippets = useMemo( + () => snippetsList?.filter(snippet => + (!currentType || getSnippetType(snippet) === currentType) && + (!currentStatus || (currentStatus === 'active' && snippet.active) || (currentStatus === 'inactive' && !snippet.active)) && + (!currentTag || snippet.tags.includes(currentTag)) + ) ?? [], + [snippetsList, currentType, currentTag, currentStatus]) + + return ( + <> + + + snippet.id} + columns={TableColumns} + actions={actions} + extraTableNav={which => + } + /> + + ) +} diff --git a/src/js/components/SnippetsTable/SnippetsTable.tsx b/src/js/components/SnippetsTable/SnippetsTable.tsx index 71c97d6d..467415d3 100644 --- a/src/js/components/SnippetsTable/SnippetsTable.tsx +++ b/src/js/components/SnippetsTable/SnippetsTable.tsx @@ -1,4 +1,41 @@ -import React from "react" +import { __ } from "@wordpress/i18n" +import React, { useState } from "react" +import { WithRestAPIContext } from '../../hooks/useRestAPI' +import { WithSnippetsListContext } from '../../hooks/useSnippetsList' +import { SNIPPET_STATUSES, SNIPPET_TYPES, SnippetStatus, SnippetType } from '../../types/Snippet' +import { SnippetsListTable } from './SnippetsListTable' +import { SnippetTypeTabs } from './SnippetTypeTabs' + +const fetchQueryParam = (name: string): string | undefined => { + const urlParams = new URLSearchParams(window.location.search) + return urlParams.get(name) ?? undefined +} + +const Page = () => { + const [currentType, setCurrentType] = useState(() => { + const type = fetchQueryParam('type') + return type && SNIPPET_TYPES.includes(type as SnippetType) ? (type as SnippetType) : undefined + }) + + const [currentStatus, setCurrentStatus] = useState(() => { + const status = fetchQueryParam('status') + return status && SNIPPET_STATUSES.includes(status as SnippetStatus) ? (status as SnippetStatus) : undefined + }) + + return ( +
+

{__('Manage Code Snippets', 'code-snippets')}

+ + + + +
+ ) +} export const SnippetsTable: React.FC = () => -
TODO
+ + + + + diff --git a/src/js/components/SnippetsTable/TableColumns.tsx b/src/js/components/SnippetsTable/TableColumns.tsx new file mode 100644 index 00000000..a676f6fa --- /dev/null +++ b/src/js/components/SnippetsTable/TableColumns.tsx @@ -0,0 +1,117 @@ +import React, { ChangeEventHandler, useState } from 'react' +import { useRestAPI } from '../../hooks/useRestAPI' +import { useSnippetsList } from '../../hooks/useSnippetsList' +import { Snippet } from '../../types/Snippet' +import { handleUnknownError } from '../../utils/errors' +import { getSnippetType } from '../../utils/snippets/snippets' +import { stripTags } from '../../utils/text' +import { Badge } from '../common/Badge' +import { ListTableColumn } from '../common/ListTable' +import { __ } from '@wordpress/i18n' +import { addQueryArgs } from '@wordpress/url' +import { humanTimeDiff } from '@wordpress/date' + +interface ColumnProps { + snippet: Snippet +} + +const ActivateColumn: React.FC = ({ snippet }) => { + const { snippetsAPI: { activate, deactivate } } = useRestAPI() + const { refreshSnippetsList } = useSnippetsList() + + return ( + { + (snippet.active ? deactivate(snippet) : activate(snippet)) + .then(() => refreshSnippetsList()) + .catch(handleUnknownError) + }} + /> + ) +} + +const NameColumn: React.FC = ({ snippet }) => { + return snippet.name +} + +const PriorityColumn: React.FC = ({ snippet }) => { + const [value, setValue] = useState(snippet.priority) + // const { snippetsAPI: { update } } = useRestAPI() + const id = `snippet-${snippet.id}-priority` + + const handleUpdate: ChangeEventHandler = event => { + console.log(event, value) + } + + return ( + <> + + setValue(Number(event.target.value))} + /> + + ) +} + +export const TableColumns: ListTableColumn[] = [ + { + id: 'activate', + render: snippet => + }, + { + id: 'name', + title: __('Name', 'code-snippets'), + isPrimary: true, + sortedValue: item => item.name.toLowerCase(), + render: snippet => + }, + { + id: 'type', + title: __('Type', 'code-snippets'), + sortedValue: item => getSnippetType(item), + render: snippet => + }, + { + id: 'desc', + title: __('Description', 'code-snippets'), + // TODO: figure out how to allow formatting and markup. + render: snippet => stripTags(snippet.desc) + }, + { + id: 'tags', + title: __('Tags', 'code-snippets'), + render: snippet => + snippet.tags.map((tag, index) => + <> + + {tag} + + {index < snippet.tags.length - 1 ? ', ' : ''} + + ) + }, + { + id: 'date', + title: __('Modified', 'code-snippets'), + sortedValue: snippet => snippet.modified ? new Date(snippet.modified).toISOString() : '', + render: snippet => snippet.modified ? humanTimeDiff(snippet.modified, undefined) : '—' + }, + { + id: 'priority', + title: __('Priority', 'code-snippets'), + sortedValue: snippet => snippet.priority, + render: snippet => + } +] diff --git a/src/js/components/common/ListTable/ListTable.tsx b/src/js/components/common/ListTable/ListTable.tsx new file mode 100644 index 00000000..14e1a029 --- /dev/null +++ b/src/js/components/common/ListTable/ListTable.tsx @@ -0,0 +1,119 @@ +import React, { useMemo, useState } from 'react' +import classnames from 'classnames' +import { TableHeadings } from './TableHeadings' +import { TableItems } from './TableItems' +import { TableNav } from './TableNav' +import type { TableNavProps } from './TableNav' +import type { TableHeadingsProps } from './TableHeadings' +import type { Key, ReactNode } from 'react' + +export interface ListTableColumn { + id: Key + title?: ReactNode + render: (item: T) => ReactNode + isHidden?: boolean + isPrimary?: boolean + sortedValue?: (item: T) => Key + defaultSortDirection?: ListTableSortDirection +} + +export interface ListTableBulkAction { + name: string + apply: (selected: Set) => Promise +} + +export interface ListTableBulkActionGroup { + name: string + actions: ListTableBulkAction[] +} + +export type ListTableSortDirection = 'asc' | 'desc' + +export interface ListTableNavProps { + actions?: readonly (ListTableBulkAction | ListTableBulkActionGroup)[] + isDisabled?: boolean + extraTableNav?: (which: 'top' | 'bottom') => ReactNode +} + +export interface ListTableItemsProps { + items: T[] + getKey: (item: T) => K + columns: ListTableColumn[] + noItems?: ReactNode + rowClassName?: (item: T) => string +} + +export interface ListTableProps extends ListTableItemsProps, ListTableNavProps { + fixed?: boolean + striped?: boolean + className?: string +} + +const sortItems = ( + items: T[], + sortColumn: ListTableColumn | undefined, + sortDirection: ListTableSortDirection +): T[] => + items.toSorted((itemA, itemB) => { + const valueA = sortColumn?.sortedValue?.(itemA) + const valueB = sortColumn?.sortedValue?.(itemB) + + if (valueA === undefined || valueB === undefined) { + return 0 + } + + if (valueA < valueB) { + return 'asc' === sortDirection ? -1 : 1 + } + + if (valueA > valueB) { + return 'asc' === sortDirection ? 1 : -1 + } + + return 0 + }) + +export const ListTable = ({ + items, + fixed, + striped, + getKey, + columns, + actions, + noItems, + className, + extraTableNav, + isDisabled = false, +}: ListTableProps) => { + const [selected, setSelected] = useState(new Set()) + const [sortColumn, setSortColumn] = useState>() + const [sortDirection, setSortDirection] = useState('asc') + + const sortedItems: T[] = useMemo( + () => sortItems(items, sortColumn, sortDirection), + [items, sortColumn, sortDirection]) + + const tableNavProps: Omit, 'which'> = + { hasItems: 0 < items.length, actions, extraTableNav, selected, isDisabled } + + const tableHeadingsProps: Omit, 'which'> = + { items: sortedItems, setSelected, columns, getKey, sortColumn, setSortColumn, sortDirection, setSortDirection } + + return ( + <> + + + + + + + + + + + +
+ + + ) +} diff --git a/src/js/components/common/ListTable/TableHeadings.tsx b/src/js/components/common/ListTable/TableHeadings.tsx new file mode 100644 index 00000000..55cbf909 --- /dev/null +++ b/src/js/components/common/ListTable/TableHeadings.tsx @@ -0,0 +1,102 @@ +import React from 'react' +import classnames from 'classnames' +import { __ } from '@wordpress/i18n' +import type { ListTableColumn, ListTableProps, ListTableSortDirection } from './ListTable' +import type { Dispatch, Key, SetStateAction, ThHTMLAttributes } from 'react' + +interface SortableHeadingProps { + column: ListTableColumn + cellProps: ThHTMLAttributes + sortColumn: ListTableColumn | undefined + sortDirection: ListTableSortDirection + setSortColumn: Dispatch | undefined>> + setSortDirection: Dispatch> +} + +const SortableHeading = ({ + column, + cellProps, + sortColumn, + setSortColumn, + sortDirection, + setSortDirection +}: SortableHeadingProps) => { + const isCurrent = column.id === sortColumn?.id + + const newSortDirection = isCurrent + ? 'asc' === sortDirection ? 'desc' : 'asc' + : column.defaultSortDirection ?? 'asc' + + return ( + + { + event.preventDefault() + setSortColumn(column) + setSortDirection(newSortDirection) + }}> + {column.title} + + + + + {isCurrent ? null + : + {/* translators: Hidden accessibility text. */} + {'asc' === newSortDirection ? __('Sort ascending.', 'code-snippets') : __('Sort descending.', 'code-snippets')} + } + + + ) +} + +export interface TableHeadingsProps extends Pick, 'columns' | 'getKey' | 'items'> { + which: 'head' | 'foot' + sortColumn: ListTableColumn | undefined + setSelected: Dispatch>> + sortDirection: ListTableSortDirection + setSortColumn: Dispatch | undefined>> + setSortDirection: Dispatch> +} + +export const TableHeadings = ({ + items, + which, + getKey, + columns, + sortColumn, + setSelected, + setSortColumn, + sortDirection, + setSortDirection +}: TableHeadingsProps) => + + + { + setSelected(new Set(event.target.checked ? items.map(getKey) : null)) + }} + /> + + + {columns.map(column => { + const cellProps: ThHTMLAttributes = { + id: 'head' === which ? column.id.toString() : undefined, + scope: 'col', + className: classnames( + 'manage-column', + `column-${column.id}`, + `${column.id}-column`, + { 'hidden': column.isHidden, 'column-primary': column.isPrimary } + ) + } + + return column.sortedValue + ? + : {column.title} + })} + diff --git a/src/js/components/common/ListTable/TableItems.tsx b/src/js/components/common/ListTable/TableItems.tsx new file mode 100644 index 00000000..c5b0996f --- /dev/null +++ b/src/js/components/common/ListTable/TableItems.tsx @@ -0,0 +1,62 @@ +import React from 'react' +import type { Dispatch, Key, SetStateAction, ThHTMLAttributes } from 'react' +import type { ListTableColumn, ListTableItemsProps } from './ListTable' + +interface CheckboxCellProps extends Pick, 'getKey'> { + item: T + setSelected: Dispatch>> +} + +const CheckboxCell = ({ item, setSelected, getKey }: CheckboxCellProps) => + + { + setSelected(previous => { + const updated = new Set(previous) + + if (event.target.checked) { + updated.add(getKey(item)) + } else { + updated.delete(getKey(item)) + } + + return updated + }) + }} + /> + + +interface TableCellProps { + item: T + column: ListTableColumn +} + +const TableCell = ({ item, column }: TableCellProps) => { + const props: ThHTMLAttributes = { + className: `${column.id}-column column-${column.id}`, + children: column.render(item) + } + + return column.isPrimary ? : +} + +export interface TableItemsProps + extends Pick, 'items' | 'getKey' | 'columns' | 'noItems' | 'rowClassName'> { + setSelected: Dispatch>> +} + +export const TableItems = ({ items, getKey, columns, noItems, setSelected, rowClassName }: TableItemsProps) => + 0 < items.length + ? items.map(item => + + + + {columns.map(column => + )} + + ) + : + {noItems} + diff --git a/src/js/components/common/ListTable/TableNav.tsx b/src/js/components/common/ListTable/TableNav.tsx new file mode 100644 index 00000000..89a0b59d --- /dev/null +++ b/src/js/components/common/ListTable/TableNav.tsx @@ -0,0 +1,116 @@ +import React, { useMemo, useState } from 'react' +import { __ } from '@wordpress/i18n' +import { Spinner } from '@wordpress/components' +import { handleUnknownError } from '../../../utils/errors' +import { SubmitButton } from '../SubmitButton' +import type { ListTableBulkAction, ListTableNavProps } from './ListTable' +import type { Key } from 'react' + +interface BulkActionSelectProps extends Required, 'which' | 'actions'>> { + setSelectedAction: (action: ListTableBulkAction | undefined) => void +} + +const BulkActionSelect = ({ which, actions, setSelectedAction }: BulkActionSelectProps) => { + const actionsMap: Map> = useMemo( + () => new Map( + actions + .flatMap(actionOrGroup => + 'actions' in actionOrGroup ? actionOrGroup.actions : [actionOrGroup]) + .map(action => [action.name, action]) + ), [actions]) + + return ( + + ) +} + +interface BulkActionsProps extends Required, 'which' | 'actions'>> { + applyAction: (action: ListTableBulkAction) => Promise +} + +const BulkActions = ({ which, actions, applyAction }: BulkActionsProps) => { + const [selectedAction, setSelectedAction] = useState>() + const [isPerformingAction, setIsPerformingAction] = useState(false) + + return ( +
+ + + + + { + event.preventDefault() + + if (selectedAction) { + setIsPerformingAction(true) + applyAction(selectedAction) + .catch(handleUnknownError) + .finally(() => { + setSelectedAction(undefined) + setIsPerformingAction(false) + }) + } + }} + /> + + {isPerformingAction ? : null} +
+ ) +} + +export interface TableNavProps extends ListTableNavProps { + which: 'top' | 'bottom' + hasItems: boolean + selected: Set +} + +export const TableNav = ({ + which, + actions, + hasItems, + selected, + extraTableNav +}: TableNavProps) => + extraTableNav || hasItems && actions + ?
+ + {hasItems && actions + ? action.apply(selected)} + /> + : null} + + {extraTableNav?.(which)} + + {/* TODO pagination */} + +
+
+ : null diff --git a/src/js/components/common/ListTable/index.ts b/src/js/components/common/ListTable/index.ts new file mode 100644 index 00000000..d862c206 --- /dev/null +++ b/src/js/components/common/ListTable/index.ts @@ -0,0 +1 @@ +export * from './ListTable' diff --git a/src/js/types/Snippet.ts b/src/js/types/Snippet.ts index dfdec93f..948826ef 100644 --- a/src/js/types/Snippet.ts +++ b/src/js/types/Snippet.ts @@ -14,8 +14,13 @@ export interface Snippet { readonly code_error?: readonly [string, number] | null } +export const SNIPPET_TYPES = ['php', 'html', 'css', 'js', 'cond'] as const +export const SNIPPET_STATUSES = ['active', 'inactive', 'recently_activated'] as const + +export type SnippetType = typeof SNIPPET_TYPES[number] +export type SnippetStatus = typeof SNIPPET_STATUSES[number] + export type SnippetCodeType = 'php' | 'html' | 'css' | 'js' -export type SnippetType = SnippetCodeType | 'cond' export type SnippetCodeScope = typeof SNIPPET_TYPE_SCOPES[SnippetCodeType][number] export type SnippetScope = typeof SNIPPET_TYPE_SCOPES[SnippetType][number] diff --git a/src/js/utils/snippets/snippets.ts b/src/js/utils/snippets/snippets.ts index 20a0fd74..4a3a5d20 100644 --- a/src/js/utils/snippets/snippets.ts +++ b/src/js/utils/snippets/snippets.ts @@ -3,6 +3,14 @@ import { isNetworkAdmin } from '../screen' import { parseSnippetObject } from './objects' import type { Snippet, SnippetType } from '../../types/Snippet' +export const SNIPPET_TYPE_LABELS: Record = { + php: __('Functions', 'code-snippets'), + html: __('Content', 'code-snippets'), + css: __('Styles', 'code-snippets'), + js: __('Scripts', 'code-snippets'), + cond: __('Conditions', 'code-snippets') +} + const PRO_TYPES = new Set(['css', 'js', 'cond']) const defaults: Omit = { diff --git a/src/php/admin-menus/class-manage-menu.php b/src/php/admin-menus/class-manage-menu.php index 17dd318d..7b711820 100644 --- a/src/php/admin-menus/class-manage-menu.php +++ b/src/php/admin-menus/class-manage-menu.php @@ -170,6 +170,15 @@ public function load() { $contextual_help = new Contextual_Help( 'edit' ); $contextual_help->load(); + + add_screen_option( + 'per_page', + array( + 'label' => __( 'Snippets per page', 'code-snippets' ), + 'default' => 999, + 'option' => 'snippets_per_page', + ) + ); } /** @@ -196,6 +205,7 @@ public function enqueue_assets() { 'react-dom', 'wp-url', 'wp-i18n', + 'wp-date', 'wp-components', ], $plugin->version, From d1a4718790cc3bb31c21b2568f091d3f9ec32c62 Mon Sep 17 00:00:00 2001 From: Shea Bunge Date: Fri, 25 Jul 2025 00:55:21 +1000 Subject: [PATCH 07/15] Improve look and function of React snippets table. --- src/css/manage.scss | 102 +++++++-- .../EditorSidebar/actions/ExportButtons.tsx | 19 +- src/js/components/SnippetForm/SnippetForm.tsx | 16 +- .../SnippetForm/fields/SnippetTypeInput.tsx | 13 +- .../SnippetForm/page/PageHeading.tsx | 7 +- .../SnippetsTable/SnippetTypeTabs.tsx | 65 ------ .../SnippetsTable/SnippetsListTable.tsx | 212 +++++++++++------- .../SnippetsTable/SnippetsTable.tsx | 97 ++++++-- .../components/SnippetsTable/TableColumns.tsx | 137 +++++++++-- .../components/common/ListTable/ListTable.tsx | 4 +- .../common/ListTable/TableItems.tsx | 11 +- src/js/hooks/useRestAPI.tsx | 16 +- src/js/hooks/useSnippetForm.tsx | 2 +- src/js/hooks/useSnippetSubmit.ts | 85 ------- src/js/hooks/useSnippetsAPI.ts | 10 +- src/js/hooks/useSnippetsList.tsx | 2 +- src/js/hooks/useSnippetsTable.tsx | 170 ++++++++++++++ src/js/types/Snippet.ts | 5 +- src/js/types/Window.ts | 4 + src/js/types/schema/SnippetSchema.ts | 1 + src/js/utils/bootstrap.tsx | 23 +- src/js/utils/files.ts | 17 +- src/js/utils/restAPI.ts | 2 + src/js/utils/snippets/objects.ts | 61 +++-- src/js/utils/snippets/snippets.ts | 23 +- src/php/admin-menus/class-edit-menu.php | 2 +- src/php/admin-menus/class-manage-menu.php | 9 +- src/php/class-plugin.php | 12 +- src/php/class-snippet.php | 12 + src/php/rest-api/class-rest-api.php | 129 +++++++++++ .../class-snippets-rest-controller.php | 5 + src/php/snippet-ops.php | 30 ++- 32 files changed, 894 insertions(+), 409 deletions(-) delete mode 100644 src/js/components/SnippetsTable/SnippetTypeTabs.tsx delete mode 100644 src/js/hooks/useSnippetSubmit.ts create mode 100644 src/js/hooks/useSnippetsTable.tsx create mode 100644 src/php/rest-api/class-rest-api.php diff --git a/src/css/manage.scss b/src/css/manage.scss index 836280ce..e55346f9 100644 --- a/src/css/manage.scss +++ b/src/css/manage.scss @@ -2,21 +2,14 @@ @use 'common/switch'; @use 'common/select'; -.wrap h2.nav-tab-wrapper { - display: flex; - flex-flow: row wrap-reverse; - gap: 0.5em; -} - .nav-tab { display: flex; flex-flow: row wrap; align-items: center; gap: 8px; - margin: 0; @media screen and (width <= 1190px) { - .snippet-label { + span:first-child:not(:last-child) { display: none; } } @@ -26,13 +19,31 @@ margin: 10px 0 0; } -.wrap .subsubsub { - li::after { - content: ' | '; +.name-column, +.type-column { + .dashicons { + font-size: 16px; + inline-size: 16px; + block-size: 16px; + vertical-align: middle; + } + + .dashicons-clock { + vertical-align: middle; + } +} + +.active-snippet .name-column > a { + font-weight: 600; +} + +.active-snippet { + td, th { + background-color: rgba(#78c8e6, 0.06); } - li:last-child::after { - content: ''; + th.check-column { + border-inline-start: 2px solid #2ea2cc; } } @@ -78,4 +89,69 @@ display: flex; flex-direction: row; } + + .row-actions { + color: #ddd; + position: relative; + inset-inline-start: 0; + + .button-link { + min-height: unset; + line-height: unset; + + &:hover { + background: none; + } + } + } + + .column-activate { + padding-inline-end: 0 !important; + } + + .clear-filters { + vertical-align: middle; + } + + tfoot th.check-column { + padding: 13px 0 0 3px; + } + + thead th.check-column, + tfoot th.check-column, + .inactive-snippet th.check-column { + padding-inline-start: 5px; + } + + .active-snippet, .inactive-snippet { + td, th { + padding: 10px 9px; + border: none; + box-shadow: inset 0 -1px 0 rgb(0 0 0 / 10%); + } + } + + tr.active-snippet + tr.inactive-snippet th, + tr.active-snippet + tr.inactive-snippet td { + border-block-start: 1px solid rgb(0 0 0 / 3%); + box-shadow: inset 0 1px 0 rgb(0 0 0 / 2%), inset 0 -1px 0 #e1e1e1; + } + + .delete { + color: #b32d2e; + + &:hover { + border-block-end: 1px solid #f00; + color: #f00; + } + } + + #wpbody-content & .column-name { + white-space: nowrap; /* prevents wrapping of snippet title */ + vertical-align: top; + } +} + +.wp-core-ui .button.clear-filters { + vertical-align: baseline; } diff --git a/src/js/components/EditorSidebar/actions/ExportButtons.tsx b/src/js/components/EditorSidebar/actions/ExportButtons.tsx index 466256db..9d4cc8a0 100644 --- a/src/js/components/EditorSidebar/actions/ExportButtons.tsx +++ b/src/js/components/EditorSidebar/actions/ExportButtons.tsx @@ -1,27 +1,14 @@ import React from 'react' import { __ } from '@wordpress/i18n' import { useRestAPI } from '../../../hooks/useRestAPI' -import { Button } from '../../common/Button' import { downloadSnippetExportFile } from '../../../utils/files' +import { Button } from '../../common/Button' import { useSnippetForm } from '../../../hooks/useSnippetForm' -import type { SnippetsExport } from '../../../types/schema/SnippetsExport' export const ExportButtons: React.FC = () => { const { snippetsAPI } = useRestAPI() const { snippet, isWorking, setIsWorking, handleRequestError } = useSnippetForm() - const handleFileResponse = (response: string | SnippetsExport) => { - setIsWorking(false) - console.info('file response', response) - - if ('string' === typeof response) { - downloadSnippetExportFile(response, snippet) - } else { - const JSON_INDENT_SPACES = 2 - downloadSnippetExportFile(JSON.stringify(response, undefined, JSON_INDENT_SPACES), snippet, 'json') - } - } - return (
+ + : null} + ) } +const SnippetsTableInner = () => +
+ + +

+ + {SNIPPET_TYPES.map(type => )} +

+ + +
+ export const SnippetsTable: React.FC = () => - + + + diff --git a/src/js/components/SnippetsTable/TableColumns.tsx b/src/js/components/SnippetsTable/TableColumns.tsx index a676f6fa..14216dea 100644 --- a/src/js/components/SnippetsTable/TableColumns.tsx +++ b/src/js/components/SnippetsTable/TableColumns.tsx @@ -1,15 +1,19 @@ -import React, { ChangeEventHandler, useState } from 'react' +import React, { Fragment, useState } from 'react' +import { __, sprintf } from '@wordpress/i18n' +import { addQueryArgs } from '@wordpress/url' +import { humanTimeDiff } from '@wordpress/date' +import { Modal } from '@wordpress/components' import { useRestAPI } from '../../hooks/useRestAPI' import { useSnippetsList } from '../../hooks/useSnippetsList' -import { Snippet } from '../../types/Snippet' import { handleUnknownError } from '../../utils/errors' -import { getSnippetType } from '../../utils/snippets/snippets' +import { downloadSnippetExportFile } from '../../utils/files' +import { isNetworkAdmin } from '../../utils/screen' +import { getSnippetEditUrl, getSnippetType } from '../../utils/snippets/snippets' import { stripTags } from '../../utils/text' import { Badge } from '../common/Badge' -import { ListTableColumn } from '../common/ListTable' -import { __ } from '@wordpress/i18n' -import { addQueryArgs } from '@wordpress/url' -import { humanTimeDiff } from '@wordpress/date' +import { Button } from '../common/Button' +import type { Snippet } from '../../types/Snippet' +import type { ListTableColumn } from '../common/ListTable' interface ColumnProps { snippet: Snippet @@ -36,32 +40,129 @@ const ActivateColumn: React.FC = ({ snippet }) => { ) } +const DeleteRowAction: React.FC = ({ snippet }) => { + const { snippetsAPI } = useRestAPI() + const [confirmDeleteDialogOpen, setConfirmDeleteDialogOpen] = useState(false) + const { refreshSnippetsList } = useSnippetsList() + + return ( + <> + + + {confirmDeleteDialogOpen + ? setConfirmDeleteDialogOpen(false)} + closeButtonLabel={__('Cancel', 'code-snippets')} + > + {__('You are about to permanently delete this snippet.', 'code-snippets')} + + + + + + : null} + + ) +} + +const RowActions: React.FC = ({ snippet }) => { + const { snippetsAPI } = useRestAPI() + + if (!isNetworkAdmin() && snippet.network && !snippet.shared_network) { + return ( +
+ {snippet.active + ? {__('Network Active', 'code-snippets')} + : {__('Network Only', 'code-snippets')}} +
+ ) + } + + if (snippet.shared_network && !window.CODE_SNIPPETS_MANAGE?.hasNetworkCap) { + return undefined + } + + return ( +
+ {__('Edit', 'code-snippets')}{' | '} + + {' | '} + + +
+ ) +} + const NameColumn: React.FC = ({ snippet }) => { - return snippet.name + // translators: %s: snippet identifier. + const displayName = snippet.name.trim() ? snippet.name : sprintf(__('Snippet #%d', 'code-snippets'), snippet.id) + + return ( + <> + {isNetworkAdmin() || !snippet.network || window.CODE_SNIPPETS_MANAGE?.hasNetworkCap + ? {displayName} + : displayName} + + {snippet.shared_network && {__('Shared on Network', 'code-snippets')}} + + + + ) } const PriorityColumn: React.FC = ({ snippet }) => { const [value, setValue] = useState(snippet.priority) - // const { snippetsAPI: { update } } = useRestAPI() + const { snippetsAPI } = useRestAPI() + const { refreshSnippetsList } = useSnippetsList() const id = `snippet-${snippet.id}-priority` - const handleUpdate: ChangeEventHandler = event => { - console.log(event, value) + const handleUpdate = () => { + snippetsAPI.update({ ...snippet, priority: value }) + .then(response => { + if (response.id === snippet.id) { + setValue(response.priority) + } + }) + .then(refreshSnippetsList) + .catch(handleUnknownError) } return ( - <> +
{ + event.preventDefault() + handleUpdate() + }}> setValue(Number(event.target.value))} /> - +
) } @@ -94,19 +195,21 @@ export const TableColumns: ListTableColumn[] = [ title: __('Tags', 'code-snippets'), render: snippet => snippet.tags.map((tag, index) => - <> + {tag} {index < snippet.tags.length - 1 ? ', ' : ''} - + ) }, { id: 'date', title: __('Modified', 'code-snippets'), sortedValue: snippet => snippet.modified ? new Date(snippet.modified).toISOString() : '', - render: snippet => snippet.modified ? humanTimeDiff(snippet.modified, undefined) : '—' + render: snippet => snippet.modified + ? + : '—' }, { id: 'priority', diff --git a/src/js/components/common/ListTable/ListTable.tsx b/src/js/components/common/ListTable/ListTable.tsx index 14e1a029..9e58fb96 100644 --- a/src/js/components/common/ListTable/ListTable.tsx +++ b/src/js/components/common/ListTable/ListTable.tsx @@ -13,6 +13,7 @@ export interface ListTableColumn { render: (item: T) => ReactNode isHidden?: boolean isPrimary?: boolean + isHeading?: boolean sortedValue?: (item: T) => Key defaultSortDirection?: ListTableSortDirection } @@ -82,6 +83,7 @@ export const ListTable = ({ actions, noItems, className, + rowClassName, extraTableNav, isDisabled = false, }: ListTableProps) => { @@ -107,7 +109,7 @@ export const ListTable = ({ - + diff --git a/src/js/components/common/ListTable/TableItems.tsx b/src/js/components/common/ListTable/TableItems.tsx index c5b0996f..7fd3ea00 100644 --- a/src/js/components/common/ListTable/TableItems.tsx +++ b/src/js/components/common/ListTable/TableItems.tsx @@ -1,5 +1,5 @@ import React from 'react' -import type { Dispatch, Key, SetStateAction, ThHTMLAttributes } from 'react' +import type { Dispatch, Key, SetStateAction } from 'react' import type { ListTableColumn, ListTableItemsProps } from './ListTable' interface CheckboxCellProps extends Pick, 'getKey'> { @@ -34,12 +34,11 @@ interface TableCellProps { } const TableCell = ({ item, column }: TableCellProps) => { - const props: ThHTMLAttributes = { - className: `${column.id}-column column-${column.id}`, - children: column.render(item) - } + const className = `${column.id}-column column-${column.id}` - return column.isPrimary ? : + return column.isHeading + ? {column.render(item)} + : {column.render(item)} } export interface TableItemsProps diff --git a/src/js/hooks/useRestAPI.tsx b/src/js/hooks/useRestAPI.tsx index ece0ef5f..12314e5b 100644 --- a/src/js/hooks/useRestAPI.tsx +++ b/src/js/hooks/useRestAPI.tsx @@ -5,7 +5,7 @@ import { REST_API_AXIOS_CONFIG } from '../utils/restAPI' import { buildSnippetsAPI } from '../utils/snippets/api' import type { SnippetsAPI } from '../utils/snippets/api' import type { PropsWithChildren } from 'react' -import type { AxiosInstance, AxiosResponse } from 'axios' +import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios' export interface RestAPIContext { api: RestAPI @@ -15,8 +15,8 @@ export interface RestAPIContext { export interface RestAPI { get: (url: string) => Promise - post: (url: string, data?: D) => Promise - put: (url: string, data?: D) => Promise + post: (url: string, data?: D, config?: AxiosRequestConfig) => Promise + put: (url: string, data?: D, config?: AxiosRequestConfig) => Promise del: (url: string) => Promise } @@ -40,17 +40,17 @@ const buildRestAPI = (axiosInstance: AxiosInstance): RestAPI => ({ get: (url: string): Promise => debugRequest('GET', url, axiosInstance.get, never>(url)), - post: (url: string, data?: D): Promise => - debugRequest('POST', url, axiosInstance.post, typeof data>(url, data), data), + post: (url: string, data?: D, config?: AxiosRequestConfig): Promise => + debugRequest('POST', url, axiosInstance.post, D>(url, data, config), data), del: (url: string): Promise => debugRequest('DELETE', url, axiosInstance.delete, never>(url)), - put: (url: string, data?: D): Promise => - debugRequest('PUT', url, axiosInstance.put, typeof data>(url, data), data) + put: (url: string, data?: D, config?: AxiosRequestConfig): Promise => + debugRequest('PUT', url, axiosInstance.put, D>(url, data, config), data) }) -export const [RestAPIContext, useRestAPI] = createContextHook('RestAPI') +export const [RestAPIContext, useRestAPI] = createContextHook('useRestAPI') export const WithRestAPIContext: React.FC = ({ children }) => { const axiosInstance = useMemo(() => axios.create(REST_API_AXIOS_CONFIG), []) diff --git a/src/js/hooks/useSnippetForm.tsx b/src/js/hooks/useSnippetForm.tsx index 645d5a0f..62c9ec57 100644 --- a/src/js/hooks/useSnippetForm.tsx +++ b/src/js/hooks/useSnippetForm.tsx @@ -22,7 +22,7 @@ export interface SnippetFormContext { setCodeEditorInstance: Dispatch> } -export const [SnippetFormContext, useSnippetForm] = createContextHook('SnippetForm') +export const [SnippetFormContext, useSnippetForm] = createContextHook('useSnippetForm') export interface WithSnippetFormContextProps extends PropsWithChildren { initialSnippet: () => Snippet diff --git a/src/js/hooks/useSnippetSubmit.ts b/src/js/hooks/useSnippetSubmit.ts deleted file mode 100644 index bbc9e2ff..00000000 --- a/src/js/hooks/useSnippetSubmit.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { __ } from '@wordpress/i18n' -import { addQueryArgs } from '@wordpress/url' -import { isAxiosError } from 'axios' -import { useCallback } from 'react' -import { createSnippetObject } from '../utils/snippets' -import { useSnippetsAPI } from './useSnippetsAPI' -import type { Dispatch, SetStateAction } from 'react' -import type { ScreenNotice } from '../types/ScreenNotice' -import type { Snippet } from '../types/Snippet' - -const messages = { - addNew: __('Add New Snippet', 'code-snippets'), - edit: __('Edit Snippet', 'code-snippets'), - created: __('Snippet created.', 'code-snippets'), - updated: __('Snippet updated.', 'code-snippets'), - createdActivated: __('Snippet created and activated.', 'code-snippets'), - updatedActivated: __('Snippet updated and activated.', 'code-snippets'), - updatedExecuted: __('Snippet updated and executed.', 'code-snippets'), - updatedDeactivated: __('Snippet updated and deactivated'), - failedCreate: __('Could not create snippet.', 'code-snippets'), - failedUpdate: __('Could not update snippet.', 'code-snippets') -} - -const getSuccessNotice = (request: Snippet, response: Snippet, active: boolean | undefined): string => { - if (active === undefined) { - return 0 === request.id ? messages.created : messages.updated - } - - if (0 === request.id && active) { - return messages.createdActivated - } - - if (active) { - return 'single-use' === response.scope - ? messages.updatedExecuted - : messages.updatedActivated - } else { - return messages.updatedDeactivated - } -} - -export const useSnippetSubmit = ( - setSnippet: Dispatch>, - setIsWorking: Dispatch>, - setCurrentNotice: Dispatch> -): (snippet: Snippet, active?: boolean) => Promise => { - const api = useSnippetsAPI() - - return useCallback(async (snippet: Snippet, active?: boolean) => { - setIsWorking(true) - setCurrentNotice(undefined) - - const result = await (async (): Promise => { - try { - const requestData: Snippet = { ...snippet, active: active ?? snippet.active } - const data = await (0 === snippet.id ? api.create(requestData) : api.update(requestData)) - setIsWorking(false) - return data.id ? data : undefined - } catch (error) { - setIsWorking(false) - return isAxiosError(error) ? error.message : undefined - } - })() - - if (undefined === result || 'string' === typeof result) { - const message = [ - snippet.id ? messages.failedCreate : messages.failedUpdate, - result ?? __('The server did not send a valid response.', 'code-snippets') - ] - - setCurrentNotice(['error', message.filter(Boolean).join(' ')]) - return undefined - } else { - setSnippet(createSnippetObject(result)) - setCurrentNotice(['updated', getSuccessNotice(snippet, result, active)]) - - if (snippet.id && result.id) { - window.document.title = window.document.title.replace(messages.addNew, messages.edit) - window.history.replaceState({}, '', addQueryArgs(window.CODE_SNIPPETS?.urls.edit, { id: result.id })) - } - - return result - } - }, [api, setCurrentNotice, setIsWorking, setSnippet]) -} diff --git a/src/js/hooks/useSnippetsAPI.ts b/src/js/hooks/useSnippetsAPI.ts index 714e1e4e..5805e35a 100644 --- a/src/js/hooks/useSnippetsAPI.ts +++ b/src/js/hooks/useSnippetsAPI.ts @@ -3,7 +3,7 @@ import { addQueryArgs } from '@wordpress/url' import { handleUnknownError } from '../utils/errors' import { REST_API_AXIOS_CONFIG, REST_SNIPPETS_BASE } from '../utils/restAPI' import { isNetworkAdmin } from '../utils/screen' -import { createSnippetObject } from '../utils/snippets' +import { createSnippetObject } from '../utils/snippets/snippets' import { useAxios } from './useAxios' import type { Snippet } from '../types/Snippet' import type { SnippetsExport } from '../types/SnippetsExport' @@ -37,19 +37,19 @@ export const useSnippetsAPI = (): SnippetsAPI => { get(addQueryArgs(`${REST_SNIPPETS_BASE}/${snippetId}`, { network })), create: snippet => - post(REST_SNIPPETS_BASE, snippet), + post(REST_SNIPPETS_BASE, snippet), update: snippet => - post(buildURL(snippet), snippet), + post(buildURL(snippet), snippet), delete: (snippet: Snippet) => del(buildURL(snippet)), activate: snippet => - post(buildURL(snippet, 'activate')), + post(buildURL(snippet, 'activate')), deactivate: snippet => - post(buildURL(snippet, 'deactivate')), + post(buildURL(snippet, 'deactivate')), export: snippet => get(buildURL(snippet, 'export')), diff --git a/src/js/hooks/useSnippetsList.tsx b/src/js/hooks/useSnippetsList.tsx index f65c6566..3b18b5c4 100644 --- a/src/js/hooks/useSnippetsList.tsx +++ b/src/js/hooks/useSnippetsList.tsx @@ -10,7 +10,7 @@ export interface SnippetsListContext { refreshSnippetsList: () => Promise } -const [SnippetsListContext, useSnippetsList] = createContextHook('SnippetsList') +const [SnippetsListContext, useSnippetsList] = createContextHook('useSnippetsList') export const WithSnippetsListContext: React.FC = ({ children }) => { const { snippetsAPI: { fetchAll } } = useRestAPI() diff --git a/src/js/hooks/useSnippetsTable.tsx b/src/js/hooks/useSnippetsTable.tsx new file mode 100644 index 00000000..9b17339d --- /dev/null +++ b/src/js/hooks/useSnippetsTable.tsx @@ -0,0 +1,170 @@ +import React, { useCallback, useMemo, useState } from 'react' +import { SNIPPET_STATUSES, SNIPPET_TYPES } from '../types/Snippet' +import { createContextHook } from '../utils/bootstrap' +import { parseSnippetObject } from '../utils/snippets/objects' +import { getSnippetType } from '../utils/snippets/snippets' +import { useSnippetsList } from './useSnippetsList' +import type { Snippet, SnippetStatus, SnippetType } from '../types/Snippet' +import type { PropsWithChildren } from 'react' + +const fetchQueryParam = (name: string, validValues?: readonly T[]): T | undefined => { + const urlParams = new URLSearchParams(window.location.search) + const value = urlParams.get(name) + + if (!value) { + return undefined + } + + if (validValues) { + return validValues.includes(value as T) ? (value as T) : undefined + } + + return value as T +} + +const parseSearchQuery = (query?: string): [string | undefined, number | undefined] => { + const lineMatch = query?.trim().match(/@line:(?\d+)/) + const lineNumber = lineMatch?.groups?.line ? parseInt(lineMatch.groups.line, 10) : undefined + + return lineMatch && lineNumber + ? [query?.replace(lineMatch[0], '').trim(), lineNumber] + : [query, undefined] +} + +const updateQueryParam = (name: string, value?: string) => { + if ('URLSearchParams' in window) { + const searchParams = new URLSearchParams(window.location.search) + + if (value) { + searchParams.set(name, value) + } else { + searchParams.delete(name) + } + + const newUrl = window.location.toString().replace(window.location.search, `?${searchParams.toString()}`) + console.log(window.location.search, searchParams.toString(), newUrl) + window.history.replaceState({}, document.title, newUrl) + } +} + +const useFilterSnippetsList = ({ + currentTag, + currentType, + searchQueryText, + searchLineNumber +}: Pick): Snippet[] => { + const { snippetsList } = useSnippetsList() + const sanitizedSearchQueryText = searchQueryText?.toLowerCase().trim() + + const snippets = snippetsList ?? window.CODE_SNIPPETS_MANAGE?.snippetsList.map(parseSnippetObject) + + return useMemo( + () => snippets?.filter(snippet => { + if (currentType && getSnippetType(snippet) !== currentType) { + return false + } + + if (currentTag && !snippet.tags.includes(currentTag)) { + return false + } + + if (sanitizedSearchQueryText) { + if (searchLineNumber !== undefined) { + const codeLines = snippet.code.split('\n') + return codeLines[searchLineNumber]?.includes(sanitizedSearchQueryText) + } else { + const fields = ['name', 'desc', 'code', 'tags'] as const + + return fields.some(field => + ('tags' === field ? snippet.tags.join(' ') : snippet[field]) + .toLowerCase().includes(sanitizedSearchQueryText.toLowerCase())) + } + } + + return true + }) ?? [], + [snippets, currentTag, currentType, sanitizedSearchQueryText, searchLineNumber]) +} + +const useSnippetsByStatus = (snippets: Snippet[]) => + useMemo(() => + snippets.reduce((acc, snippet) => { + if (!acc.get(undefined)?.push(snippet)) { + acc.set(undefined, [snippet]) + } + + const status = snippet.lastActive + ? 'recently_activated' + : snippet.active ? 'active' : 'inactive' + + if (!acc.get(status)?.push(snippet)) { + acc.set(status, [snippet]) + } + + return acc + }, new Map()), + [snippets]) + +export interface SnippetsTableContext { + currentTag: string | undefined + currentType: SnippetType | undefined + searchQuery: string | undefined + currentStatus: SnippetStatus | undefined + setCurrentTag: (tag?: string) => void + setCurrentType: (type?: SnippetType) => void + setCurrentStatus: (status?: SnippetStatus) => void + setSearchQuery: (query?: string) => void + snippetsByStatus: Map + searchLineNumber?: number + searchQueryText?: string +} + +export const [SnippetsTableContext, useSnippetsTable] = createContextHook('useSnippetsTable') + +export const WithSnippetsTableContext: React.FC = ({ children }) => { + const [currentTag, setTag] = useState(() => fetchQueryParam('tag')) + const [currentType, setType] = useState(() => fetchQueryParam('type', SNIPPET_TYPES)) + const [currentStatus, setStatus] = useState(() => fetchQueryParam('status', SNIPPET_STATUSES)) + const [searchQuery, setSearch] = useState(() => fetchQueryParam('s')) + + const setCurrentType = useCallback((type?: SnippetType) => { + setType(type) + updateQueryParam('type', type) + }, [setType]) + + const setCurrentStatus = useCallback((status?: SnippetStatus) => { + setStatus(status) + updateQueryParam('status', status) + }, [setStatus]) + + const setCurrentTag = useCallback((tag?: string) => { + setTag(tag) + updateQueryParam('tag', tag) + }, [setTag]) + + const setSearchQuery = useCallback((query?: string) => { + setSearch(query) + updateQueryParam('s', query) + }, [setSearch]) + + const [searchQueryText, searchLineNumber] = useMemo(() => parseSearchQuery(searchQuery), [searchQuery]) + + const visibleSnippets = useFilterSnippetsList({ currentTag, currentType, searchLineNumber, searchQueryText }) + const snippetsByStatus = useSnippetsByStatus(visibleSnippets) + + const value: SnippetsTableContext = { + currentTag, + currentType, + searchQuery, + currentStatus, + setCurrentTag, + setCurrentType, + setSearchQuery, + setCurrentStatus, + snippetsByStatus, + searchLineNumber, + searchQueryText + } + + return {children} +} diff --git a/src/js/types/Snippet.ts b/src/js/types/Snippet.ts index 948826ef..d588d85b 100644 --- a/src/js/types/Snippet.ts +++ b/src/js/types/Snippet.ts @@ -11,11 +11,12 @@ export interface Snippet { readonly shared_network?: boolean | null readonly modified?: string readonly conditionId: number + readonly lastActive?: number readonly code_error?: readonly [string, number] | null } -export const SNIPPET_TYPES = ['php', 'html', 'css', 'js', 'cond'] as const -export const SNIPPET_STATUSES = ['active', 'inactive', 'recently_activated'] as const +export const SNIPPET_TYPES = ['php', 'html', 'css', 'js', 'cond'] +export const SNIPPET_STATUSES = ['active', 'inactive', 'recently_activated'] export type SnippetType = typeof SNIPPET_TYPES[number] export type SnippetStatus = typeof SNIPPET_STATUSES[number] diff --git a/src/js/types/Window.ts b/src/js/types/Window.ts index 027f0505..4c29c8b3 100644 --- a/src/js/types/Window.ts +++ b/src/js/types/Window.ts @@ -36,6 +36,10 @@ declare global { connectCloud: string } } + readonly CODE_SNIPPETS_MANAGE?: { + hasNetworkCap: boolean + snippetsList: Snippet[] + } readonly CODE_SNIPPETS_EDIT?: { snippet: Snippet pageTitleActions: Record diff --git a/src/js/types/schema/SnippetSchema.ts b/src/js/types/schema/SnippetSchema.ts index a765bf8e..41ac2a83 100644 --- a/src/js/types/schema/SnippetSchema.ts +++ b/src/js/types/schema/SnippetSchema.ts @@ -16,5 +16,6 @@ export interface WritableSnippetSchema { export interface SnippetSchema extends Readonly> { readonly id: number readonly modified: string + readonly last_active?: number readonly code_error?: readonly [string, number] | null } diff --git a/src/js/utils/bootstrap.tsx b/src/js/utils/bootstrap.tsx index 978a7429..671c2b99 100644 --- a/src/js/utils/bootstrap.tsx +++ b/src/js/utils/bootstrap.tsx @@ -1,20 +1,21 @@ -import { createContext, useContext } from 'react' +import React, { createContext, useContext } from 'react' import { createRoot } from 'react-dom/client' import type { Context, FunctionComponent } from 'react' -import React from 'react' export const loadComponent = (containerId: string, Component: FunctionComponent): void => { - const container = document.getElementById('snippets-table-container') + window.addEventListener('DOMContentLoaded', () => { + const container = document.getElementById(containerId) - if (container) { - const root = createRoot(container) - root.render() - } else { - console.error(`Could not find ${containerId.replace(/-_/, ' ')}.`) - } + if (container) { + const root = createRoot(container) + root.render() + } else { + console.error(`Could not find element #${containerId}.`) + } + }) } -export const createContextHook = (name: string): [ +export const createContextHook = (hookName: string): [ Context, () => T ] => { @@ -24,7 +25,7 @@ export const createContextHook = (name: string): [ const value = useContext(contextValue) if (value === undefined) { - throw Error(`use${name} can only be used within a ${name} context provider.`) + throw Error(`${hookName} can only be used within a corresponding context provider.`) } return value diff --git a/src/js/utils/files.ts b/src/js/utils/files.ts index f0a54880..119e7e2b 100644 --- a/src/js/utils/files.ts +++ b/src/js/utils/files.ts @@ -1,8 +1,10 @@ import { getSnippetType } from './snippets/snippets' +import type { SnippetsExport } from '../types/SnippetsExport' import type { Snippet } from '../types/Snippet' const SECOND_IN_MS = 1000 const TIMEOUT_SECONDS = 40 +const JSON_INDENT_SPACES = 2 const MIME_INFO = { php: ['php', 'text/php'], @@ -23,16 +25,19 @@ export const downloadAsFile = (content: BlobPart, filename: string, type: string } export const downloadSnippetExportFile = ( - content: BlobPart, + content: SnippetsExport | string, { id, name, scope }: Snippet, type?: keyof typeof MIME_INFO ) => { - const [ext, mimeType] = MIME_INFO[type ?? getSnippetType({ scope })] - const sanitizedName = name.toLowerCase().replace(/[^\w-]+/g, '-').trim() - const title = '' === sanitizedName ? `snippet-${id}` : sanitizedName - const filename = `${title}.code-snippets.${ext}` - downloadAsFile(content, filename, mimeType) + if ('string' === typeof content) { + const [ext, mimeType] = MIME_INFO[type ?? getSnippetType({ scope })] + const filename = `${title}.code-snippets.${ext}` + downloadAsFile(content, filename, mimeType) + } else { + const filename = `${title}.code-snippets.json` + downloadAsFile(JSON.stringify(content, undefined, JSON_INDENT_SPACES), filename, 'application/json') + } } diff --git a/src/js/utils/restAPI.ts b/src/js/utils/restAPI.ts index 1a6b14e8..f2888bc6 100644 --- a/src/js/utils/restAPI.ts +++ b/src/js/utils/restAPI.ts @@ -1,6 +1,8 @@ import { trimTrailingChar } from './text' import type { AxiosRequestConfig } from 'axios' +export const REST_API_NAMESPACE = 'code-snippets' + export const REST_BASE = trimTrailingChar(window.CODE_SNIPPETS?.restAPI.base ?? '', '/') export const REST_SNIPPETS_BASE = trimTrailingChar(window.CODE_SNIPPETS?.restAPI.snippets ?? '', '/') diff --git a/src/js/utils/snippets/objects.ts b/src/js/utils/snippets/objects.ts index 09c9c272..ee74c2a2 100644 --- a/src/js/utils/snippets/objects.ts +++ b/src/js/utils/snippets/objects.ts @@ -1,5 +1,20 @@ -import type { Snippet, SnippetScope } from '../../types/Snippet' import { SNIPPET_TYPE_SCOPES } from '../../types/Snippet' +import { isNetworkAdmin } from '../screen' +import type { Snippet, SnippetScope } from '../../types/Snippet' + +const defaults: Omit = { + id: 0, + name: '', + code: '', + desc: '', + scope: 'global', + modified: '', + active: false, + network: isNetworkAdmin(), + shared_network: null, + priority: 10, + conditionId: 0 +} const isAbsInt = (value: unknown): value is number => 'number' === typeof value && 0 < value @@ -11,22 +26,28 @@ export const isValidScope = (scope: unknown): scope is SnippetScope => 'string' === typeof scope && Object.values(SNIPPET_TYPE_SCOPES).some(typeScopes => typeScopes.some(typeScope => typeScope === scope)) -export const parseSnippetObject = (fields: unknown, defaults: Snippet): Snippet => - 'object' !== typeof fields || null === fields - ? defaults - : { - id: 'id' in fields && isAbsInt(fields.id) ? fields.id : defaults.id, - name: 'name' in fields && 'string' === typeof fields.name ? fields.name : defaults.name, - desc: 'desc' in fields && 'string' === typeof fields.desc ? fields.desc : defaults.desc, - code: 'code' in fields && 'string' === typeof fields.code ? fields.code : defaults.code, - tags: 'tags' in fields ? parseStringArray(fields.tags) ?? defaults.tags : defaults.tags, - scope: 'scope' in fields && isValidScope(fields.scope) ? fields.scope : defaults.scope, - modified: 'modified' in fields && 'string' === typeof fields.modified ? fields.modified : defaults.modified, - active: 'active' in fields && 'boolean' === typeof fields.active ? fields.active : defaults.active, - network: 'network' in fields && 'boolean' === typeof fields.network ? fields.network : defaults.network, - shared_network: 'shared_network' in fields && 'boolean' === typeof fields.shared_network && fields.shared_network || - defaults.shared_network, - priority: 'priority' in fields && 'number' === typeof fields.priority ? fields.priority : defaults.priority, - conditionId: 'condition_id' in fields && isAbsInt(fields.condition_id) ? fields.condition_id - : 'conditionId' in fields && isAbsInt(fields.conditionId) ? fields.conditionId : defaults.conditionId - } +export const parseSnippetObject = (fields: unknown): Snippet => { + const result: { -readonly [F in keyof Snippet]: Snippet[F] } = { ...defaults, tags: [] } + + if ('object' !== typeof fields || null === fields) { + return result + } + + return { + id: 'id' in fields && isAbsInt(fields.id) ? fields.id : defaults.id, + name: 'name' in fields && 'string' === typeof fields.name ? fields.name : defaults.name, + desc: 'desc' in fields && 'string' === typeof fields.desc ? fields.desc : defaults.desc, + code: 'code' in fields && 'string' === typeof fields.code ? fields.code : defaults.code, + tags: 'tags' in fields ? parseStringArray(fields.tags) ?? [] : [], + scope: 'scope' in fields && isValidScope(fields.scope) ? fields.scope : defaults.scope, + modified: 'modified' in fields && 'string' === typeof fields.modified ? fields.modified : defaults.modified, + active: 'active' in fields && 'boolean' === typeof fields.active ? fields.active : defaults.active, + network: 'network' in fields && 'boolean' === typeof fields.network ? fields.network : defaults.network, + shared_network: 'shared_network' in fields && 'boolean' === typeof fields.shared_network && fields.shared_network || + defaults.shared_network, + priority: 'priority' in fields && 'number' === typeof fields.priority ? fields.priority : defaults.priority, + conditionId: 'condition_id' in fields && isAbsInt(fields.condition_id) ? fields.condition_id + : 'conditionId' in fields && isAbsInt(fields.conditionId) ? fields.conditionId : defaults.conditionId, + lastActive: 'last_active' in fields ? Number(fields.last_active) : undefined + } +} diff --git a/src/js/utils/snippets/snippets.ts b/src/js/utils/snippets/snippets.ts index 4a3a5d20..73529206 100644 --- a/src/js/utils/snippets/snippets.ts +++ b/src/js/utils/snippets/snippets.ts @@ -1,5 +1,5 @@ import { __ } from '@wordpress/i18n' -import { isNetworkAdmin } from '../screen' +import { addQueryArgs } from '@wordpress/url' import { parseSnippetObject } from './objects' import type { Snippet, SnippetType } from '../../types/Snippet' @@ -13,22 +13,8 @@ export const SNIPPET_TYPE_LABELS: Record = { const PRO_TYPES = new Set(['css', 'js', 'cond']) -const defaults: Omit = { - id: 0, - name: '', - code: '', - desc: '', - scope: 'global', - modified: '', - active: false, - network: isNetworkAdmin(), - shared_network: null, - priority: 10, - conditionId: 0 -} - export const createSnippetObject = (fields: unknown = null): Snippet => - parseSnippetObject(fields, { ...defaults, tags: [] }) + parseSnippetObject(fields) export const getSnippetType = ({ scope }: Pick): SnippetType => { switch (true) { @@ -49,6 +35,11 @@ export const getSnippetType = ({ scope }: Pick): SnippetType = } } +export const getSnippetEditUrl = (snippet?: Pick): string | undefined => + snippet?.id + ? addQueryArgs(window.CODE_SNIPPETS?.urls.edit, { id: snippet.id }) + : window.CODE_SNIPPETS?.urls.addNew + export const validateSnippet = (snippet: Snippet): undefined | string => { const missingTitle = '' === snippet.name.trim() const missingCode = '' === snippet.code.trim() diff --git a/src/php/admin-menus/class-edit-menu.php b/src/php/admin-menus/class-edit-menu.php index 75e77c86..e8a99290 100644 --- a/src/php/admin-menus/class-edit-menu.php +++ b/src/php/admin-menus/class-edit-menu.php @@ -67,7 +67,7 @@ public function register() { $this->add_menu( code_snippets()->get_menu_slug( 'add' ), _x( 'Add New', 'menu label', 'code-snippets' ), - __( 'Create New Snippet', 'code-snippets' ) + __( 'Add New Snippet', 'code-snippets' ) ); } diff --git a/src/php/admin-menus/class-manage-menu.php b/src/php/admin-menus/class-manage-menu.php index 7b711820..af8a68cb 100644 --- a/src/php/admin-menus/class-manage-menu.php +++ b/src/php/admin-menus/class-manage-menu.php @@ -3,6 +3,7 @@ namespace Code_Snippets; use Code_Snippets\Cloud\Cloud_Search_List_Table; +use Code_Snippets\REST_API\Snippets_REST_Controller; use function Code_Snippets\Settings\get_setting; /** @@ -221,7 +222,13 @@ public function enqueue_assets() { self::JS_HANDLE, 'CODE_SNIPPETS_MANAGE', [ - 'pageTitleActions' => $plugin->is_compact_menu() ? $this->page_title_action_links( [ 'add', 'import', 'settings' ] ) : [], + 'hasNetworkCap' => current_user_can( code_snippets()->get_network_cap_name() ), + 'snippetsList' => array_map( + function ( $snippet ) { + return $snippet->get_fields(); + }, + get_snippets() + ), ] ); } diff --git a/src/php/class-plugin.php b/src/php/class-plugin.php index 973281d0..14950c8c 100644 --- a/src/php/class-plugin.php +++ b/src/php/class-plugin.php @@ -89,7 +89,6 @@ public function __construct( string $version, string $file ) { add_filter( 'admin_url', array( $this, 'add_safe_mode_query_var' ) ); } - add_action( 'rest_api_init', [ $this, 'init_rest_api' ] ); add_action( 'allowed_redirect_hosts', [ $this, 'allow_code_snippets_redirect' ] ); } @@ -124,22 +123,13 @@ public function load_plugin() { $this->active_snippets = new Active_Snippets(); $this->front_end = new Front_End(); $this->cloud_api = new Cloud_API(); + new Rest_API(); $upgrade = new Upgrade( $this->version, $this->db ); add_action( 'plugins_loaded', array( $upgrade, 'run' ), 0 ); $this->licensing = new Licensing(); } - /** - * Register custom REST API controllers. - * - * @return void - */ - public function init_rest_api() { - $snippets_controller = new Snippets_REST_Controller(); - $snippets_controller->register_routes(); - } - /** * Disable snippet execution if the necessary query var is set. * diff --git a/src/php/class-snippet.php b/src/php/class-snippet.php index ecab62c9..992df301 100644 --- a/src/php/class-snippet.php +++ b/src/php/class-snippet.php @@ -5,6 +5,7 @@ use DateTime; use DateTimeZone; use Exception; +use function Code_Snippets\Settings\get_self_option; /** * A snippet object. @@ -28,6 +29,7 @@ * @property int $revision Revision or version number of snippet. * @property string $cloud_id Cloud ID and ownership status of snippet. * + * @property-read int $last_active Timestamp of when the snippet was last active, if available. * @property-read string $display_name The snippet name if it exists or a placeholder if it does not. * @property-read string $tags_list The tags in string list format. * @property-read string $scope_icon The dashicon used to represent the current scope. @@ -202,6 +204,16 @@ public static function get_types(): array { return [ 'php', 'html', 'css', 'js', 'cond' ]; } + /** + * Retrieve the timestamp of when the snippet was last active, if available. + * + * @return string + */ + protected function get_last_active(): string { + $recently_active = get_self_option( $this->network, 'recently_activated_snippets', [] ); + return $recently_active[ (string) $this->id ] ?? 0; + } + /** * Determine the language that the snippet code is written in, based on the scope * diff --git a/src/php/rest-api/class-rest-api.php b/src/php/rest-api/class-rest-api.php new file mode 100644 index 00000000..088697b3 --- /dev/null +++ b/src/php/rest-api/class-rest-api.php @@ -0,0 +1,129 @@ +register_routes(); + $this->register_routes(); + } + + /** + * Register REST routes. + */ + public function register_routes() { + register_rest_route( + $this->namespace, + 'recently-active', + [ + [ + 'methods' => WP_REST_Server::READABLE, + 'callback' => [ $this, 'get_recent_list_callback' ], + 'permission_callback' => [ code_snippets(), 'current_user_can' ], + 'args' => [ + 'network' => [ + 'description' => esc_html__( 'Fetch the recent list for network-wide snippets instead of site-wide.', 'code-snippets' ), + 'type' => 'boolean', + 'default' => false, + ], + ], + ], + ] + ); + + register_rest_route( + $this->namespace, + 'recently-active', + [ + [ + 'methods' => WP_REST_Server::DELETABLE, + 'callback' => [ $this, 'clear_recent_list_callback' ], + 'permission_callback' => [ code_snippets(), 'current_user_can' ], + 'args' => [ + 'network' => [ + 'description' => esc_html__( 'Clear the recent list for network-wide snippets instead of site-wide.', 'code-snippets' ), + 'type' => 'boolean', + 'default' => false, + ], + ], + ], + ] + ); + } + + /** + * Callback for retrieving the recently activated snippets list. + * + * This will return the list of recently activated snippets, either site-wide or network-wide, + * depending on the 'network' parameter. + * + * @param WP_REST_Request $request The REST request object. + * + * @return WP_REST_Response The recently activated snippets list. + */ + public function get_recent_list_callback( WP_REST_Request $request ): WP_REST_Response { + return rest_ensure_response( + $request->get_param( 'network' ) + ? get_site_option( 'recently_activated_snippets', [] ) + : get_option( 'recently_activated_snippets', [] ) + ); + } + + /** + * Callback for clearing the recently activated snippets list. + * + * This will clear the list of recently activated snippets, either site-wide or network-wide, + * depending on the 'network' parameter. + * + * @param WP_REST_Request $request The REST request object. + * + * @return WP_REST_Response The recently activated snippets list prior to clearing it. + */ + public function clear_recent_list_callback( WP_REST_Request $request ): WP_REST_Response { + if ( $request->get_param( 'network' ) ) { + $current = get_site_option( 'recently_activated_snippets', [] ); + update_site_option( 'recently_activated_snippets', [] ); + } else { + $current = get_option( 'recently_activated_snippets', [] ); + update_option( 'recently_activated_snippets', [] ); + } + + return rest_ensure_response( $current ); + } +} diff --git a/src/php/rest-api/class-snippets-rest-controller.php b/src/php/rest-api/class-snippets-rest-controller.php index 036e5470..77fe9c0a 100644 --- a/src/php/rest-api/class-snippets-rest-controller.php +++ b/src/php/rest-api/class-snippets-rest-controller.php @@ -547,6 +547,11 @@ public function get_item_schema(): array { 'format' => 'date-time', 'readonly' => true, ], + 'last_active' => [ + 'description' => esc_html__( 'Timestamp of when the snippet was last active, if available.', 'code-snippets' ), + 'type' => 'integer', + 'readonly' => true, + ], 'code_error' => [ 'description' => esc_html__( 'Error message if the snippet code could not be parsed.', 'code-snippets' ), 'type' => 'string', diff --git a/src/php/snippet-ops.php b/src/php/snippet-ops.php index 65420aa8..aa77d992 100644 --- a/src/php/snippet-ops.php +++ b/src/php/snippet-ops.php @@ -445,6 +445,13 @@ function delete_snippet( int $id, ?bool $network = null ): bool { do_action( 'code_snippets/delete_snippet', $id, $network ); clean_snippets_cache( $table ); code_snippets()->cloud_api->delete_snippet_from_transient_data( $id ); + + $recently_active = get_self_option( $network, 'recently_activated_snippets', [] ); + + if ( isset( $recently_active[ $id ] ) ) { + unset( $recently_active[ $id ] ); + update_self_option( $network, 'recently_activated_snippets', $recently_active ); + } } return (bool) $result; @@ -492,7 +499,7 @@ function test_snippet_code( Snippet $snippet ) { * * @since 2.0.0 */ -function save_snippet( $snippet ) { +function save_snippet( $snippet ): ?Snippet { global $wpdb; $table = code_snippets()->db->get_table_name( $snippet->network ); @@ -551,19 +558,34 @@ function save_snippet( $snippet ) { $snippet->id = $wpdb->insert_id; do_action( 'code_snippets/create_snippet', $snippet, $table ); } else { + $existing = get_snippet( $snippet->id, $snippet->network ); // Otherwise, update the snippet data. $result = $wpdb->update( $table, $data, [ 'id' => $snippet->id ], null, [ '%d' ] ); + if ( false === $result ) { return null; } - do_action( 'code_snippets/update_snippet', $snippet, $table ); + $updated = get_snippet( $snippet->id, $snippet->network ); + do_action( 'code_snippets/update_snippet', $updated, $table, $existing ); + + if ( ! $updated->active && $existing->active ) { + $recently_active = [ $updated->id => time() ] + get_self_option( $updated->network, 'recently_activated_snippets', [] ); + update_self_option( $updated->network, 'recently_activated_snippets', $recently_active ); + } elseif ( ! $updated->active ) { + $recently_active = get_self_option( $updated->network, 'recently_activated_snippets', [] ); + + if ( isset( $recently_active[ $updated->id ] ) ) { + unset( $recently_active[ $updated->id ] ); + update_self_option( $updated->network, 'recently_activated_snippets', $recently_active ); + } + } } - update_shared_network_snippets( [ $snippet ] ); + update_shared_network_snippets( [ $updated ] ); clean_snippets_cache( $table ); - return $snippet; + return $updated; } /** From 00935b222e837de6b3ea293121c34426fcfd51ba Mon Sep 17 00:00:00 2001 From: Shea Bunge Date: Fri, 25 Jul 2025 01:02:29 +1000 Subject: [PATCH 08/15] Add upsell dialog to manage page. --- src/css/manage.scss | 1 + .../SnippetsTable/SnippetsTable.tsx | 71 ++++++++++++------- 2 files changed, 45 insertions(+), 27 deletions(-) diff --git a/src/css/manage.scss b/src/css/manage.scss index e55346f9..1582d8eb 100644 --- a/src/css/manage.scss +++ b/src/css/manage.scss @@ -1,6 +1,7 @@ @use 'common/badges'; @use 'common/switch'; @use 'common/select'; +@use 'common/upsell'; .nav-tab { display: flex; diff --git a/src/js/components/SnippetsTable/SnippetsTable.tsx b/src/js/components/SnippetsTable/SnippetsTable.tsx index bc938e05..8a6a84fa 100644 --- a/src/js/components/SnippetsTable/SnippetsTable.tsx +++ b/src/js/components/SnippetsTable/SnippetsTable.tsx @@ -1,41 +1,50 @@ import { __, sprintf } from '@wordpress/i18n' -import React from 'react' +import React, { useState } from 'react' import classnames from 'classnames' import { addQueryArgs } from '@wordpress/url' import { WithRestAPIContext } from '../../hooks/useRestAPI' import { WithSnippetsListContext } from '../../hooks/useSnippetsList' import { WithSnippetsTableContext, useSnippetsTable } from '../../hooks/useSnippetsTable' import { SNIPPET_TYPES } from '../../types/Snippet' -import { SNIPPET_TYPE_LABELS } from '../../utils/snippets/snippets' +import { isLicensed } from '../../utils/screen' +import { isProType, SNIPPET_TYPE_LABELS } from '../../utils/snippets/snippets' import { Badge } from '../common/Badge' import { Button } from '../common/Button' +import { UpsellDialog } from '../common/UpsellDialog' import { SnippetsListTable } from './SnippetsListTable' import type { SnippetType } from '../../types/Snippet' -import type { MouseEventHandler } from 'react' interface SnippetTypeTabProps { type?: SnippetType + setIsUpgradeDialogOpen: (isOpen: boolean) => void } -const SnippetTypeTab: React.FC = ({ type }) => { +const SnippetTypeTab: React.FC = ({ type, setIsUpgradeDialogOpen }) => { const { currentType, setCurrentType } = useSnippetsTable() const tabName = type ?? 'all' - const handleClick: MouseEventHandler = event => { - event.preventDefault() - setCurrentType(type) - } - return ( { + event.preventDefault() + + if (type && !isLicensed() && isProType(type)) { + setIsUpgradeDialogOpen(true) + } else { + setCurrentType(type) + } + }} > - - {type ? SNIPPET_TYPE_LABELS[type] : __('All Snippets', 'code-snippets')} - - {type && } + + {type ? SNIPPET_TYPE_LABELS[type] : __('All Snippets', 'code-snippets')} + + {type && + } ) } @@ -51,13 +60,13 @@ const PageHeading = () => { {__('Search results', 'code-snippets')} {/* translators: %s: search query. */} - {searchQueryText && sprintf( __( ' for ā€œ%sā€', 'code-snippets' ), searchQueryText )} + {searchQueryText && sprintf(__(' for ā€œ%sā€', 'code-snippets'), searchQueryText)} {/* translators: %s: search query. */} - {searchLineNumber && sprintf( __( ' on line ā€œ%dā€', 'code-snippets' ), searchLineNumber )} + {searchLineNumber && sprintf(__(' on line ā€œ%dā€', 'code-snippets'), searchLineNumber)} {/* translators: %s: tag name. */} - {currentTag && sprintf( __( ' in tag ā€œ%sā€', 'code-snippets' ), currentTag )} + {currentTag && sprintf(__(' in tag ā€œ%sā€', 'code-snippets'), currentTag)} {' '} - - {confirmDeleteDialogOpen - ? setConfirmDeleteDialogOpen(false)} - closeButtonLabel={__('Cancel', 'code-snippets')} - > - {__('You are about to permanently delete this snippet.', 'code-snippets')} - - - - - - : null} - - ) + default: { + const actionText = snippet.network && !snippet.shared_network + ? snippet.active ? __('Network Deactivate', 'code-snippets') : __('Network Activate', 'code-snippets') + : snippet.active ? __('Deactivate', 'code-snippets') : __('Activate', 'code-snippets') + + return ( + <> + + + { + (snippet.active ? deactivate(snippet) : activate(snippet)) + .then(() => refreshSnippetsList()) + .catch(handleUnknownError) + }} + /> + + ) + } + } } const RowActions: React.FC = ({ snippet }) => { const { snippetsAPI } = useRestAPI() + const { refreshSnippetsList } = useSnippetsList() if (!isNetworkAdmin() && snippet.network && !snippet.shared_network) { return ( @@ -100,6 +96,20 @@ const RowActions: React.FC = ({ snippet }) => {
{__('Edit', 'code-snippets')}{' | '} + {' | '} + {' | '} - +
) } -const NameColumn: React.FC = ({ snippet }) => { - // translators: %s: snippet identifier. - const displayName = snippet.name.trim() ? snippet.name : sprintf(__('Snippet #%d', 'code-snippets'), snippet.id) +const NameColumn: React.FC = ({ snippet }) => + <> + {isNetworkAdmin() || !snippet.network || window.CODE_SNIPPETS_MANAGE?.hasNetworkCap + ? {getSnippetDisplayName(snippet)} + : getSnippetDisplayName(snippet)} - return ( - <> - {isNetworkAdmin() || !snippet.network || window.CODE_SNIPPETS_MANAGE?.hasNetworkCap - ? {displayName} - : displayName} + {snippet.shared_network && {__('Shared on Network', 'code-snippets')}} + + + - {snippet.shared_network && {__('Shared on Network', 'code-snippets')}} +const TypeColumn: React.FC = ({ snippet }) => { + const { setCurrentType } = useSnippetsFilters() + const type = getSnippetType(snippet) - - + return ( + { + event.preventDefault() + setCurrentType(type) + }} + > + + ) } +const TagsColumn: React.FC = ({ snippet }) => + snippet.tags.map((tag, index) => + + + {tag} + + {index < snippet.tags.length - 1 ? ', ' : ''} + ) + +const DateColumn: React.FC = ({ snippet }) => + snippet.modified + ? + + + : <>— + const PriorityColumn: React.FC = ({ snippet }) => { const [value, setValue] = useState(snippet.priority) const { snippetsAPI } = useRestAPI() @@ -175,41 +214,30 @@ export const TableColumns: ListTableColumn[] = [ id: 'name', title: __('Name', 'code-snippets'), isPrimary: true, - sortedValue: item => item.name.toLowerCase(), + sortedValue: snippet => getSnippetDisplayName(snippet).toLowerCase(), render: snippet => }, { id: 'type', title: __('Type', 'code-snippets'), - sortedValue: item => getSnippetType(item), - render: snippet => + sortedValue: snippet => getSnippetType(snippet), + render: snippet => }, { id: 'desc', title: __('Description', 'code-snippets'), - // TODO: figure out how to allow formatting and markup. - render: snippet => stripTags(snippet.desc) + render: snippet => {snippet.desc} }, { id: 'tags', title: __('Tags', 'code-snippets'), - render: snippet => - snippet.tags.map((tag, index) => - - - {tag} - - {index < snippet.tags.length - 1 ? ', ' : ''} - - ) + render: snippet => }, { id: 'date', title: __('Modified', 'code-snippets'), sortedValue: snippet => snippet.modified ? new Date(snippet.modified).toISOString() : '', - render: snippet => snippet.modified - ? - : '—' + render: snippet => }, { id: 'priority', diff --git a/src/js/components/EditorSidebar/actions/DeleteButton.tsx b/src/js/components/common/DeleteButton.tsx similarity index 51% rename from src/js/components/EditorSidebar/actions/DeleteButton.tsx rename to src/js/components/common/DeleteButton.tsx index 1dc7f1cb..97eaed04 100644 --- a/src/js/components/EditorSidebar/actions/DeleteButton.tsx +++ b/src/js/components/common/DeleteButton.tsx @@ -1,22 +1,34 @@ -import { addQueryArgs } from '@wordpress/url' import React, { useState } from 'react' import { __ } from '@wordpress/i18n' -import { useRestAPI } from '../../../hooks/useRestAPI' -import { Button } from '../../common/Button' -import { ConfirmDialog } from '../../common/ConfirmDialog' -import { useSnippetForm } from '../../../hooks/useSnippetForm' +import { useRestAPI } from '../../hooks/useRestAPI' +import { Button } from './Button' +import { ConfirmDialog } from './ConfirmDialog' +import type { Snippet } from '../../types/Snippet' +import type { ButtonProps } from './Button' -export const DeleteButton: React.FC = () => { +export interface DeleteButtonProps extends ButtonProps { + snippet: Snippet + setIsWorking?: (isWorking: boolean) => void + onSuccess?: () => Promise | void + onError?: (error: unknown) => void +} + +export const DeleteButton: React.FC = ({ + snippet, + onSuccess, + onError, + className = 'delete-button', + setIsWorking, + ...buttonProps +}) => { const { snippetsAPI } = useRestAPI() - const { snippet, setIsWorking, isWorking, handleRequestError } = useSnippetForm() const [isDialogOpen, setIsDialogOpen] = useState(false) return ( <>
+ : null +} diff --git a/src/js/components/WelcomePage/Changelog.tsx b/src/js/components/WelcomePage/Changelog.tsx index dd5c4adb..30366732 100644 --- a/src/js/components/WelcomePage/Changelog.tsx +++ b/src/js/components/WelcomePage/Changelog.tsx @@ -1,7 +1,7 @@ import React, { Fragment } from 'react' +import { __, sprintf } from '@wordpress/i18n' import { CHANGELOG_SECTIONS } from '../../types/schema/WelcomeSchema' import type { ChangelogSectionTitle } from '../../types/schema/WelcomeSchema' -import { __, sprintf } from '@wordpress/i18n' const CHANGELOG_LABELS: Record = { Added: __('New features', 'code-snippets'), @@ -61,13 +61,13 @@ export const Changelog = () => {__('View changelog', 'code-snippets')}
- {CHANGELOG_DATA && CHANGELOG_DATA.map(({ version, date, entries }) => + {CHANGELOG_DATA?.map(({ version, date, entries }) =>
{/* translators: %s: version number. */} diff --git a/src/js/components/WelcomePage/WelcomePage.tsx b/src/js/components/WelcomePage/WelcomePage.tsx index 8a1edea3..6ef33fba 100644 --- a/src/js/components/WelcomePage/WelcomePage.tsx +++ b/src/js/components/WelcomePage/WelcomePage.tsx @@ -1,8 +1,8 @@ -import { __ } from "@wordpress/i18n" -import React, { useState } from "react" -import { ImageLinkSchema } from '../../types/schema/WelcomeSchema' +import { __ } from '@wordpress/i18n' +import React, { useState } from 'react' import { Toolbar } from '../common/Toolbar' import { Changelog } from './Changelog' +import type { ImageLinkSchema } from '../../types/schema/WelcomeSchema' const DATA = window.CODE_SNIPPETS_WELCOME @@ -58,7 +58,7 @@ const Partners: React.FC = ({ partners }) => interface ArticlesProps { - articles: ImageLinkSchema[] + articles: ImageLinkSchema[] } const Articles: React.FC = ({ articles }) => diff --git a/src/js/components/common/Toolbar.tsx b/src/js/components/common/Toolbar.tsx index b390f493..23039d97 100644 --- a/src/js/components/common/Toolbar.tsx +++ b/src/js/components/common/Toolbar.tsx @@ -1,10 +1,11 @@ import { __ } from '@wordpress/i18n' import classnames from 'classnames' -import React, { ReactNode, useState } from 'react' +import React, { useState } from 'react' import { isLicensed, shouldShowUpsell } from '../../utils/screen' import { fetchQueryParam } from '../../utils/urls' import { CommunityIcon, LibraryIcon, SettingsIcon, SnippetsIcon, TeamsIcon } from './icons/ToolbarIcons' import { UpsellDialog } from './UpsellDialog' +import type { ReactNode } from 'react' interface NavLink { name: string diff --git a/src/js/hooks/useSnippetsAPI.ts b/src/js/hooks/useSnippetsAPI.ts deleted file mode 100644 index 5805e35a..00000000 --- a/src/js/hooks/useSnippetsAPI.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { useEffect, useMemo, useState } from 'react' -import { addQueryArgs } from '@wordpress/url' -import { handleUnknownError } from '../utils/errors' -import { REST_API_AXIOS_CONFIG, REST_SNIPPETS_BASE } from '../utils/restAPI' -import { isNetworkAdmin } from '../utils/screen' -import { createSnippetObject } from '../utils/snippets/snippets' -import { useAxios } from './useAxios' -import type { Snippet } from '../types/Snippet' -import type { SnippetsExport } from '../types/SnippetsExport' - -export interface SnippetsAPI { - fetchAll: (network?: boolean | null) => Promise - fetch: (snippetId: number, network?: boolean | null) => Promise - create: (snippet: Snippet) => Promise - update: (snippet: Snippet) => Promise - delete: (snippet: Snippet) => Promise - activate: (snippet: Snippet) => Promise - deactivate: (snippet: Snippet) => Promise - export: (snippet: Snippet) => Promise - exportCode: (snippet: Snippet) => Promise -} - -const buildURL = ({ id, network }: Snippet, action?: string) => - addQueryArgs( - [REST_SNIPPETS_BASE, id, action].filter(Boolean).join('/'), - { network: network ? true : undefined } - ) - -export const useSnippetsAPI = (): SnippetsAPI => { - const { get, post, del } = useAxios(REST_API_AXIOS_CONFIG) - - return useMemo((): SnippetsAPI => ({ - fetchAll: network => - get(addQueryArgs(REST_SNIPPETS_BASE, { network })), - - fetch: (snippetId, network) => - get(addQueryArgs(`${REST_SNIPPETS_BASE}/${snippetId}`, { network })), - - create: snippet => - post(REST_SNIPPETS_BASE, snippet), - - update: snippet => - post(buildURL(snippet), snippet), - - delete: (snippet: Snippet) => - del(buildURL(snippet)), - - activate: snippet => - post(buildURL(snippet, 'activate')), - - deactivate: snippet => - post(buildURL(snippet, 'deactivate')), - - export: snippet => - get(buildURL(snippet, 'export')), - - exportCode: snippet => - get(buildURL(snippet, 'export-code')) - }), [get, post, del]) -} - -export const useSnippets = (): Snippet[] | undefined => { - const api = useSnippetsAPI() - const [snippets, setSnippets] = useState() - - useEffect(() => { - if (!snippets) { - api.fetchAll(isNetworkAdmin()) - .then(response => - setSnippets(response.map(snippet => createSnippetObject(snippet)))) - .catch(handleUnknownError) - } - }, [api, snippets]) - - return snippets -} diff --git a/src/js/types/KeyboardShortcut.ts b/src/js/types/KeyboardShortcut.ts index 58e647fb..f94a54c6 100644 --- a/src/js/types/KeyboardShortcut.ts +++ b/src/js/types/KeyboardShortcut.ts @@ -1,31 +1 @@ import { _x } from '@wordpress/i18n' - -export const KEYBOARD_KEYS = { - 'Cmd': _x('Cmd', 'keyboard key', 'code-snippets'), - 'Ctrl': _x('Ctrl', 'keyboard key', 'code-snippets'), - 'Shift': _x('Shift', 'keyboard key', 'code-snippets'), - 'Option': _x('Option', 'keyboard key', 'code-snippets'), - 'Alt': _x('Alt', 'keyboard key', 'code-snippets'), - 'Tab': _x('Tab', 'keyboard key', 'code-snippets'), - 'Up': _x('Up', 'keyboard key', 'code-snippets'), - 'Down': _x('Down', 'keyboard key', 'code-snippets'), - 'A': _x('A', 'keyboard key', 'code-snippets'), - 'D': _x('D', 'keyboard key', 'code-snippets'), - 'F': _x('F', 'keyboard key', 'code-snippets'), - 'G': _x('G', 'keyboard key', 'code-snippets'), - 'R': _x('R', 'keyboard key', 'code-snippets'), - 'S': _x('S', 'keyboard key', 'code-snippets'), - 'Y': _x('Y', 'keyboard key', 'code-snippets'), - 'Z': _x('Z', 'keyboard key', 'code-snippets'), - '/': _x('/', 'keyboard key', 'code-snippets'), - '[': _x(']', 'keyboard key', 'code-snippets'), - ']': _x(']', 'keyboard key', 'code-snippets') -} - -export type KeyboardKey = keyof typeof KEYBOARD_KEYS - -export interface KeyboardShortcut { - label: string - mod: KeyboardKey | KeyboardKey[] - key: KeyboardKey -} diff --git a/src/js/types/Window.ts b/src/js/types/Window.ts index 45adab8c..85ec3dce 100644 --- a/src/js/types/Window.ts +++ b/src/js/types/Window.ts @@ -1,6 +1,6 @@ +import type { ChangelogSchema, ImageLinkSchema } from './schema/WelcomeSchema' import type Prism from 'prismjs' import type tinymce from 'tinymce' -import { ChangelogSchema, ImageLinkSchema } from './schema/WelcomeSchema' import type { Snippet } from './Snippet' import type { CodeEditorInstance, EditorOption, WordPressCodeEditor } from './WordPressCodeEditor' import type { WordPressEditor } from './WordPressEditor' diff --git a/src/js/types/schema/WelcomeSchema.ts b/src/js/types/schema/WelcomeSchema.ts index ed5f9dfd..c9f5cd09 100644 --- a/src/js/types/schema/WelcomeSchema.ts +++ b/src/js/types/schema/WelcomeSchema.ts @@ -1,17 +1,12 @@ -export type ChangelogSchema = { +export interface ChangelogSchema { version: string date: string entries: ChangelogEntriesSchema } -export type ChangelogEntriesSchema = { - [section in ChangelogSectionTitle]?: { - core?: string[] - pro?: string[] - } -} +export type ChangelogEntriesSchema = Partial> -export const CHANGELOG_SECTIONS = ['Added', 'Changed', 'Fixed', 'Deprecated', 'Removed', 'Security', 'Other'] as const +export const CHANGELOG_SECTIONS = ['Added', 'Changed', 'Fixed', 'Deprecated', 'Removed', 'Security', 'Other'] export type ChangelogSectionTitle = typeof CHANGELOG_SECTIONS[number] export interface ImageLinkSchema { diff --git a/src/php/rest-api/class-rest-api.php b/src/php/rest-api/class-rest-api.php index 088697b3..db8e4acc 100644 --- a/src/php/rest-api/class-rest-api.php +++ b/src/php/rest-api/class-rest-api.php @@ -6,6 +6,8 @@ use WP_REST_Request; use WP_REST_Response; use WP_REST_Server; +use function Code_Snippets\Settings\delete_self_option; +use function Code_Snippets\Settings\get_self_option; /** * Class for managing the REST API functionality of the plugin. @@ -116,13 +118,10 @@ public function get_recent_list_callback( WP_REST_Request $request ): WP_REST_Re * @return WP_REST_Response The recently activated snippets list prior to clearing it. */ public function clear_recent_list_callback( WP_REST_Request $request ): WP_REST_Response { - if ( $request->get_param( 'network' ) ) { - $current = get_site_option( 'recently_activated_snippets', [] ); - update_site_option( 'recently_activated_snippets', [] ); - } else { - $current = get_option( 'recently_activated_snippets', [] ); - update_option( 'recently_activated_snippets', [] ); - } + $network = $request->get_param( 'network' ); + + $current = get_self_option( $network, 'recently_activated_snippets', [] ); + delete_self_option( $network, 'recently_activated_snippets' ); return rest_ensure_response( $current ); } diff --git a/src/php/settings/settings.php b/src/php/settings/settings.php index cf94f8b9..7f724edf 100644 --- a/src/php/settings/settings.php +++ b/src/php/settings/settings.php @@ -16,19 +16,6 @@ const OPTION_GROUP = 'code-snippets'; const OPTION_NAME = 'code_snippets_settings'; -/** - * Add a new option for either the current site or the current network - * - * @param bool $network Whether to add a network-wide option. - * @param string $option Name of option to add. Expected to not be SQL-escaped. - * @param mixed $value Option value, can be anything. Expected to not be SQL-escaped. - * - * @return bool False if the option was not added. True if the option was added. - */ -function add_self_option( bool $network, string $option, $value ): bool { - return $network ? add_site_option( $option, $value ) : add_option( $option, $value ); -} - /** * Retrieves an option value based on an option name from either the current site or the current network * @@ -43,7 +30,7 @@ function get_self_option( bool $network, string $option, $default_value = false } /** - * Update the value of an option that was already added on the current site or the current network + * Update the value of an option that was already added on the current site or the current network. * * @param bool $network Whether to update a network-wide option. * @param string $option Name of option. Expected to not be SQL-escaped. @@ -55,6 +42,18 @@ function update_self_option( bool $network, string $option, $value ): bool { return $network ? update_site_option( $option, $value ) : update_option( $option, $value ); } +/** + * Remove an option on th current site or the current network. + * + * @param bool $network Whether to delete a network-wide option. + * @param string $option Name of option. Expected to not be SQL-escaped. + * + * @return bool False if value was not deleted. True if value was deleted. + */ +function delete_self_option( bool $network, string $option ): bool { + return $network ? delete_site_option( $option ) : delete_option( $option ); +} + /** * Returns 'true' if plugin settings are unified on a multisite installation * under the Network Admin settings menu From e43e1128bc9ed8c248a1da0cc336c1503042cbe7 Mon Sep 17 00:00:00 2001 From: Shea Bunge Date: Thu, 31 Jul 2025 20:00:30 +1000 Subject: [PATCH 15/15] WIP --- package-lock.json | 60 + package.json | 2 + phpunit.xml | 13 - phpunit.xml.dist | 19 + playwright.config.ts | 27 + src/composer.json | 4 +- src/composer.lock | 1988 ++++++++++++++++- tests/bootstrap.php | 14 - tests/js/e2e/code-snippets.spec.ts | 49 + tests/php/bootstrap.php | 23 + tests/php/class-test-case.php | 21 + .../class-test-rest-snippets-controller.php | 120 + tests/php/class-test-snippet-ops.php | 97 + tests/{ => php}/install.sh | 43 +- tests/php/wp-tests-config.php | 71 + tests/test-unit-tests.php | 10 - 16 files changed, 2441 insertions(+), 120 deletions(-) delete mode 100644 phpunit.xml create mode 100644 phpunit.xml.dist create mode 100644 playwright.config.ts delete mode 100644 tests/bootstrap.php create mode 100644 tests/js/e2e/code-snippets.spec.ts create mode 100644 tests/php/bootstrap.php create mode 100644 tests/php/class-test-case.php create mode 100644 tests/php/class-test-rest-snippets-controller.php create mode 100644 tests/php/class-test-snippet-ops.php rename tests/{ => php}/install.sh (58%) create mode 100644 tests/php/wp-tests-config.php delete mode 100644 tests/test-unit-tests.php diff --git a/package-lock.json b/package-lock.json index f8ca4bee..43e909b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "GPL-2.0-or-later", "dependencies": { "@codemirror/fold": "^0.19.4", + "@playwright/test": "^1.54.1", "@wordpress/components": "^29.3.0", "@wordpress/dom-ready": "^4.17.0", "@wordpress/i18n": "^5.17.0", @@ -57,6 +58,7 @@ "glob": "^11.0.1", "globals": "^15.14.0", "mini-css-extract-plugin": "^2.9.2", + "playwright": "^1.54.1", "postcss": "^8.5.2", "postcss-color-hsl": "^2.0.0", "postcss-hexrgba": "^2.1.0", @@ -2580,6 +2582,20 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.54.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.54.1.tgz", + "integrity": "sha512-FS8hQ12acieG2dYSksmLOF7BNxnVf2afRJdCuM1eMSxj6QTSE6G4InGF7oApGgDb65MX7AwMVlIkpru0yZA4Xw==", + "dependencies": { + "playwright": "1.54.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "dev": true, @@ -6487,6 +6503,20 @@ } } }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "license": "MIT", @@ -8382,6 +8412,36 @@ "node": ">=8" } }, + "node_modules/playwright": { + "version": "1.54.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.54.1.tgz", + "integrity": "sha512-peWpSwIBmSLi6aW2auvrUtf2DqY16YYcCMO8rTVx486jKmDTJg7UAhyrraP98GB8BoPURZP8+nxO7TSd4cPr5g==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.54.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.54.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.54.1.tgz", + "integrity": "sha512-Nbjs2zjj0htNhzgiy5wu+3w09YetDx5pkrpI/kZotDlDUaYk0HVA5xrBVPdow4SAUIlhgKcJeJg4GRKW6xHusA==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/possible-typed-array-names": { "version": "1.0.0", "dev": true, diff --git a/package.json b/package.json index 6c04dc79..50bfd5c5 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ }, "dependencies": { "@codemirror/fold": "^0.19.4", + "@playwright/test": "^1.54.1", "@wordpress/components": "^29.3.0", "@wordpress/dom-ready": "^4.17.0", "@wordpress/i18n": "^5.17.0", @@ -82,6 +83,7 @@ "glob": "^11.0.1", "globals": "^15.14.0", "mini-css-extract-plugin": "^2.9.2", + "playwright": "^1.54.1", "postcss": "^8.5.2", "postcss-color-hsl": "^2.0.0", "postcss-hexrgba": "^2.1.0", diff --git a/phpunit.xml b/phpunit.xml deleted file mode 100644 index 79b62733..00000000 --- a/phpunit.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - ./tests/ - - - diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 00000000..d170ccd5 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,19 @@ + + + + + ./tests/php + + + + + + + diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..8d322d10 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,27 @@ +// playwright.config.ts +import { defineConfig, devices } from '@playwright/test' + +export default defineConfig({ + testDir: './tests/js/e2e', + timeout: 60 * 1000, + expect: { + timeout: 5000 + }, + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: 'html', + use: { + baseURL: 'http://localhost', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: 'retain-on-failure' + }, + projects: [ + { + name: 'Desktop Chrome', + use: { ...devices['Desktop Chrome'] } + } + ] +}) diff --git a/src/composer.json b/src/composer.json index f08c96b3..5839bbcb 100644 --- a/src/composer.json +++ b/src/composer.json @@ -34,7 +34,9 @@ "require-dev": { "wp-coding-standards/wpcs": "^3.1", "phpcompatibility/phpcompatibility-wp": "^2.1", - "dealerdirect/phpcodesniffer-composer-installer": "^1.0" + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "wp-phpunit/wp-phpunit": "^6.8", + "yoast/phpunit-polyfills": "^4.0" }, "config": { "platform": { diff --git a/src/composer.lock b/src/composer.lock index 588b3f42..2b21a8a0 100644 --- a/src/composer.lock +++ b/src/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "af21aa57ee761a9e969a806ede3a5073", + "content-hash": "93dac6f6f61a84fc1fd56a9b9a1e8b61", "packages": [ { "name": "composer/installers", @@ -250,6 +250,312 @@ ], "time": "2025-07-17T20:45:56+00:00" }, + { + "name": "doctrine/instantiator", + "version": "1.5.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/instantiator.git", + "reference": "0a0fa9780f5d4e507415a065172d26a98d02047b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/instantiator/zipball/0a0fa9780f5d4e507415a065172d26a98d02047b", + "reference": "0a0fa9780f5d4e507415a065172d26a98d02047b", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "doctrine/coding-standard": "^9 || ^11", + "ext-pdo": "*", + "ext-phar": "*", + "phpbench/phpbench": "^0.16 || ^1", + "phpstan/phpstan": "^1.4", + "phpstan/phpstan-phpunit": "^1", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", + "vimeo/psalm": "^4.30 || ^5.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Instantiator\\": "src/Doctrine/Instantiator/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com", + "homepage": "https://ocramius.github.io/" + } + ], + "description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors", + "homepage": "https://www.doctrine-project.org/projects/instantiator.html", + "keywords": [ + "constructor", + "instantiate" + ], + "support": { + "issues": "https://github.com/doctrine/instantiator/issues", + "source": "https://github.com/doctrine/instantiator/tree/1.5.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finstantiator", + "type": "tidelift" + } + ], + "time": "2022-12-30T00:15:36+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.13.3", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "faed855a7b5f4d4637717c2b3863e277116beb36" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/faed855a7b5f4d4637717c2b3863e277116beb36", + "reference": "faed855a7b5f4d4637717c2b3863e277116beb36", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.3" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-07-05T12:25:42+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.6.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "221b0d0fdf1369c71047ad1d18bb5880017bbc56" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/221b0d0fdf1369c71047ad1d18bb5880017bbc56", + "reference": "221b0d0fdf1369c71047ad1d18bb5880017bbc56", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.0-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.6.0" + }, + "time": "2025-07-27T20:03:57+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, { "name": "phpcompatibility/php-compatibility", "version": "9.3.5", @@ -635,115 +941,1558 @@ "time": "2025-06-12T04:32:33+00:00" }, { - "name": "squizlabs/php_codesniffer", - "version": "3.13.2", + "name": "phpunit/php-code-coverage", + "version": "9.2.32", "source": { "type": "git", - "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", - "reference": "5b5e3821314f947dd040c70f7992a64eac89025c" + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "85402a822d1ecf1db1096959413d35e1c37cf1a5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/5b5e3821314f947dd040c70f7992a64eac89025c", - "reference": "5b5e3821314f947dd040c70f7992a64eac89025c", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/85402a822d1ecf1db1096959413d35e1c37cf1a5", + "reference": "85402a822d1ecf1db1096959413d35e1c37cf1a5", "shasum": "" }, "require": { - "ext-simplexml": "*", - "ext-tokenizer": "*", + "ext-dom": "*", + "ext-libxml": "*", "ext-xmlwriter": "*", - "php": ">=5.4.0" + "nikic/php-parser": "^4.19.1 || ^5.1.0", + "php": ">=7.3", + "phpunit/php-file-iterator": "^3.0.6", + "phpunit/php-text-template": "^2.0.4", + "sebastian/code-unit-reverse-lookup": "^2.0.3", + "sebastian/complexity": "^2.0.3", + "sebastian/environment": "^5.1.5", + "sebastian/lines-of-code": "^1.0.4", + "sebastian/version": "^3.0.2", + "theseer/tokenizer": "^1.2.3" }, "require-dev": { - "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.3.4" + "phpunit/phpunit": "^9.6" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" }, - "bin": [ - "bin/phpcbf", - "bin/phpcs" - ], "type": "library", "extra": { "branch-alias": { - "dev-master": "3.x-dev" + "dev-main": "9.2.x-dev" } }, + "autoload": { + "classmap": [ + "src/" + ] + }, "notification-url": "https://packagist.org/downloads/", "license": [ "BSD-3-Clause" ], "authors": [ { - "name": "Greg Sherwood", - "role": "Former lead" - }, - { - "name": "Juliette Reinders Folmer", - "role": "Current lead" - }, - { - "name": "Contributors", - "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer/graphs/contributors" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "description": "PHP_CodeSniffer tokenizes PHP, JavaScript and CSS files and detects violations of a defined set of coding standards.", - "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer", + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", "keywords": [ - "phpcs", - "standards", - "static analysis" + "coverage", + "testing", + "xunit" ], "support": { - "issues": "https://github.com/PHPCSStandards/PHP_CodeSniffer/issues", - "security": "https://github.com/PHPCSStandards/PHP_CodeSniffer/security/policy", - "source": "https://github.com/PHPCSStandards/PHP_CodeSniffer", - "wiki": "https://github.com/PHPCSStandards/PHP_CodeSniffer/wiki" + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.32" }, "funding": [ { - "url": "https://github.com/PHPCSStandards", - "type": "github" - }, - { - "url": "https://github.com/jrfnl", + "url": "https://github.com/sebastianbergmann", "type": "github" - }, - { - "url": "https://opencollective.com/php_codesniffer", - "type": "open_collective" - }, - { - "url": "https://thanks.dev/u/gh/phpcsstandards", - "type": "thanks_dev" } ], - "time": "2025-06-17T22:17:01+00:00" + "time": "2024-08-22T04:23:01+00:00" }, { - "name": "wp-coding-standards/wpcs", - "version": "3.2.0", + "name": "phpunit/php-file-iterator", + "version": "3.0.6", "source": { "type": "git", - "url": "https://github.com/WordPress/WordPress-Coding-Standards.git", - "reference": "d2421de7cec3274ae622c22c744de9a62c7925af" + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/WordPress/WordPress-Coding-Standards/zipball/d2421de7cec3274ae622c22c744de9a62c7925af", - "reference": "d2421de7cec3274ae622c22c744de9a62c7925af", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf", + "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf", "shasum": "" }, "require": { - "ext-filter": "*", - "ext-libxml": "*", - "ext-tokenizer": "*", - "ext-xmlreader": "*", - "php": ">=5.4", - "phpcsstandards/phpcsextra": "^1.4.0", - "phpcsstandards/phpcsutils": "^1.1.0", - "squizlabs/php_codesniffer": "^3.13.0" + "php": ">=7.3" }, "require-dev": { - "php-parallel-lint/php-console-highlighter": "^1.0.0", + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/3.0.6" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2021-12-02T12:48:52+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "3.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/5a10147d0aaf65b58940a0b72f71c9ac0423cc67", + "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^9.3" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/3.1.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-09-28T05:58:55+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28", + "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T05:33:50+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "5.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2", + "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "source": "https://github.com/sebastianbergmann/php-timer/tree/5.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:16:10+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "9.6.23", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "43d2cb18d0675c38bd44982a5d1d88f6d53d8d95" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/43d2cb18d0675c38bd44982a5d1d88f6d53d8d95", + "reference": "43d2cb18d0675c38bd44982a5d1d88f6d53d8d95", + "shasum": "" + }, + "require": { + "doctrine/instantiator": "^1.5.0 || ^2", + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xml": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.1", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=7.3", + "phpunit/php-code-coverage": "^9.2.32", + "phpunit/php-file-iterator": "^3.0.6", + "phpunit/php-invoker": "^3.1.1", + "phpunit/php-text-template": "^2.0.4", + "phpunit/php-timer": "^5.0.3", + "sebastian/cli-parser": "^1.0.2", + "sebastian/code-unit": "^1.0.8", + "sebastian/comparator": "^4.0.8", + "sebastian/diff": "^4.0.6", + "sebastian/environment": "^5.1.5", + "sebastian/exporter": "^4.0.6", + "sebastian/global-state": "^5.0.7", + "sebastian/object-enumerator": "^4.0.4", + "sebastian/resource-operations": "^3.0.4", + "sebastian/type": "^3.2.1", + "sebastian/version": "^3.0.2" + }, + "suggest": { + "ext-soap": "To be able to generate mocks based on WSDL files", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "9.6-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.23" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsors.html", + "type": "custom" + }, + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", + "type": "tidelift" + } + ], + "time": "2025-05-02T06:40:34+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "2b56bea83a09de3ac06bb18b92f068e60cc6f50b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/2b56bea83a09de3ac06bb18b92f068e60cc6f50b", + "reference": "2b56bea83a09de3ac06bb18b92f068e60cc6f50b", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/1.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T06:27:43+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "1.0.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/1fc9f64c0927627ef78ba436c9b17d967e68e120", + "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "source": "https://github.com/sebastianbergmann/code-unit/tree/1.0.8" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:08:54+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "2.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5", + "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/2.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-09-28T05:30:19+00:00" + }, + { + "name": "sebastian/comparator", + "version": "4.0.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "fa0f136dd2334583309d32b62544682ee972b51a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/fa0f136dd2334583309d32b62544682ee972b51a", + "reference": "fa0f136dd2334583309d32b62544682ee972b51a", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "sebastian/diff": "^4.0", + "sebastian/exporter": "^4.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.8" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2022-09-14T12:41:17+00:00" + }, + { + "name": "sebastian/complexity", + "version": "2.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "25f207c40d62b8b7aa32f5ab026c53561964053a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/25f207c40d62b8b7aa32f5ab026c53561964053a", + "reference": "25f207c40d62b8b7aa32f5ab026c53561964053a", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.18 || ^5.0", + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "source": "https://github.com/sebastianbergmann/complexity/tree/2.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-12-22T06:19:30+00:00" + }, + { + "name": "sebastian/diff", + "version": "4.0.6", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "ba01945089c3a293b01ba9badc29ad55b106b0bc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/ba01945089c3a293b01ba9badc29ad55b106b0bc", + "reference": "ba01945089c3a293b01ba9badc29ad55b106b0bc", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3", + "symfony/process": "^4.2 || ^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "source": "https://github.com/sebastianbergmann/diff/tree/4.0.6" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T06:30:58+00:00" + }, + { + "name": "sebastian/environment", + "version": "5.1.5", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "830c43a844f1f8d5b7a1f6d6076b784454d8b7ed" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/830c43a844f1f8d5b7a1f6d6076b784454d8b7ed", + "reference": "830c43a844f1f8d5b7a1f6d6076b784454d8b7ed", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "http://www.github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "source": "https://github.com/sebastianbergmann/environment/tree/5.1.5" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:03:51+00:00" + }, + { + "name": "sebastian/exporter", + "version": "4.0.6", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "78c00df8f170e02473b682df15bfcdacc3d32d72" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/78c00df8f170e02473b682df15bfcdacc3d32d72", + "reference": "78c00df8f170e02473b682df15bfcdacc3d32d72", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "sebastian/recursion-context": "^4.0" + }, + "require-dev": { + "ext-mbstring": "*", + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.6" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T06:33:00+00:00" + }, + { + "name": "sebastian/global-state", + "version": "5.0.7", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "bca7df1f32ee6fe93b4d4a9abbf69e13a4ada2c9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/bca7df1f32ee6fe93b4d4a9abbf69e13a4ada2c9", + "reference": "bca7df1f32ee6fe93b4d4a9abbf69e13a4ada2c9", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "sebastian/object-reflector": "^2.0", + "sebastian/recursion-context": "^4.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^9.3" + }, + "suggest": { + "ext-uopz": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "http://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.7" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T06:35:11+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "1.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "e1e4a170560925c26d424b6a03aed157e7dcc5c5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/e1e4a170560925c26d424b6a03aed157e7dcc5c5", + "reference": "e1e4a170560925c26d424b6a03aed157e7dcc5c5", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.18 || ^5.0", + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/1.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-12-22T06:20:34+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "4.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "5c9eeac41b290a3712d88851518825ad78f45c71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/5c9eeac41b290a3712d88851518825ad78f45c71", + "reference": "5c9eeac41b290a3712d88851518825ad78f45c71", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "sebastian/object-reflector": "^2.0", + "sebastian/recursion-context": "^4.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/4.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:12:34+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/b4f479ebdbf63ac605d183ece17d8d7fe49c15c7", + "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:14:26+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "4.0.5", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "e75bd0f07204fec2a0af9b0f3cfe97d05f92efc1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/e75bd0f07204fec2a0af9b0f3cfe97d05f92efc1", + "reference": "e75bd0f07204fec2a0af9b0f3cfe97d05f92efc1", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/4.0.5" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:07:39+00:00" + }, + { + "name": "sebastian/resource-operations", + "version": "3.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/resource-operations.git", + "reference": "05d5692a7993ecccd56a03e40cd7e5b09b1d404e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/05d5692a7993ecccd56a03e40cd7e5b09b1d404e", + "reference": "05d5692a7993ecccd56a03e40cd7e5b09b1d404e", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides a list of PHP built-in functions that operate on resources", + "homepage": "https://www.github.com/sebastianbergmann/resource-operations", + "support": { + "source": "https://github.com/sebastianbergmann/resource-operations/tree/3.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-14T16:00:52+00:00" + }, + { + "name": "sebastian/type", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7", + "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "source": "https://github.com/sebastianbergmann/type/tree/3.2.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:13:03+00:00" + }, + { + "name": "sebastian/version", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c6c1022351a901512170118436c764e473f6de8c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c6c1022351a901512170118436c764e473f6de8c", + "reference": "c6c1022351a901512170118436c764e473f6de8c", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "source": "https://github.com/sebastianbergmann/version/tree/3.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-09-28T06:39:44+00:00" + }, + { + "name": "squizlabs/php_codesniffer", + "version": "3.13.2", + "source": { + "type": "git", + "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", + "reference": "5b5e3821314f947dd040c70f7992a64eac89025c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/5b5e3821314f947dd040c70f7992a64eac89025c", + "reference": "5b5e3821314f947dd040c70f7992a64eac89025c", + "shasum": "" + }, + "require": { + "ext-simplexml": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": ">=5.4.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.3.4" + }, + "bin": [ + "bin/phpcbf", + "bin/phpcs" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Greg Sherwood", + "role": "Former lead" + }, + { + "name": "Juliette Reinders Folmer", + "role": "Current lead" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer/graphs/contributors" + } + ], + "description": "PHP_CodeSniffer tokenizes PHP, JavaScript and CSS files and detects violations of a defined set of coding standards.", + "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer", + "keywords": [ + "phpcs", + "standards", + "static analysis" + ], + "support": { + "issues": "https://github.com/PHPCSStandards/PHP_CodeSniffer/issues", + "security": "https://github.com/PHPCSStandards/PHP_CodeSniffer/security/policy", + "source": "https://github.com/PHPCSStandards/PHP_CodeSniffer", + "wiki": "https://github.com/PHPCSStandards/PHP_CodeSniffer/wiki" + }, + "funding": [ + { + "url": "https://github.com/PHPCSStandards", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" + } + ], + "time": "2025-06-17T22:17:01+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "1.2.3", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", + "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/1.2.3" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:36:25+00:00" + }, + { + "name": "wp-coding-standards/wpcs", + "version": "3.2.0", + "source": { + "type": "git", + "url": "https://github.com/WordPress/WordPress-Coding-Standards.git", + "reference": "d2421de7cec3274ae622c22c744de9a62c7925af" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/WordPress/WordPress-Coding-Standards/zipball/d2421de7cec3274ae622c22c744de9a62c7925af", + "reference": "d2421de7cec3274ae622c22c744de9a62c7925af", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "ext-libxml": "*", + "ext-tokenizer": "*", + "ext-xmlreader": "*", + "php": ">=5.4", + "phpcsstandards/phpcsextra": "^1.4.0", + "phpcsstandards/phpcsutils": "^1.1.0", + "squizlabs/php_codesniffer": "^3.13.0" + }, + "require-dev": { + "php-parallel-lint/php-console-highlighter": "^1.0.0", "php-parallel-lint/php-parallel-lint": "^1.4.0", "phpcompatibility/php-compatibility": "^9.0", "phpcsstandards/phpcsdevtools": "^1.2.0", @@ -783,6 +2532,117 @@ } ], "time": "2025-07-24T20:08:31+00:00" + }, + { + "name": "wp-phpunit/wp-phpunit", + "version": "6.8.1", + "source": { + "type": "git", + "url": "https://github.com/wp-phpunit/wp-phpunit.git", + "reference": "a33d328dab5a4a9ddf0c560bcadbabb58b5ee67f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/wp-phpunit/wp-phpunit/zipball/a33d328dab5a4a9ddf0c560bcadbabb58b5ee67f", + "reference": "a33d328dab5a4a9ddf0c560bcadbabb58b5ee67f", + "shasum": "" + }, + "type": "library", + "autoload": { + "files": [ + "__loaded.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "GPL-2.0-or-later" + ], + "authors": [ + { + "name": "Evan Mattson", + "email": "me@aaemnnost.tv" + }, + { + "name": "WordPress Community", + "homepage": "https://wordpress.org/about/" + } + ], + "description": "WordPress core PHPUnit library", + "homepage": "https://github.com/wp-phpunit", + "keywords": [ + "phpunit", + "test", + "wordpress" + ], + "support": { + "docs": "https://github.com/wp-phpunit/docs", + "issues": "https://github.com/wp-phpunit/issues", + "source": "https://github.com/wp-phpunit/wp-phpunit" + }, + "time": "2025-04-16T01:40:54+00:00" + }, + { + "name": "yoast/phpunit-polyfills", + "version": "4.0.0", + "source": { + "type": "git", + "url": "https://github.com/Yoast/PHPUnit-Polyfills.git", + "reference": "134921bfca9b02d8f374c48381451da1d98402f9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Yoast/PHPUnit-Polyfills/zipball/134921bfca9b02d8f374c48381451da1d98402f9", + "reference": "134921bfca9b02d8f374c48381451da1d98402f9", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "phpunit/phpunit": "^7.5 || ^8.0 || ^9.0 || ^11.0 || ^12.0" + }, + "require-dev": { + "php-parallel-lint/php-console-highlighter": "^1.0.0", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "yoast/yoastcs": "^3.1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.x-dev" + } + }, + "autoload": { + "files": [ + "phpunitpolyfills-autoload.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Team Yoast", + "email": "support@yoast.com", + "homepage": "https://yoast.com" + }, + { + "name": "Contributors", + "homepage": "https://github.com/Yoast/PHPUnit-Polyfills/graphs/contributors" + } + ], + "description": "Set of polyfills for changed PHPUnit functionality to allow for creating PHPUnit cross-version compatible tests", + "homepage": "https://github.com/Yoast/PHPUnit-Polyfills", + "keywords": [ + "phpunit", + "polyfill", + "testing" + ], + "support": { + "issues": "https://github.com/Yoast/PHPUnit-Polyfills/issues", + "security": "https://github.com/Yoast/PHPUnit-Polyfills/security/policy", + "source": "https://github.com/Yoast/PHPUnit-Polyfills" + }, + "time": "2025-02-09T18:58:54+00:00" } ], "aliases": [], diff --git a/tests/bootstrap.php b/tests/bootstrap.php deleted file mode 100644 index e0f5446e..00000000 --- a/tests/bootstrap.php +++ /dev/null @@ -1,14 +0,0 @@ - { + test('Admin can log in and see Code Snippets menu', async ({ page }) => { + await wpLogin(page) + await expect(page.locator('#adminmenu')).toContainText('Snippets') + }) + + test('Can add a new snippet', async ({ page }) => { + await wpLogin(page) + await page.click('text=Snippets') + await page.click('text=Add New') + await page.fill('#title', 'E2E Test Snippet') + await page.fill('.CodeMirror textarea', 'echo "Hello World!";') + await page.click('text=Save Changes') + await expect(page.locator('.notice-success')).toContainText('Snippet added') + }) + + test('Can activate and deactivate a snippet', async ({ page }) => { + in(page) + await page.click('text=Snippets') + await page.click('text=E2E Test Snippet') + await page.click('text=Activate') + await expect(page.locator('.row-actions span')).toContainText('Deactivate') + await page.click('text=Deactivate') + await expect(page.locator('.row-actions span')).toContainText('Activate') + }) + + test('Can delete a snippet', async ({ page }) => { + await wpLogin(page) + await page.click('text=Snippets') + await page.click('text=E2E Test Snippet') + await page.click('text=Delete') + await page.click('text=OK') // Confirm dialog + await expect(page.locator('body')).not.toContainText('E2E Test Snippet') + }) +}) diff --git a/tests/php/bootstrap.php b/tests/php/bootstrap.php new file mode 100644 index 00000000..c8d534c2 --- /dev/null +++ b/tests/php/bootstrap.php @@ -0,0 +1,23 @@ +assertInstanceOf( Snippet::class, $actual, $message ); + } +} diff --git a/tests/php/class-test-rest-snippets-controller.php b/tests/php/class-test-rest-snippets-controller.php new file mode 100644 index 00000000..31686ad7 --- /dev/null +++ b/tests/php/class-test-rest-snippets-controller.php @@ -0,0 +1,120 @@ +user->create( [ 'role' => 'administrator' ] ); + } + + /** + * Sets up the fixture. + * + * This method is called before each test. + * + * @return void + */ + public function setUp(): void { + parent::setUp(); + wp_set_current_user( self::$admin_id ); + } + + public function test_get_items() { + $request = new WP_REST_Request( 'GET', '/code-snippets/v1/snippets' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertEquals( 200, $response->get_status() ); + } + + public function test_create_and_get_item() { + $data = [ + 'title' => 'REST Test Snippet', + 'code' => ' 'global', + 'tags' => [], + ]; + $request = new WP_REST_Request( 'POST', '/code-snippets/v1/snippets' ); + $request->set_body_params( $data ); + $response = rest_get_server()->dispatch( $request ); + $this->assertEquals( 200, $response->get_status() ); + $item = $response->get_data(); + $this->assertEquals( $data['title'], $item['title'] ); + + // Get the item + $get_request = new WP_REST_Request( 'GET', '/code-snippets/v1/snippets/' . $item['id'] ); + $get_response = rest_get_server()->dispatch( $get_request ); + $this->assertEquals( 200, $get_response->get_status() ); + $this->assertEquals( $data['title'], $get_response->get_data()['title'] ); + } + + public function test_update_item() { + // Create first + $data = [ + 'title' => 'To Update', + 'code' => ' 'global', + 'tags' => [], + ]; + $request = new WP_REST_Request( 'POST', '/code-snippets/v1/snippets' ); + $request->set_body_params( $data ); + $response = rest_get_server()->dispatch( $request ); + $item = $response->get_data(); + + // Update + $update = new WP_REST_Request( 'PUT', '/code-snippets/v1/snippets/' . $item['id'] ); + $update->set_body_params( [ 'title' => 'Updated Title' ] ); + $update_response = rest_get_server()->dispatch( $update ); + $this->assertEquals( 200, $update_response->get_status() ); + $this->assertEquals( 'Updated Title', $update_response->get_data()['title'] ); + } + + public function test_delete_item() { + // Create first + $data = [ + 'title' => 'To Delete', + 'code' => ' 'global', + 'tags' => [], + ]; + $request = new WP_REST_Request( 'POST', '/code-snippets/v1/snippets' ); + $request->set_body_params( $data ); + $response = rest_get_server()->dispatch( $request ); + $item = $response->get_data(); + + // Delete + $delete = new WP_REST_Request( 'DELETE', '/code-snippets/v1/snippets/' . $item['id'] ); + $delete_response = rest_get_server()->dispatch( $delete ); + $this->assertEquals( 204, $delete_response->get_status() ); + } + + public function test_activate_and_deactivate_item() { + // Create first + $data = [ + 'title' => 'To Activate', + 'code' => ' 'global', + 'tags' => [], + ]; + $request = new WP_REST_Request( 'POST', '/code-snippets/v1/snippets' ); + $request->set_body_params( $data ); + $response = rest_get_server()->dispatch( $request ); + $item = $response->get_data(); + + // Activate + $activate = new WP_REST_Request( 'POST', '/code-snippets/v1/snippets/' . $item['id'] . '/activate' ); + $activate_response = rest_get_server()->dispatch( $activate ); + $this->assertEquals( 200, $activate_response->get_status() ); + + // Deactivate + $deactivate = new WP_REST_Request( 'POST', '/code-snippets/v1/snippets/' . $item['id'] . '/deactivate' ); + $deactivate_response = rest_get_server()->dispatch( $deactivate ); + $this->assertEquals( 200, $deactivate_response->get_status() ); + } +} diff --git a/tests/php/class-test-snippet-ops.php b/tests/php/class-test-snippet-ops.php new file mode 100644 index 00000000..6ce46c9c --- /dev/null +++ b/tests/php/class-test-snippet-ops.php @@ -0,0 +1,97 @@ + 'Test Snippet', + 'desc' => 'A test snippet', + 'code' => ' [ 'test' ], + 'scope' => 'global', + 'active' => 0, + ]; + + $snippet = save_snippet( $data ); + $this->assertInstanceOf( Snippet::class, $snippet ); + $this->assertEquals( 'Test Snippet', $snippet->name ); + + $fetched = get_snippet( $snippet->id ); + $this->assertSnippet( $fetched ); + $this->assertEquals( $snippet->id, $fetched->id ); + } + + /** + * Test retrieving a list of snippets. + * + * @return void + */ + public function test_get_snippets_returns_array() { + $snippets = get_snippets(); + $this->assertIsArray( $snippets ); + if ( $snippets ) { + $this->assertSnippet( $snippets[0] ); + } + } + + /** + * Test activating and deactivating a snippet. + * + * @return void + */ + public function test_activate_and_deactivate_snippet() { + $data = [ + 'name' => 'Active Snippet', + 'desc' => 'To activate', + 'code' => ' [ 'active' ], + 'scope' => 'global', + 'active' => 0, + 'type' => 'php', + ]; + $snippet = save_snippet( $data ); + $activated = activate_snippet( $snippet->id ); + + $this->assertInstanceOf( Snippet::class, $activated ); + $this->assertEquals( 1, $activated->active ); + + $deactivated = deactivate_snippet( $snippet->id ); + $this->assertInstanceOf( Snippet::class, $deactivated ); + $this->assertEquals( 0, $deactivated->active ); + } + + /** + * Test deleting a snippet. + * + * @return void + */ + public function test_delete_snippet() { + $data = new Snippet(); + $snippet = save_snippet( $data ); + + $deleted = delete_snippet( $snippet->id ); + $this->assertTrue( $deleted ); + + $fetched = get_snippet( $snippet->id ); + $this->assertEquals( 0, $fetched->id ); + } +} diff --git a/tests/install.sh b/tests/php/install.sh similarity index 58% rename from tests/install.sh rename to tests/php/install.sh index d07b691f..b46487dc 100644 --- a/tests/install.sh +++ b/tests/php/install.sh @@ -12,25 +12,28 @@ DB_HOST=${5-localhost} export WP_DEVELOP_DIR=${WP_DEVELOP_DIR-/tmp/wordpress/} PROJECT_DIR=$(pwd) -plugin_slug=$(basename ${PROJECT_DIR}) +plugin_slug=$(basename "${PROJECT_DIR}") set -ex install_wp() { - rm -rf ${WP_DEVELOP_DIR} - mkdir -p ${WP_DEVELOP_DIR} + rm -rf "${WP_DEVELOP_DIR}" + mkdir -p "${WP_DEVELOP_DIR}" - git clone --depth=1 --quiet git://develop.git.wordpress.org/ ${WP_DEVELOP_DIR}/ - cd ${WP_DEVELOP_DIR} + git clone --depth=1 --quiet git://develop.git.wordpress.org/ "${WP_DEVELOP_DIR}/" + cd "${WP_DEVELOP_DIR}" - if [[ ${WP_VERSION} == 'latest' ]] || [[ ${WP_VERSION} == 'develop' ]]; then + if [[ ${WP_VERSION} == 'latest' ]] || [[ ${WP_VERSION} == 'develop' ]] + then export WP_VERSION='master' - elif [[ ${WP_VERSION} == 'stable' ]]; then + elif [[ ${WP_VERSION} == 'stable' ]] + then git fetch --tags --depth=1 --quiet - export WP_VERSION=$(git tag | sort -n | tail -1) + WP_VERSION=$(git tag | sort -n | tail -1) + export WP_VERSION fi - git checkout ${WP_VERSION} --quiet + git checkout "${WP_VERSION}" --quiet } create_db() { @@ -40,12 +43,16 @@ create_db() { local DB_SOCK_OR_PORT=${PARTS[1]}; local EXTRA="" - if ! [[ -z ${DB_HOSTNAME} ]] ; then - if [[ $(echo ${DB_SOCK_OR_PORT} | grep -e '^[0-9]\{1,\}$') ]]; then + if [[ -n ${DB_HOSTNAME} ]] + then + if echo "${DB_SOCK_OR_PORT}" | grep -q '^[0-9]\{1,\}$' + then EXTRA=" --host=$DB_HOSTNAME --port=$DB_SOCK_OR_PORT --protocol=tcp" - elif ! [[ -z ${DB_SOCK_OR_PORT} ]] ; then + elif [[ -n ${DB_SOCK_OR_PORT} ]] + then EXTRA=" --socket=$DB_SOCK_OR_PORT" - elif ! [[ -z ${DB_HOSTNAME} ]] ; then + elif [[ -n ${DB_HOSTNAME} ]] + then EXTRA=" --host=$DB_HOSTNAME --protocol=tcp" fi fi @@ -64,16 +71,16 @@ config_test_suite() { cp wp-tests-config-sample.php wp-tests-config.php - sed ${opts} "s/youremptytestdbnamehere/$DB_NAME/" wp-tests-config.php - sed ${opts} "s/yourusernamehere/$DB_USER/" wp-tests-config.php - sed ${opts} "s/yourpasswordhere/$DB_PASS/" wp-tests-config.php - sed ${opts} "s|localhost|${DB_HOST}|" wp-tests-config.php + sed "${opts}" "s/youremptytestdbnamehere/$DB_NAME/" wp-tests-config.php + sed "${opts}" "s/yourusernamehere/$DB_USER/" wp-tests-config.php + sed "${opts}" "s/yourpasswordhere/$DB_PASS/" wp-tests-config.php + sed "${opts}" "s|localhost|${DB_HOST}|" wp-tests-config.php } install_plugin () { if [[ -d ${PROJECT_DIR}/dist ]] then - mv ${PROJECT_DIR}/dist ${WP_DEVELOP_DIR}/src/wp-content/plugins/${plugin_slug} + mv "${PROJECT_DIR}/dist" "${WP_DEVELOP_DIR}/src/wp-content/plugins/${plugin_slug}" fi } diff --git a/tests/php/wp-tests-config.php b/tests/php/wp-tests-config.php new file mode 100644 index 00000000..138ea60f --- /dev/null +++ b/tests/php/wp-tests-config.php @@ -0,0 +1,71 @@ +assertTrue( true ); // The unit tests are running - } -}