diff --git a/config/modules/codemirror-keymaps.d.ts b/config/modules/codemirror-keymaps.d.ts new file mode 100644 index 00000000..9f5e587c --- /dev/null +++ b/config/modules/codemirror-keymaps.d.ts @@ -0,0 +1,5 @@ +declare module 'codemirror/src/input/keymap' { + import type { KeyMap } from 'codemirror' + + export const getKeyMap: (keyMap: string) => KeyMap +} diff --git a/config/webpack-js.ts b/config/webpack-js.ts index b8a0d1fb..d48bd167 100644 --- a/config/webpack-js.ts +++ b/config/webpack-js.ts @@ -6,7 +6,7 @@ import { toCamelCase } from '../src/js/utils/text' import { dependencies } from '../package.json' import type { Configuration } from 'webpack' -const SOURCE_DIR = './src/js' +const SOURCE_DIR = './src/js/entries' const DEST_DIR = './src/dist' const babelConfig = { @@ -24,12 +24,14 @@ 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' } + settings: { import: `${SOURCE_DIR}/settings.ts`, dependOn: 'editor' }, + welcome: `${SOURCE_DIR}/welcome.ts`, }, output: { path: join(resolve(__dirname), '..', DEST_DIR), diff --git a/package-lock.json b/package-lock.json index 0ff4790b..43e909b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,16 @@ { "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", + "@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 b13552b4..50bfd5c5 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" @@ -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/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.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 b256075a..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", @@ -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,313 @@ "type": "thanks_dev" } ], - "time": "2025-06-27T17:24:01+00:00" + "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", @@ -635,123 +941,1566 @@ "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.1.0", + "name": "phpunit/php-file-iterator", + "version": "3.0.6", "source": { "type": "git", - "url": "https://github.com/WordPress/WordPress-Coding-Standards.git", - "reference": "9333efcbff231f10dfd9c56bb7b65818b4733ca7" + "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/9333efcbff231f10dfd9c56bb7b65818b4733ca7", - "reference": "9333efcbff231f10dfd9c56bb7b65818b4733ca7", + "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.2.1", - "phpcsstandards/phpcsutils": "^1.0.10", - "squizlabs/php_codesniffer": "^3.9.0" + "php": ">=7.3" }, "require-dev": { - "php-parallel-lint/php-console-highlighter": "^1.0.0", - "php-parallel-lint/php-parallel-lint": "^1.3.2", - "phpcompatibility/php-compatibility": "^9.0", - "phpcsstandards/phpcsdevtools": "^1.2.0", - "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.0" - }, - "suggest": { - "ext-iconv": "For improved results", - "ext-mbstring": "For improved results" + "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", + "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.0" + }, + "suggest": { + "ext-iconv": "For improved results", + "ext-mbstring": "For improved results" }, "type": "phpcodesniffer-standard", "notification-url": "https://packagist.org/downloads/", @@ -782,7 +2531,118 @@ "type": "custom" } ], - "time": "2024-03-25T16:39:00+00:00" + "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/src/css/common/_toolbar.scss b/src/css/common/_toolbar.scss new file mode 100644 index 00000000..d44805d8 --- /dev/null +++ b/src/css/common/_toolbar.scss @@ -0,0 +1,108 @@ +@use 'upsell'; + +$toolbar-block-size: 150px; +$wpbody-block-indent: 20px; + +#wpbody { + padding-block-start: $toolbar-block-size; +} + +.code-snippets-toolbar { + color: #2c3337; + background: #fff; + font-family: 'SF Pro', sans-serif; + margin-inline-start: -$wpbody-block-indent; + position: absolute; + inline-size: calc(100% + $wpbody-block-indent); + block-size: $toolbar-block-size; + inset-block-start: 0; + display: flex; + flex-direction: column; + + ul, li { + margin: 0; + padding: 0; + list-style: none; + } +} + +.code-snippets-toolbar-upper { + justify-content: space-between; + block-size: 77px; + padding-inline: 35px; + display: flex; + align-items: center; + + .logo { + display: flex; + align-items: center; + gap: 8px; + + img { + block-size: 42px; + } + } + + h1 { + font-size: 18px; + font-weight: 700; + line-height: 1.5; + } + + nav ul { + display: flex; + gap: 22px; + align-items: center; + } + + nav a { + color: inherit; + text-decoration: none; + + &.active-link, &:hover, &:focus, &:active { + color: #2271b1; + } + } +} + +.code-snippets-toolbar-lower { + border: 1px solid #c3c4c7; + + ul { + display: flex; + align-items: end; + } + + li a { + display: flex; + margin: 0; + padding: 24px 24px 20px; + color: inherit; + gap: 12px; + align-items: center; + font-size: 16px; + text-decoration: none; + font-weight: bold; + box-sizing: border-box; + transition: border unset; + border-block-end: 4px solid transparent; + + &.active-link, &:hover, &:focus, &:active { + color: #2271b1; + border-block-end-color: currentcolor; + } + } + + .pro-chip { + color: #d46f4d; + text-transform: uppercase; + border: 2px solid currentcolor; + border-radius: 999px; + font-size: 12px; + display: flex; + justify-content: center; + align-items: center; + box-sizing: border-box; + padding: 3px 9px; + } +} diff --git a/src/css/edit.scss b/src/css/edit.scss index 241aedb2..f531e8ee 100644 --- a/src/css/edit.scss +++ b/src/css/edit.scss @@ -8,6 +8,7 @@ @use 'common/select'; @use 'common/tooltips'; @use 'common/upsell'; +@use 'common/toolbar'; @use 'edit/form'; @use 'edit/sidebar'; @use 'edit/editor'; diff --git a/src/css/edit/_editor.scss b/src/css/edit/_editor.scss index 257f1628..79373aa7 100644 --- a/src/css/edit/_editor.scss +++ b/src/css/edit/_editor.scss @@ -59,9 +59,9 @@ } .snippet-editor-help { - position: absolute; inset-inline-end: 5px; inset-block-start: 5px; + position: absolute; td { &:first-child { @@ -72,18 +72,27 @@ white-space: nowrap; } } +} + +.mac-keyboard-shortcut { + text-align: end; - .mac-key { - display: none; + kbd { + font-family: inherit; + padding: 0; + margin: 0; } +} - .platform-mac { - .mac-key { - display: inline; - } +.pc-keyboard-shortcut { + display: inline-flex; + align-items: center; + gap: 3px; - .pc-key { - display: none; - } + kbd { + font-family: inherit; + margin: 0; + padding-block: 0; + padding-inline: 2px; } } diff --git a/src/css/manage/_cloud.scss b/src/css/manage-legacy.scss similarity index 60% rename from src/css/manage/_cloud.scss rename to src/css/manage-legacy.scss index 024903e3..ab5f4ed2 100644 --- a/src/css/manage/_cloud.scss +++ b/src/css/manage-legacy.scss @@ -1,5 +1,218 @@ -@use '../common/theme'; -@use '../common/tooltips'; +/** + * Custom styling for the snippets table + */ + +@use 'sass:map'; +@use 'sass:color'; +@use 'common/theme'; +@use 'common/badges'; +@use 'common/switch'; +@use 'common/select'; + +.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; + } + } +} + +/** Cloud */ + .cloud-legend-tooltip { h3 { diff --git a/src/css/manage.scss b/src/css/manage.scss index 77cc17ce..acafcccd 100644 --- a/src/css/manage.scss +++ b/src/css/manage.scss @@ -1,17 +1,30 @@ -/** - * 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'; +@use 'common/upsell'; +@use 'common/toolbar'; + +.nav-tab { + display: flex; + flex-flow: row wrap; + align-items: center; + gap: 8px; -.column-name, -.column-type { + @media screen and (width <= 1190px) { + span:first-child:not(:last-child) { + display: none; + } + } +} + +.nav-tab-wrapper + .subsubsub, p.search-box { + margin-block: 10px 0; + margin-inline: 0; +} + +.name-column, +.type-column { .dashicons { font-size: 16px; inline-size: 16px; @@ -24,7 +37,7 @@ } } -.active-snippet .column-name > a { +.active-snippet .name-column > a { font-weight: 600; } @@ -38,7 +51,7 @@ } } -.column-priority input { +.priority-column input { appearance: none; background: none; border: none; @@ -49,9 +62,9 @@ &:hover, &:focus, &:active { color: #000; - background-color: #f5f5f5; background-color: rgb(0 0 0 / 10%); border-radius: 6px; + appearance: unset; } &:disabled { @@ -59,11 +72,22 @@ } } -.clear-filters { - vertical-align: baseline !important; +.snippets-table-heading { + display: flex; + align-items: center; + margin-block: 50px 1.4rem; + + h1 { + font-size: 1.6rem; + padding: 0; + } + + .button-primary { + margin-inline-start: auto; + } } -.snippets { +.wp-list-table { td.column-id { text-align: center; } @@ -90,6 +114,15 @@ color: #ddd; position: relative; inset-inline-start: 0; + + .button-link { + min-block-size: unset; + line-height: unset; + + &:hover { + background: none; + } + } } .column-activate { @@ -126,8 +159,10 @@ 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 { + .delete { + color: #b32d2e; + + &:hover, &:focus, &:active { border-block-end: 1px solid #f00; color: #f00; } @@ -135,85 +170,14 @@ #wpbody-content & .column-name { white-space: nowrap; /* prevents wrapping of snippet title */ + vertical-align: top; } } -td.column-description { - max-inline-size: 700px; - - pre { - white-space: unset; - } +.wp-core-ui .button.clear-filters { + vertical-align: baseline; } .inactive-snippet { @include theme.link-colors(#579); } - -@media screen and (width <= 782px) { - p.search-box { - float: inline-start; - position: initial; - margin-block: 1em 0; - margin-inline: 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-block: 10px 0; - margin-inline: 0; -} - -.snippet-type-description { - border-block-end: 1px solid #ccc; - margin: 0; - padding-block: 1em; - padding-inline: 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/settings.scss b/src/css/settings.scss index ffde5fe8..e4227482 100644 --- a/src/css/settings.scss +++ b/src/css/settings.scss @@ -1,4 +1,5 @@ @use 'common/codemirror'; +@use 'common/toolbar'; $sections: general, editor, debug; @@ -16,7 +17,7 @@ p.submit { margin-block-end: 1em; } -input[type="number"] { +input[type='number'] { inline-size: 4em; } @@ -84,7 +85,7 @@ body.js { color: #dc3232; } -.wrap[data-active-tab="license"] .submit { +.wrap[data-active-tab='license'] .submit { display: none; } diff --git a/src/css/welcome.scss b/src/css/welcome.scss index cfceb7db..8a67c926 100644 --- a/src/css/welcome.scss +++ b/src/css/welcome.scss @@ -1,369 +1,229 @@ @use 'sass:color'; @use 'common/theme'; @use 'common/badges'; +@use 'common/toolbar'; $breakpoint: 1060px; -.csp-welcome-wrap { +.code-snippets-welcome { padding: 25px; - h1, h2, h3 { - font-weight: 700; - margin-block: 10px; - - .dashicons { - font-size: 90%; - line-height: inherit; - inline-size: auto; - } - } - h1 { - font-size: 1.6rem; + font-size: 32px; + margin-block: 48px 32px; } +} - h2 { - font-size: 1.4rem; - } +.code-snippets-updates { + display: grid; + grid-template-columns: 2fr 1fr; + gap: 20px; - .dashicons-external { - float: inline-end; - color: #666; + > * { + background: #fff; + border-radius: 8px; + padding: 32px; } -} - -.csp-welcome-header { - display: flex; - flex-flow: row wrap; - justify-content: space-between; - align-items: center; header { display: flex; - flex-direction: row; + flex-flow: row; + justify-content: space-between; align-items: center; - gap: 10px; - - h1 { - font-size: 1.4rem; - font-weight: bold; - line-height: 1; - margin: 0; - - span { - text-decoration: underline theme.$primary wavy 3px; - text-decoration-skip-ink: none; - text-underline-offset: 11px; - text-transform: capitalize; - } - } + border-block-end: 1px solid #e2e2e4; + margin-block-end: 20px; + padding-block-end: 20px; } -} - -.csp-welcome-header nav { - column-gap: 15px; - ul { - display: flex; - flex-flow: row wrap; - justify-content: space-evenly; + h2 { + font-size: 18px; margin: 0; } +} - li { - margin-block-end: 0; - } - - li a { - margin-block: 10px; - align-items: center; - border-width: 1px; - border-style: solid; - color: white; - cursor: pointer; - display: flex; - font-weight: 400; - gap: 3px; - text-decoration: none; - transition: all .1s ease-in-out; - border-radius: 3px; - padding: 8px; - - &:hover { - background: transparent; - } - - .dashicons, svg { - text-decoration: none; - margin-block-start: -1px; - margin-inline-start: 3px; - } +.code-snippets-hero { + display: flex; + flex-flow: column; - svg { - fill: #fff; - inline-size: 20px; - block-size: 20px; - font-size: 20px; - vertical-align: top; - } + figure { + margin-block: 1em 0; + margin-inline: 0; + overflow: hidden; + border-radius: 0.5rem; + position: relative; + block-size: auto; + background: #efefef; + flex: 1; + text-align: center; - &:hover svg { - fill: currentcolor; + img { + inline-size: 100%; + block-size: 100%; + overflow: hidden; + object-fit: cover; } } +} - $link-colors: ( - pro: theme.$secondary, - cloud: #08c5d1, - resources: #424242, - discord: theme.$brand-discord, - facebook: theme.$brand-facebook - ); - - @each $link-name, $color in $link-colors { - .csp-link-#{$link-name} { - background: $color; - border-color: $color; +.code-snippets-partners, +.code-snippets-articles { + display: flex; + flex-direction: row;; + gap: 16px; - &:hover { - color: $color; - } - } + figure { + margin: 0; + padding: 0; } -} - -.csp-cards { - display: grid; - grid-auto-rows: 1fr; - grid-template-columns: repeat(4, 1fr); - gap: 40px 15px; - @media (max-width: $breakpoint) { - grid-template-columns: 1fr !important; + img { + inline-size: 100%; + block-size: 220px; + overflow: hidden; + object-fit: cover; } } -.csp-card { - border: 1px solid theme.$outline; - background: white; - border-radius: 10px; +.code-snippets-card { + flex: 1; + background: #fff; + border-radius: 8px; display: flex; flex-flow: column; -} - -a.csp-card { - text-decoration: none; - - &:hover { - background: color.adjust(theme.$primary, $lightness: 55%); - transition: .5s background-color; - box-shadow: 0 1px 1px rgb(255 255 255 / 50%); + color: #2c3337; - .dashicons-external { - color: #000; - } + figure img { + border-start-start-radius: 8px; + border-start-end-radius: 8px; } } -.csp-section-changes { - border: 1px solid theme.$outline; - border-inline-start: 0; - border-inline-end: 0; - padding-block: 40px 50px; - padding-inline: 0; - display: flex; - flex-direction: column; - row-gap: 20px; - margin-block-start: 30px; - - .csp-cards { - grid-template-columns: 2fr 1fr; - gap: 20px; +.code-snippets-partners { + header { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + padding: 24px; } +} - .csp-card { - padding: 20px; - box-shadow: 0 1px 1px rgb(0 0 0 / 5%); - - h2 { - color: theme.$primary; - } +.code-snippets-articles { + header { + display: flex; + flex-direction: column; + align-items: flex-start; + padding: 24px; + gap: 14px; + block-size: 100%; } - .csp-changelog-wrapper { - overflow-y: scroll; + .button { + margin-block-start: auto; } - .csp-section-changelog { - font-size: 0.9rem; - line-height: 1.5; - color: #333; - block-size: 400px; - - h3 { - float: inline-end; - color: #666; - } - - h4 { - margin-block: 30px 10px; - margin-inline: 0; - } - - ul { - margin-block-start: 5px; - } + h2 { + font-size: 18px; + margin: 0; + } - li { - display: grid; - grid-template-columns: 40px 1fr; - grid-template-rows: 1fr; - align-items: baseline; - gap: 7px; - } + p { + margin: 0; + } - li .badge { - text-align: center; - } + .item-category { + color: white; + background: theme.$secondary; + display: block; + font-size: 12px; + letter-spacing: 1px; + margin-block: 0; + text-transform: uppercase; + inline-size: fit-content; + padding: 3px 10px; + border-radius: 3px; + font-weight: bold; + } +} - > article::after { - border-block-end: 1px solid #666; - content: ' '; - display: block; - inline-size: 50%; - margin-block: 3em 0; - margin-inline: auto; - } +.code-snippets-changelog-entries { + font-size: 0.9rem; + line-height: 1.5; + overflow-y: scroll; + max-block-size: 500px; + color: #2c3337; + padding-inline-end: 1em; - > article:last-child { - padding-block-end: 1px; + header { + border: 0; + margin: 0; + padding: 0; - &::after { - border: 0; - } + p { + font-size: 14px; } } - figure { - margin-block: 1em 0; - margin-inline: 0; - overflow: hidden; - border-radius: 0.5rem; - border: 1px solid grey; - position: relative; - block-size: auto; - background: #646970; - - img { - inline-size: 100%; - block-size: 100%; - overflow: hidden; - object-fit: cover; - } + h3 { + font-size: 16px; } - .dashicons-lightbulb { - color: #f1c40f; + h4 { + font-size: 12px; + text-transform: uppercase; + margin: 0 0 16px; + display: flex; + gap: 5px; + align-items: center; } - .dashicons-chart-line { - color: #85144b; + ul { + margin-block-start: 5px; } - .dashicons-buddicons-replies { - color: #3d9970; + li { + display: grid; + grid-template-columns: 40px 1fr; + grid-template-rows: 1fr; + align-items: baseline; + gap: 10px; } -} - -.csp-section-links { - padding-block: 40px 50px; - padding-inline: 0; - .csp-card { - margin-block-start: 20px; - justify-content: flex-start; - color: black; - position: relative; - overflow: hidden; - row-gap: 10px; - padding: 1rem; - inline-size: 85%; - - header { - flex: 1; - } - - figure { - margin-block: 1em 0; - margin-inline: 0; - - img { - border-radius: 5px; - inline-size: 100%; - block-size: 100%; - max-block-size: 300px; - overflow: hidden; - object-fit: cover; - } - } - - .csp-card-item-category { - color: white; - background: theme.$secondary; - display: block; - font-size: .9rem; - letter-spacing: 1px; - margin-block: 0; - text-transform: uppercase; - inline-size: fit-content; - padding-block: 5px; - padding-inline: 15px; - border-radius: 50px; - } - - h3 { - font-size: 1.7rem; - color: theme.$primary; - line-height: normal; - } - - .csp-card-item-description { - color: #51525c; - font-size: 1rem; - font-weight: 300; - } - - footer { - display: flex; - flex-direction: row; - justify-content: space-between; - align-items: center; - } + > article::after { + border-block-end: 1px solid #666; + content: ' '; + display: block; + inline-size: 50%; + margin-block: 3em; + margin-inline: auto; } - &.csp-section-partners { - border-block-start: 1px solid theme.$outline; + > article:last-child { + padding-block-end: 1px; - header { - display: flex; - flex-direction: row-reverse; - justify-content: space-between; - align-items: center; + &::after { + border: 0; } } - &.csp-section-articles { - h2 { - font-size: 1.1rem; - } + $icon-colors: ( + lightbulb #f1c40f, + chart-line #85144b, + buddicons-replies #3d9970, + remove #ffbf00, + trash #c0c0c0, + shield #0074d9, + open-folder #5d2f27 + ); - figure img { - aspect-ratio: 1; + @each $icon, $color in $icon-colors { + .dashicons-#{$icon} { + color: $color; } } } -.csp-loading-spinner { +.code-snippets-loading-spinner { block-size: 0; inline-size: 0; padding: 15px; diff --git a/src/js/components/EditorSidebar/EditorSidebar.tsx b/src/js/components/EditorSidebar/EditorSidebar.tsx index a29cee20..5d26b6ad 100644 --- a/src/js/components/EditorSidebar/EditorSidebar.tsx +++ b/src/js/components/EditorSidebar/EditorSidebar.tsx @@ -1,22 +1,23 @@ import React from 'react' import { Spinner } from '@wordpress/components' -import { isRTL } from '@wordpress/i18n' +import { __, isRTL } from '@wordpress/i18n' +import { addQueryArgs } from '@wordpress/url' import { useSnippetForm } from '../../hooks/useSnippetForm' import { isNetworkAdmin } from '../../utils/screen' import { getSnippetType, isCondition } from '../../utils/snippets/snippets' +import { DeleteButton } from '../common/DeleteButton' import { Notices } from '../SnippetForm/page/Notices' import { ShortcodeInfo } from './actions/ShortcodeInfo' import { MultisiteSharingSettings } from './controls/MultisiteSharingSettings' import { ExportButtons } from './actions/ExportButtons' import { SubmitButtons } from './actions/SubmitButtons' import { ActivationSwitch } from './controls/ActivationSwitch' -import { DeleteButton } from './actions/DeleteButton' import { PriorityInput } from './controls/PriorityInput' import { RTLControl } from './controls/RTLControl' import { TagsInput } from './controls/TagsInput' export const EditorSidebar = () => { - const { snippet, isWorking } = useSnippetForm() + const { snippet, isWorking, setIsWorking, handleRequestError } = useSnippetForm() return (
@@ -34,7 +35,15 @@ export const EditorSidebar = () => { {snippet.id ?
- + + window.location.replace(addQueryArgs(window.CODE_SNIPPETS?.urls.manage, { result: 'deleted' }))} + onError={error => + handleRequestError(error, __('Could not delete snippet.', 'code-snippets'))} + />
: null}
diff --git a/src/js/components/EditorSidebar/actions/ExportButtons.tsx b/src/js/components/EditorSidebar/actions/ExportButtons.tsx index d4b08d81..eb2cfa39 100644 --- a/src/js/components/EditorSidebar/actions/ExportButtons.tsx +++ b/src/js/components/EditorSidebar/actions/ExportButtons.tsx @@ -1,8 +1,8 @@ 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 { Snippet } from '../../../types/Snippet' import type { SnippetsExport } from '../../../types/schema/SnippetsExport' diff --git a/src/js/components/SnippetForm/SnippetForm.tsx b/src/js/components/SnippetForm/SnippetForm.tsx index 0094e93b..c9462e03 100644 --- a/src/js/components/SnippetForm/SnippetForm.tsx +++ b/src/js/components/SnippetForm/SnippetForm.tsx @@ -9,6 +9,7 @@ import { handleUnknownError } from '../../utils/errors' import { createSnippetObject, getSnippetType, isCondition, validateSnippet } from '../../utils/snippets/snippets' import { WithSnippetFormContext, useSnippetForm } from '../../hooks/useSnippetForm' import { ConfirmDialog } from '../common/ConfirmDialog' +import { Toolbar } from '../common/Toolbar' import { UpsellDialog } from '../common/UpsellDialog' import { ConditionModalButton } from '../ConditionModal/ConditionModalButton' import { EditorSidebar } from '../EditorSidebar' @@ -90,8 +91,8 @@ const EditForm: React.FC = ({ children, className }) => { if (response && 0 !== response.id && window.CODE_SNIPPETS) { if (window.location.href.toString().includes(window.CODE_SNIPPETS.urls.addNew)) { document.title = document.title - .replace(__('Add New Snippet', 'code-snippets'), __('Edit Snippet', 'code-snippets')) - .replace(__('Add New Condition', 'code-snippets'), __('Edit Condition', 'code-snippets')) + .replace(__('Create New Snippet', 'code-snippets'), __('Edit Snippet', 'code-snippets')) + .replace(__('Create New Condition', 'code-snippets'), __('Edit Condition', 'code-snippets')) const newUrl = addQueryArgs(window.CODE_SNIPPETS.urls.edit, { id: response.id }) window.history.pushState({}, document.title, newUrl) @@ -191,6 +192,7 @@ export const SnippetForm: React.FC = () => createSnippetObject(window.CODE_SNIPPETS_EDIT?.snippet)}> + diff --git a/src/js/components/SnippetForm/fields/CodeEditorShortcuts.tsx b/src/js/components/SnippetForm/fields/CodeEditorShortcuts.tsx index 9b04cb60..6ee4fb09 100644 --- a/src/js/components/SnippetForm/fields/CodeEditorShortcuts.tsx +++ b/src/js/components/SnippetForm/fields/CodeEditorShortcuts.tsx @@ -1,123 +1,199 @@ import { __, _x } from '@wordpress/i18n' -import classnames from 'classnames' -import React from 'react' -import { KEYBOARD_KEYS } from '../../../types/KeyboardShortcut' +import React, { Fragment, useMemo } from 'react' +import { getKeyMap } from 'codemirror/src/input/keymap' +import { useSnippetForm } from '../../../hooks/useSnippetForm' import { isMacOS } from '../../../utils/screen' -import type { KeyboardKey, KeyboardShortcut } from '../../../types/KeyboardShortcut' +import type { KeyMap } from 'codemirror' -const shortcuts: Record = { - saveChanges: { - label: __('Save changes', 'code-snippets'), - mod: 'Cmd', - key: 'S' - }, - selectAll: { - label: __('Select all', 'code-snippets'), - mod: 'Cmd', - key: 'A' - }, - beginSearch: { - label: __('Begin searching', 'code-snippets'), - mod: 'Cmd', - key: 'F' - }, - findNext: { - label: __('Find next', 'code-snippets'), - mod: 'Cmd', - key: 'G' - }, - findPrevious: { - label: __('Find previous', 'code-snippets'), - mod: ['Shift', 'Cmd'], - key: 'G' - }, - replace: { - label: __('Replace', 'code-snippets'), - mod: ['Shift', 'Cmd'], - key: 'F' - }, - replaceAll: { - label: __('Replace all', 'code-snippets'), - mod: ['Shift', 'Cmd', 'Option'], - key: 'R' - }, - search: { - label: __('Persistent search', 'code-snippets'), - mod: 'Alt', - key: 'F' - }, - toggleComment: { - label: __('Toggle comment', 'code-snippets'), - mod: 'Cmd', - key: '/' - }, - swapLineUp: { - label: __('Swap line up', 'code-snippets'), - mod: 'Option', - key: 'Up' - }, - swapLineDown: { - label: __('Swap line down', 'code-snippets'), - mod: 'Option', - key: 'Down' - }, - autoIndent: { - label: __('Auto-indent current line or selection', 'code-snippets'), - mod: 'Shift', - key: 'Tab' - } +const KEYBOARD_KEYS = { + 'Fn': _x('Fn', 'keyboard key', 'code-snippets'), + 'Cmd': _x('Ctrl', 'keyboard key', 'code-snippets'), + 'Ctrl': _x('Ctrl', 'keyboard key', 'code-snippets'), + 'Shift': _x('Shift', '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'), + 'Left': _x('Left', 'keyboard key', 'code-snippets'), + 'Right': _x('Right', 'keyboard key', 'code-snippets'), + 'A': _x('A', 'keyboard key', 'code-snippets'), + 'B': _x('B', 'keyboard key', 'code-snippets'), + 'C': _x('C', 'keyboard key', 'code-snippets'), + 'D': _x('D', 'keyboard key', 'code-snippets'), + 'E': _x('E', 'keyboard key', 'code-snippets'), + 'F': _x('F', 'keyboard key', 'code-snippets'), + 'G': _x('G', 'keyboard key', 'code-snippets'), + 'H': _x('H', 'keyboard key', 'code-snippets'), + 'I': _x('I', 'keyboard key', 'code-snippets'), + 'J': _x('J', 'keyboard key', 'code-snippets'), + 'K': _x('K', 'keyboard key', 'code-snippets'), + 'L': _x('L', 'keyboard key', 'code-snippets'), + 'M': _x('M', 'keyboard key', 'code-snippets'), + 'N': _x('N', 'keyboard key', 'code-snippets'), + 'O': _x('O', 'keyboard key', 'code-snippets'), + 'P': _x('P', 'keyboard key', 'code-snippets'), + 'Q': _x('Q', 'keyboard key', 'code-snippets'), + 'R': _x('R', 'keyboard key', 'code-snippets'), + 'S': _x('S', 'keyboard key', 'code-snippets'), + 'T': _x('T', 'keyboard key', 'code-snippets'), + 'U': _x('U', 'keyboard key', 'code-snippets'), + 'V': _x('V', 'keyboard key', 'code-snippets'), + 'W': _x('W', 'keyboard key', 'code-snippets'), + 'X': _x('X', '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 const KEYBOARD_SYMBOLS: Partial = { + Cmd: '⌘', + Ctrl: '⌃', + Alt: '⌥', + Shift: '⇧', + Tab: '⇥', + Up: '↑', + Down: '↓', + Left: '←', + Right: '→' +} + +const keyMapLabels = { + saveChanges: __('Save changes', 'code-snippets'), + selectAll: __('Select all', 'code-snippets'), + find: __('Begin searching', 'code-snippets'), + findNext: __('Find next', 'code-snippets'), + findPrev: __('Find previous', 'code-snippets'), + replace: __('Replace', 'code-snippets'), + replaceAll: __('Replace all', 'code-snippets'), + findPersistent: __('Persistent search', 'code-snippets'), + toggleComment: __('Toggle comment', 'code-snippets'), + swapLineUp: __('Swap line up', 'code-snippets'), + swapLineDown: __('Swap line down', 'code-snippets'), + autoIndent: __('Auto-indent current line or selection', 'code-snippets') } -const SEP = _x('-', 'keyboard shortcut separator', 'code-snippets') - -const ModifierKey: React.FC<{ modifier: KeyboardKey }> = ({ modifier }) => { - switch (modifier) { - case 'Ctrl': - case 'Cmd': - return ( - <> - {KEYBOARD_KEYS.Ctrl} - {KEYBOARD_KEYS.Cmd} - {SEP} - - ) - - case 'Option': - return ( - - {KEYBOARD_KEYS.Option}{SEP} - - ) - - default: - return <>{KEYBOARD_KEYS[modifier]}{SEP} +const KEY_ORDER: readonly (keyof typeof KEYBOARD_KEYS)[] = isMacOS() + ? ['Fn', 'Ctrl', 'Alt', 'Shift', 'Cmd'] + : ['Cmd', 'Ctrl', 'Shift', 'Alt', 'Fn'] + +const getKeyComparisonValue = (key: string): string => + KEY_ORDER.includes(key as keyof typeof KEYBOARD_KEYS) + ? String(KEY_ORDER.indexOf(key as keyof typeof KEYBOARD_KEYS)) + : key + +const unpackKeyMap = (keyMap: KeyMap): Map => { + const result = new Map() + + for (const [shortcut, action] of Object.entries(keyMap)) { + if ('string' === typeof action && keyMapLabels[action as keyof typeof keyMapLabels]) { + const keys = shortcut.split('-') + + keys.sort((a, b) => + getKeyComparisonValue(a).localeCompare(getKeyComparisonValue(b))) + + result.set(action, keys) + } } + + return result +} + +interface KeyboardShortcutMacProps { + keys: string[] + keyLabels: Partial> + keySymbols: Partial> +} + +const KeyboardShortcutMac: React.FC = ({ keys, keyLabels, keySymbols }) => + + {keys.map(key => + {keySymbols[key] ?? keyLabels[key] ?? key})} + + +const SEP = _x('+', 'keyboard shortcut separator', 'code-snippets') + +interface KeyboardShortcutPCProps { + keys: string[] + keyLabels: Partial> +} + +const KeyboardShortcutPC: React.FC = ({ keys, keyLabels }) => + + {keys.map((key, index) => + + {keyLabels[key] ?? key} + {index < keys.length - 1 && {SEP}} + )} + + +const fallbackKeyMap: Partial> = { + 'Ctrl-S': 'saveChanges', + 'Shift-Tab': 'autoIndent' +} + +const fallbackKeyMapMac: typeof fallbackKeyMap = { + 'Cmd-S': 'saveChanges', + 'Shift-Tab': 'autoIndent' } export interface CodeEditorShortcutsProps { editorTheme: string } -export const CodeEditorShortcuts: React.FC = ({ editorTheme }) => -
- - -
- - - {Object.entries(shortcuts).map(([name, { label, mod, key }]) => - - - - )} - -
{label} - {(Array.isArray(mod) ? mod : [mod]).map(modifier => - - - - )} - {KEYBOARD_KEYS[key]} -
+export const CodeEditorShortcuts: React.FC = ({ editorTheme }) => { + const { codeEditorInstance } = useSnippetForm() + + const shortcutKeys: Map | undefined = useMemo(() => { + if (codeEditorInstance) { + const extraKeys = codeEditorInstance.codemirror.getOption('extraKeys') + const keyMapName = codeEditorInstance.codemirror.getOption('keyMap') + + const combinedKeyMap: KeyMap = { + ...isMacOS() ? fallbackKeyMapMac : fallbackKeyMap, + ...keyMapName && getKeyMap(keyMapName), + ...'object' === typeof extraKeys ? extraKeys : undefined + } + + return unpackKeyMap(combinedKeyMap) + } + + return undefined + }, + [codeEditorInstance] + ) + + return shortcutKeys + ?
+ + +
+ + + {Object.entries(keyMapLabels).map(([action, label]) => { + const keys = shortcutKeys.get(action) + return keys + ? + + + + : null + })} + +
{label} + {isMacOS() + ? + : } +
+
-
+ : null +} diff --git a/src/js/components/SnippetForm/fields/SnippetTypeInput.tsx b/src/js/components/SnippetForm/fields/SnippetTypeInput.tsx index 1e2b1200..989b795b 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_TYPES, SNIPPET_TYPE_SCOPES } from '../../../types/Snippet' import { isLicensed } from '../../../utils/screen' -import { getSnippetType, isProType } from '../../../utils/snippets/snippets' +import { SNIPPET_TYPE_LABELS, getSnippetType, isProType } 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/SnippetForm/page/PageHeading.tsx b/src/js/components/SnippetForm/page/PageHeading.tsx index 041dd3eb..b769f6d9 100644 --- a/src/js/components/SnippetForm/page/PageHeading.tsx +++ b/src/js/components/SnippetForm/page/PageHeading.tsx @@ -9,7 +9,7 @@ const OPTIONS = window.CODE_SNIPPETS_EDIT const getAddNewHeading = (snippet: Snippet): string => 'condition' === snippet.scope ? __('Add New Condition', 'code-snippets') - : __('Add New Snippet', 'code-snippets') + : __('Create New Snippet', 'code-snippets') export const PageHeading: React.FC = () => { const { snippet, updateSnippet, setCurrentNotice } = useSnippetForm() diff --git a/src/js/components/SnippetsTable/SnippetsListTable.tsx b/src/js/components/SnippetsTable/SnippetsListTable.tsx new file mode 100644 index 00000000..fdb1f09c --- /dev/null +++ b/src/js/components/SnippetsTable/SnippetsListTable.tsx @@ -0,0 +1,196 @@ +import { __, _x, sprintf } from '@wordpress/i18n' +import React, { Fragment, useMemo } from 'react' +import { addQueryArgs } from '@wordpress/url' +import { useFilteredSnippets } from '../../hooks/useFilteredSnippets' +import { useRestAPI } from '../../hooks/useRestAPI' +import { useSnippetsFilters } from '../../hooks/useSnippetsFilters' +import { useSnippetsList } from '../../hooks/useSnippetsList' +import { handleUnknownError } from '../../utils/errors' +import { REST_API_NAMESPACE, REST_BASE } from '../../utils/restAPI' +import { getSnippetType } from '../../utils/snippets/snippets' +import { ListTable } from '../common/ListTable' +import { SubmitButton } from '../common/SubmitButton' +import { TableColumns } from './TableColumns' +import type { ListTableBulkAction } from '../common/ListTable' +import type { Snippet, SnippetStatus } from '../../types/Snippet' + +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() + } +] + +const STATUS_LABELS: [SnippetStatus | undefined, string][] = [ + [undefined, __('All', 'code-snippets')], + ['active', __('Active', 'code-snippets')], + ['inactive', __('Inactive', 'code-snippets')], + ['recently_activated', __('Recently Activated', 'code-snippets')] +] + +const SnippetStatusCounts = () => { + const { currentStatus, setCurrentStatus } = useSnippetsFilters() + const { snippetsByStatus } = useFilteredSnippets() + const visibleStatuses = STATUS_LABELS.filter(([status]) => snippetsByStatus.has(status)) + + return ( + + ) +} + +const ClearRecentlyActiveButton: React.FC = () => { + const { api } = useRestAPI() + const { refreshSnippetsList } = useSnippetsList() + const { currentStatus } = useSnippetsFilters() + + return 'recently_activated' === currentStatus + ?
+ { + event.preventDefault() + api.del(`${REST_BASE}/${REST_API_NAMESPACE}/v1/recently-active`) + .then(refreshSnippetsList) + .catch(handleUnknownError) + }} + /> +
+ : null +} + +interface ExtraTableNavProps { + visibleSnippets: Snippet[] +} + +const FilterByTagControl: React.FC = ({ visibleSnippets }) => { + const { currentTag, setCurrentTag } = useSnippetsFilters() + + const tagsList: Set = useMemo( + () => visibleSnippets.reduce((tags, snippet) => { + snippet.tags.forEach(tag => tags.add(tag)) + return tags + }, new Set()), + [visibleSnippets]) + + return 0 < tagsList.size + ?
+ +
+ : null +} + +const SearchBox = () => { + const { searchQuery, setSearchQuery } = useSnippetsFilters() + + return ( +

+ + setSearchQuery(event.target.value)} + placeholder={__('Search snippets', 'code-snippets')} + /> +

+ ) +} + +const NoItemsMessage = () => { + const { currentType, currentTag, searchQuery } = useSnippetsFilters() + + return searchQuery || currentTag + ? <> + {__('No snippets were found matching the current search query.', 'code-snippets')} + {__(' Please enter a new query or use the "Clear Filters" button above.', 'code-snippets')} + + : <>{currentType + ? __("It looks like you don't have any snippets of this type.", 'code-snippets') + : __("It looks like you don't have any snippets.", 'code-snippets')} + + {' '} + + {__('Perhaps you would like to add a new one?', 'code-snippets')} + + +} + +export const SnippetsListTable: React.FC = () => { + const { currentStatus } = useSnippetsFilters() + const { snippetsByStatus } = useFilteredSnippets() + + return ( + <> + + + + snippet.id} + columns={TableColumns} + actions={actions} + itemsPerPage={window.CODE_SNIPPETS_MANAGE?.snippetsPerPage} + extraTableNav={which => + <> + {'top' === which && } + + } + rowClassName={snippet => + `snippet ${snippet.active ? 'active' : 'inactive'}-snippet ${getSnippetType(snippet)}-snippet ${snippet.scope}-snippet`} + noItems={} + /> + + ) +} diff --git a/src/js/components/SnippetsTable/SnippetsTable.tsx b/src/js/components/SnippetsTable/SnippetsTable.tsx new file mode 100644 index 00000000..5fbdc13a --- /dev/null +++ b/src/js/components/SnippetsTable/SnippetsTable.tsx @@ -0,0 +1,120 @@ +import { __, sprintf } from '@wordpress/i18n' +import React, { useState } from 'react' +import classnames from 'classnames' +import { addQueryArgs } from '@wordpress/url' +import { WithFilteredSnippetsContext } from '../../hooks/useFilteredSnippets' +import { WithRestAPIContext } from '../../hooks/useRestAPI' +import { WithSnippetsListContext } from '../../hooks/useSnippetsList' +import { WithSnippetsTableFiltersContext, useSnippetsFilters } from '../../hooks/useSnippetsFilters' +import { SNIPPET_TYPES } from '../../types/Snippet' +import { isLicensed } from '../../utils/screen' +import { SNIPPET_TYPE_LABELS, getSnippetEditUrl, isProType } from '../../utils/snippets/snippets' +import { Badge } from '../common/Badge' +import { Button } from '../common/Button' +import { Toolbar } from '../common/Toolbar' +import { UpsellDialog } from '../common/UpsellDialog' +import { SnippetsListTable } from './SnippetsListTable' +import type { SnippetType } from '../../types/Snippet' + +interface SnippetTypeTabProps { + type?: SnippetType + setIsUpgradeDialogOpen: (isOpen: boolean) => void +} + +const SnippetTypeTab: React.FC = ({ type, setIsUpgradeDialogOpen }) => { + const { currentType, setCurrentType } = useSnippetsFilters() + const tabName = type ?? 'all' + + return ( + { + event.preventDefault() + + if (type && !isLicensed() && isProType(type)) { + setIsUpgradeDialogOpen(true) + } else { + setCurrentType(type) + } + }} + > + + {type ? SNIPPET_TYPE_LABELS[type] : __('All Snippets', 'code-snippets')} + + {type && + } + + ) +} + +const PageHeading = () => { + const { searchQueryText, searchLineNumber, currentTag, setSearchQuery, setCurrentTag } = useSnippetsFilters() + return ( +
+

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

+ + {searchQueryText || currentTag + ? + {__('Search results', 'code-snippets')} + + {/* translators: %s: search query. */} + {searchQueryText && sprintf(__(' for “%s”', 'code-snippets'), searchQueryText)} + + {/* translators: %s: search query. */} + {searchLineNumber && sprintf(__(' on line “%d”', 'code-snippets'), searchLineNumber)} + + {/* translators: %s: tag name. */} + {currentTag && sprintf(__(' in tag “%s”', 'code-snippets'), currentTag)} + + {' '} + + + : null} + + + {__('Create new snippet', 'code-snippets')} + +
+ ) +} + +const SnippetsTableInner = () => { + const [isUpgradeDialogOpen, setIsUpgradeDialogOpen] = useState(false) + + return ( +
+ + +

+ + {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 new file mode 100644 index 00000000..55b7649a --- /dev/null +++ b/src/js/components/SnippetsTable/TableColumns.tsx @@ -0,0 +1,248 @@ +import React, { Fragment, useState } from 'react' +import { __, sprintf } from '@wordpress/i18n' +import { addQueryArgs } from '@wordpress/url' +import { humanTimeDiff } from '@wordpress/date' +import { RawHTML } from '@wordpress/element' +import { useFilteredSnippets } from '../../hooks/useFilteredSnippets' +import { useRestAPI } from '../../hooks/useRestAPI' +import { useSnippetsFilters } from '../../hooks/useSnippetsFilters' +import { useSnippetsList } from '../../hooks/useSnippetsList' +import { handleUnknownError } from '../../utils/errors' +import { downloadSnippetExportFile } from '../../utils/files' +import { isNetworkAdmin } from '../../utils/screen' +import { getSnippetDisplayName, getSnippetEditUrl, getSnippetType } from '../../utils/snippets/snippets' +import { Badge } from '../common/Badge' +import { Button } from '../common/Button' +import { DeleteButton } from '../common/DeleteButton' +import type { Snippet } from '../../types/Snippet' +import type { ListTableColumn } from '../common/ListTable' + +interface ColumnProps { + snippet: Snippet +} + +const ActivateColumn: React.FC = ({ snippet }) => { + const { snippetsAPI: { activate, deactivate } } = useRestAPI() + const { activeByCondition } = useFilteredSnippets() + const { refreshSnippetsList } = useSnippetsList() + + switch (snippet.scope) { + case 'single-use': + return ( + +   + + ) + + case 'condition': + return ( + + {activeByCondition.get(snippet.id)?.length ?? 0} + + ) + + 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 ( +
+ {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 }) => + <> + {isNetworkAdmin() || !snippet.network || window.CODE_SNIPPETS_MANAGE?.hasNetworkCap + ? {getSnippetDisplayName(snippet)} + : getSnippetDisplayName(snippet)} + + {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() + const { refreshSnippetsList } = useSnippetsList() + const id = `snippet-${snippet.id}-priority` + + 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))} + /> +
+ ) +} + +export const TableColumns: ListTableColumn[] = [ + { + id: 'activate', + render: snippet => + }, + { + id: 'name', + title: __('Name', 'code-snippets'), + isPrimary: true, + sortedValue: snippet => getSnippetDisplayName(snippet).toLowerCase(), + render: snippet => + }, + { + id: 'type', + title: __('Type', 'code-snippets'), + sortedValue: snippet => getSnippetType(snippet), + render: snippet => + }, + { + id: 'desc', + title: __('Description', 'code-snippets'), + render: snippet => {snippet.desc} + }, + { + id: 'tags', + title: __('Tags', 'code-snippets'), + render: snippet => + }, + { + id: 'date', + title: __('Modified', 'code-snippets'), + sortedValue: snippet => snippet.modified ? new Date(snippet.modified).toISOString() : '', + render: snippet => + }, + { + id: 'priority', + title: __('Priority', 'code-snippets'), + sortedValue: snippet => snippet.priority, + render: snippet => + } +] 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/WelcomePage/Changelog.tsx b/src/js/components/WelcomePage/Changelog.tsx new file mode 100644 index 00000000..30366732 --- /dev/null +++ b/src/js/components/WelcomePage/Changelog.tsx @@ -0,0 +1,85 @@ +import React, { Fragment } from 'react' +import { __, sprintf } from '@wordpress/i18n' +import { CHANGELOG_SECTIONS } from '../../types/schema/WelcomeSchema' +import type { ChangelogSectionTitle } from '../../types/schema/WelcomeSchema' + +const CHANGELOG_LABELS: Record = { + Added: __('New features', 'code-snippets'), + Changed: __('Improvements', 'code-snippets'), + Deprecated: __('Deprecated features', 'code-snippets'), + Removed: __('Removed features', 'code-snippets'), + Fixed: __('Bug fixes', 'code-snippets'), + Security: __('Security updates', 'code-snippets'), + Other: __('Other', 'code-snippets') +} + +const CHANGELOG_ICONS: Record = { + Added: 'lightbulb', + Changed: 'chart-line', + Deprecated: 'remove', + Removed: 'trash', + Fixed: 'buddicons-replies', + Security: 'shield', + Other: 'open-folder' +} + +const PLUGIN_TYPE_LABELS: Record = { + core: __('Core', 'code-snippets'), + pro: __('Pro', 'code-snippets') +} + +const CHANGELOG_DATA = window.CODE_SNIPPETS_WELCOME?.changelog + +interface ChangelogSectionProps { + section: ChangelogSectionTitle + entries: Record +} + +const ChangelogSection: React.FC = ({ section, entries }) => + <> +

+ + {CHANGELOG_LABELS[section]} +

+
    + {Object.entries(entries).map(([pluginType, changes]) => + changes.map(change => +
  • + + {PLUGIN_TYPE_LABELS[pluginType] ?? pluginType} + + {change} +
  • ) + )} +
+ + +export const Changelog = () => +
+
+

{__('Latest changes', 'code-snippets')}

+ + {__('View changelog', 'code-snippets')} + +
+
+ {CHANGELOG_DATA?.map(({ version, date, entries }) => + +
+ {/* translators: %s: version number. */} +

{sprintf(__('Version %s', 'code-snippets'), version)}

+

{date}

+
+
+ {CHANGELOG_SECTIONS.map(section => + entries[section] + ? + : null)} +
+
)} +
+
diff --git a/src/js/components/WelcomePage/WelcomePage.tsx b/src/js/components/WelcomePage/WelcomePage.tsx new file mode 100644 index 00000000..6ef33fba --- /dev/null +++ b/src/js/components/WelcomePage/WelcomePage.tsx @@ -0,0 +1,99 @@ +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 + +const HeroImage = () => { + const [isImageLoaded, setImageLoaded] = useState(false) + + return ( +
+
+

{DATA?.hero.name}

+ + {__('Read more', 'code-snippets')} + +
+
+ {!isImageLoaded &&
} + {__('Latest setImageLoaded(true)} + /> +
+
+ ) +} + +interface PartnersProps { + partners: ImageLinkSchema[] +} + +const Partners: React.FC = ({ partners }) => + <> +

{__('Exclusive deals from our partners', 'code-snippets')}

+
+ {partners.map(({ title, follow_url, image_url }) => + )} +
+ + +interface ArticlesProps { + articles: ImageLinkSchema[] +} + +const Articles: React.FC = ({ articles }) => + <> +

{__('Helpful articles', 'code-snippets')}

+
+ {articles.map(({ title, follow_url, image_url, description, category }) => + )} +
+ + +export const WelcomePage = () => + <> + +
+

{__('Resources and Updates', 'code-snippets')}

+ +
+ + +
+ + {DATA?.features && } + {DATA?.partners && } +
+ diff --git a/src/js/components/WelcomePage/index.ts b/src/js/components/WelcomePage/index.ts new file mode 100644 index 00000000..ef5b08c9 --- /dev/null +++ b/src/js/components/WelcomePage/index.ts @@ -0,0 +1 @@ +export * from './WelcomePage' diff --git a/src/js/components/EditorSidebar/actions/DeleteButton.tsx b/src/js/components/common/DeleteButton.tsx similarity index 53% rename from src/js/components/EditorSidebar/actions/DeleteButton.tsx rename to src/js/components/common/DeleteButton.tsx index 8f5f7d3b..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 ( <> + +interface NavigationButtonsProps { + currentPage: number + renderAsLinks?: boolean + setCurrentPage: (page: number) => void +} + +const BackwardNavigationButtons: React.FC = ({ currentPage, ...buttonProps }) => + 1 === currentPage + ? <> + {'\n'} + {'\n'} + + : <> + «} + newPage={1} + className="first-page" + /* translators: Hidden accessibility text. */ + helperText={__('First page', 'code-snippets')} + {...buttonProps} + /> + ‹} + newPage={Math.max(1, currentPage - 1)} + className="prev-page" + /* translators: Hidden accessibility text. */ + helperText={__('Previous page', 'code-snippets')} + {...buttonProps} + /> + + +interface ForwardNavigationButtonsProps extends NavigationButtonsProps { + totalPages: number +} + +const ForwardNavigationButtons: React.FC = ({ currentPage, totalPages, ...buttonProps }) => + totalPages === currentPage + ? <> + {'\n'} + + + : <> + ›} + newPage={Math.min(totalPages, currentPage + 1)} + className="next-page" + /* translators: Hidden accessibility text. */ + helperText={__('Next page', 'code-snippets')} + {...buttonProps} + />{'\n'} + »} + newPage={totalPages} + className="last-page" + /* translators: Hidden accessibility text. */ + helperText={__('Last page', 'code-snippets')} + {...buttonProps} + />{'\n'} + + +interface PagingInputProps { + which: 'top' | 'bottom' + totalPages: number + inputValue: number + setInputValue: (value: number) => void + confirmInputValue: VoidFunction +} + +const PagingInput: React.FC = ({ which, totalPages, inputValue, setInputValue, confirmInputValue }) => + <> + + { + const value = Number(event.target.value) + + if (value) { + setInputValue(value) + } + }} + /> + + +interface CurrentPageProps extends PagingInputProps { + currentPage: number +} + +const CurrentPage: React.FC = ({ which, totalPages, currentPage, ...inputProps }) => + 'bottom' === which + ? <> + {/* translators: Hidden accessibility text. */} + {__('Current Page', 'code-snippets')} + + + {/* translators: 1: Current page. */ + sprintf(_x('%s of ', 'paging', 'code-snippets'), currentPage)} + {totalPages} + + + + : + + + {/* translators: 1: Current page. */ + _x(' of ', 'paging', 'code-snippets')} + {totalPages} + + + +interface PaginationControlsProps { + which: 'top' | 'bottom' + inputValue: number + totalPages: number + totalItems: number + currentPage: number + useQueryVars?: boolean + setInputValue: (value: number) => void + setCurrentPage: (page: number) => void +} + +const PaginationControls: React.FC = ({ + which, + totalPages, + totalItems, + inputValue, + currentPage, + useQueryVars, + setCurrentPage, + setInputValue +}) => +
{ + event.preventDefault() + setCurrentPage(inputValue) + }} + > + + {/* translators: %s: Number of items. */} + {sprintf(_n('%s item', '%s items', totalItems), totalItems)} + {'\n'} + + + + + setCurrentPage(inputValue)} + />{'\n'} + + + +
+ +export interface TablePaginationProps extends Omit, + Required> { + items: unknown[] + which: 'top' | 'bottom' + currentPage: number + setCurrentPage: (page: number) => void +} + +export const TablePagination: React.FC = ({ + items, + which, + currentPage, + itemsPerPage, + useQueryVars, + setCurrentPage +}) => { + const [inputValue, setInputValue] = useState(currentPage) + const totalItems = items.length + const totalPages = Math.ceil(totalItems / itemsPerPage) + + const setCurrentPageSafe = (page: number) => { + if (page) { + const validPage = Math.max(1, Math.min(page, totalPages)) + setInputValue(validPage) + setCurrentPage(validPage) + + if (useQueryVars) { + updateQueryParam('paged', 1 === validPage ? undefined : validPage) + } + } + } + + return ( + + ) +} 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/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/components/common/Toolbar.tsx b/src/js/components/common/Toolbar.tsx new file mode 100644 index 00000000..23039d97 --- /dev/null +++ b/src/js/components/common/Toolbar.tsx @@ -0,0 +1,156 @@ +import { __ } from '@wordpress/i18n' +import classnames from 'classnames' +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 + url: string | undefined + label: string + external?: boolean + icon?: ReactNode + pro?: boolean +} + +const UPPER_NAV_LINKS: NavLink[] = [ + { + name: 'docs', + url: 'https://help.codesnippets.pro/', + label: __('Docs', 'code-snippets'), + external: true + }, + { + name: 'cloud', + url: 'https://codesnippets.cloud/', + label: __('Cloud Dashboard', 'code-snippets'), + external: true + }, + { + name: 'welcome', + url: window.CODE_SNIPPETS?.urls.welcome, + label: __("What's New", 'code-snippets') + } +] + +const LOWER_NAV_LINKS: NavLink[] = [ + { + name: 'snippets', + url: window.CODE_SNIPPETS?.urls.manage, + label: __('Snippets', 'code-snippets'), + icon: + }, + { + name: 'cloud-library', + url: undefined, + label: __('My Library', 'code-snippets'), + icon: , + pro: true + }, + { + name: 'cloud-community', + url: undefined, + label: __('Community Cloud', 'code-snippets'), + icon: + }, + { + name: 'cloud-teams', + url: undefined, + label: __('My Teams', 'code-snippets'), + icon: , + pro: true + }, + { + name: 'settings', + url: window.CODE_SNIPPETS?.urls.settings, + label: __('Settings', 'code-snippets'), + icon: + } +] + +interface NavProps { + setIsUpsellDialogOpen: (isOpen: boolean) => void +} + +const UpperNav: React.FC = ({ setIsUpsellDialogOpen }) => +
+
+ {__('Code + +

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

+
+ + +
+ +const currentPage = fetchQueryParam('page') + +const LowerNav: React.FC = ({ setIsUpsellDialogOpen }) => + + +export const Toolbar = () => { + const [isUpsellDialogOpen, setIsUpsellDialogOpen] = useState(false) + + return ( +
+ + + +
+ ) +} diff --git a/src/js/components/common/UpsellBanner.tsx b/src/js/components/common/UpsellBanner.tsx index 452531df..05671443 100644 --- a/src/js/components/common/UpsellBanner.tsx +++ b/src/js/components/common/UpsellBanner.tsx @@ -1,13 +1,13 @@ import { ExternalLink } from '@wordpress/components' import { __ } from '@wordpress/i18n' import React, { useState } from 'react' -import { isLicensed } from '../../utils/screen' +import { shouldShowUpsell } from '../../utils/screen' import { Button } from './Button' export const UpsellBanner = () => { const [isDismissed, setIsDismissed] = useState(false) - return isDismissed || isLicensed() || window.CODE_SNIPPETS_EDIT?.hideUpsell + return isDismissed || shouldShowUpsell() ? null :
+ + + + +export const LibraryIcon = () => + + + + +export const SettingsIcon = () => + + + + + +export const SnippetsIcon = () => + + + + +export const TeamsIcon = () => + + + 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/entries/edit.ts b/src/js/entries/edit.ts new file mode 100644 index 00000000..ca3dc6ca --- /dev/null +++ b/src/js/entries/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/editor.ts b/src/js/entries/editor.ts similarity index 92% rename from src/js/editor.ts rename to src/js/entries/editor.ts index c36e2db5..bc6774dc 100644 --- a/src/js/editor.ts +++ b/src/js/entries/editor.ts @@ -1,5 +1,5 @@ import { defineMode, getMode, registerHelper } from 'codemirror' -import { Linter } from './utils/Linter' +import { Linter } from '../utils/Linter' import type { EditorConfiguration, ModeSpec } from 'codemirror' interface ModeSpecOptions { diff --git a/src/js/manage.ts b/src/js/entries/manage-legacy.ts similarity index 72% rename from src/js/manage.ts rename to src/js/entries/manage-legacy.ts index 634cad9b..e7a6d44e 100644 --- a/src/js/manage.ts +++ b/src/js/entries/manage-legacy.ts @@ -1,4 +1,4 @@ -import { handleShowCloudPreview, handleSnippetActivationSwitches, handleSnippetPriorityChanges } from './services/manage' +import { handleShowCloudPreview, handleSnippetActivationSwitches, handleSnippetPriorityChanges } from '../services/manage' handleSnippetActivationSwitches() handleSnippetPriorityChanges() diff --git a/src/js/entries/manage.ts b/src/js/entries/manage.ts new file mode 100644 index 00000000..5dd1e8d3 --- /dev/null +++ b/src/js/entries/manage.ts @@ -0,0 +1,4 @@ +import { SnippetsTable } from '../components/SnippetsTable' +import { loadComponent } from '../utils/bootstrap' + +loadComponent('snippets-table-container', SnippetsTable) diff --git a/src/js/mce.ts b/src/js/entries/mce.ts similarity index 96% rename from src/js/mce.ts rename to src/js/entries/mce.ts index c302de99..e31ffdf5 100644 --- a/src/js/mce.ts +++ b/src/js/entries/mce.ts @@ -1,7 +1,7 @@ import tinymce from 'tinymce' import type { Editor } from 'tinymce' -import type { ContentShortcodeAtts, SourceShortcodeAtts } from './types/Shortcodes' -import type { LocalisedEditor } from './types/WordPressEditor' +import type { ContentShortcodeAtts, SourceShortcodeAtts } from '../types/Shortcodes' +import type { LocalisedEditor } from '../types/WordPressEditor' const convertToValues = (array: Record) => Object.keys(array).map(key => ({ diff --git a/src/js/prism.ts b/src/js/entries/prism.ts similarity index 100% rename from src/js/prism.ts rename to src/js/entries/prism.ts diff --git a/src/js/entries/settings.ts b/src/js/entries/settings.ts new file mode 100644 index 00000000..2bc406a1 --- /dev/null +++ b/src/js/entries/settings.ts @@ -0,0 +1,8 @@ +import { Toolbar } from '../components/common/Toolbar' +import { handleEditorPreviewUpdates, handleSettingsTabs } from '../services/settings' +import { loadComponent } from '../utils/bootstrap' + +loadComponent('code-snippets-toolbar-container', Toolbar) + +handleSettingsTabs() +handleEditorPreviewUpdates() diff --git a/src/js/entries/welcome.ts b/src/js/entries/welcome.ts new file mode 100644 index 00000000..f7686591 --- /dev/null +++ b/src/js/entries/welcome.ts @@ -0,0 +1,4 @@ +import { WelcomePage } from '../components/WelcomePage' +import { loadComponent } from '../utils/bootstrap' + +loadComponent('code-snippets-welcome-container', WelcomePage) diff --git a/src/js/hooks/useFilteredSnippets.tsx b/src/js/hooks/useFilteredSnippets.tsx new file mode 100644 index 00000000..f3e3c916 --- /dev/null +++ b/src/js/hooks/useFilteredSnippets.tsx @@ -0,0 +1,92 @@ +import React, { useMemo } from 'react' +import { createContextHook } from '../utils/bootstrap' +import { parseSnippetObject } from '../utils/snippets/objects' +import { getSnippetType } from '../utils/snippets/snippets' +import { useSnippetsList } from './useSnippetsList' +import { useSnippetsFilters } from './useSnippetsFilters' +import type { PropsWithChildren} from 'react' +import type { Snippet, SnippetStatus } from '../types/Snippet' + +const partitionSnippetsByStatus = (snippets: Snippet[]): Map => + 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()) + +const partitionActiveSnippetsByCondition = (snippets: readonly Snippet[]): Map => + snippets.reduce((acc, snippet) => { + if (snippet.active) { + if (!acc.get(snippet.conditionId)?.push(snippet)) { + acc.set(snippet.conditionId, [snippet]) + } + } + + return acc + }, new Map()) + +export const [FilteredSnippetsContext, useFilteredSnippets] = createContextHook('useFilteredSnippets') + +export interface FilteredSnippetsContext { + snippetsByStatus: Map + activeByCondition: Map +} + +export const WithFilteredSnippetsContext: React.FC = ({ children }) => { + const { snippetsList } = useSnippetsList() + const { currentType, currentTag, searchLineNumber, searchQueryText } = useSnippetsFilters() + + const snippets = useMemo(() => + snippetsList ?? window.CODE_SNIPPETS_MANAGE?.snippetsList.map(parseSnippetObject) ?? [], + [snippetsList]) + + const visibleSnippets = useMemo(() => { + const searchFields = ['name', 'desc', 'code', 'tags'] as const + const sanitizedSearchQueryText = searchQueryText?.toLowerCase().trim() + + return snippets.filter(snippet => { + if (currentType && getSnippetType(snippet) !== currentType) { + return false + } + + if (currentTag && !snippet.tags.includes(currentTag)) { + return false + } + + if (sanitizedSearchQueryText) { + return searchLineNumber !== undefined + ? snippet.code.split('\n')[searchLineNumber]?.includes(sanitizedSearchQueryText) + : searchFields.some(field => + ('tags' === field ? snippet.tags.join(' ') : snippet[field]) + .toLowerCase().includes(sanitizedSearchQueryText)) + } + + return true + }) + }, [snippets, currentTag, currentType, searchQueryText, searchLineNumber]) + + const snippetsByStatus = useMemo(() => + partitionSnippetsByStatus(visibleSnippets), + [visibleSnippets]) + + const activeByCondition = useMemo( + () => partitionActiveSnippetsByCondition(snippets), + [snippets]) + + const value: FilteredSnippetsContext = { + snippetsByStatus, + activeByCondition + } + + return {children} +} diff --git a/src/js/hooks/useRestAPI.tsx b/src/js/hooks/useRestAPI.tsx index 95503183..09b3b847 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' @@ -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 => ({ @@ -37,16 +41,16 @@ const buildRestAPI = (axiosInstance: AxiosInstance): RestAPI => ({ debugRequest('GET', url, axiosInstance.get, never>(url)), post: (url: string, data?: object): Promise => - debugRequest('POST', url, axiosInstance.post, typeof data>(url, data), data), + debugRequest('POST', url, axiosInstance.post>(url, data), data), del: (url: string): Promise => debugRequest('DELETE', url, axiosInstance.delete, never>(url)), put: (url: string, data?: object): Promise => - debugRequest('PUT', url, axiosInstance.put, typeof data>(url, data), data) + debugRequest('PUT', url, axiosInstance.put>(url, data), 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 458b9f39..62c9ec57 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' @@ -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/useSnippetsFilters.tsx b/src/js/hooks/useSnippetsFilters.tsx new file mode 100644 index 00000000..c1c9fc60 --- /dev/null +++ b/src/js/hooks/useSnippetsFilters.tsx @@ -0,0 +1,86 @@ +import React, { useCallback, useMemo, useState } from 'react' +import { SNIPPET_STATUSES, SNIPPET_TYPES } from '../types/Snippet' +import { createContextHook } from '../utils/bootstrap' +import { fetchQueryParam, updateQueryParam } from '../utils/urls' +import type { SnippetStatus, SnippetType } from '../types/Snippet' +import type { PropsWithChildren } from 'react' + +const isSnippetType = (type: unknown): type is SnippetType => + SNIPPET_TYPES.includes(type as SnippetType) + +const isSnippetStatus = (status: unknown): status is SnippetStatus => + SNIPPET_STATUSES.includes(status as SnippetStatus) + +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] +} + +export interface SnippetsFiltersContext { + currentTag: string | undefined + currentType: SnippetType | undefined + searchQuery: string | undefined + currentStatus: SnippetStatus | undefined + setCurrentTag: (tag?: string) => void + setCurrentType: (type?: SnippetType) => void + setSearchQuery: (query?: string) => void + setCurrentStatus: (status?: SnippetStatus) => void + searchLineNumber?: number + searchQueryText?: string +} + +export const [SnippetsFiltersContext, useSnippetsFilters] = createContextHook('useSnippetsFilters') + +export const WithSnippetsTableFiltersContext: React.FC = ({ children }) => { + const [currentTag, setTag] = useState(() => fetchQueryParam('tag')) + const [searchQuery, setSearch] = useState(() => fetchQueryParam('s')) + + const [currentType, setCurrentType] = useState(() => { + const type = fetchQueryParam('type') + return isSnippetType(type) ? type : undefined + }) + + const [currentStatus, setCurrentStatus] = useState(() => { + const status = fetchQueryParam('status') + return isSnippetStatus(status) ? status : undefined + }) + + const setters = { + setCurrentType: useCallback((type?: SnippetType) => { + setCurrentType(type) + updateQueryParam('type', type) + }, [setCurrentType]), + setCurrentStatus: useCallback((status?: SnippetStatus) => { + setCurrentStatus(status) + updateQueryParam('status', status) + }, [setCurrentStatus]), + setCurrentTag: useCallback((tag?: string) => { + setTag(tag) + updateQueryParam('tag', tag) + }, [setTag]), + setSearchQuery: useCallback((query?: string) => { + setSearch(query) + updateQueryParam('s', query) + }, [setSearch]) + } + + const [searchQueryText, searchLineNumber] = useMemo( + () => parseSearchQuery(searchQuery), + [searchQuery]) + + const value: SnippetsFiltersContext = { + currentTag, + currentType, + searchQuery, + currentStatus, + searchQueryText, + searchLineNumber, + ...setters + } + + return {children} +} diff --git a/src/js/hooks/useSnippetsList.tsx b/src/js/hooks/useSnippetsList.tsx index 06325cc4..3b18b5c4 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' @@ -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/useSubmitSnippet.ts b/src/js/hooks/useSubmitSnippet.ts index 110d0c73..0e8e5bc6 100644 --- a/src/js/hooks/useSubmitSnippet.ts +++ b/src/js/hooks/useSubmitSnippet.ts @@ -8,7 +8,7 @@ import { useSnippetForm } from './useSnippetForm' import type { Snippet } from '../types/Snippet' const snippetMessages = { - addNew: __('Add New Snippet', 'code-snippets'), + addNew: __('Create New Snippet', 'code-snippets'), edit: __('Edit Snippet', 'code-snippets'), created: __('Snippet created.', 'code-snippets'), updated: __('Snippet updated.', 'code-snippets'), @@ -21,7 +21,7 @@ const snippetMessages = { } const conditionMessages: typeof snippetMessages = { - addNew: __('Add New Condition', 'code-snippets'), + addNew: __('Create New Condition', 'code-snippets'), edit: __('Edit Condition', 'code-snippets'), created: __('Condition created.', 'code-snippets'), updated: __('Condition updated.', 'code-snippets'), diff --git a/src/js/services/settings/editor-preview.ts b/src/js/services/settings/editor-preview.ts index f83a6b20..752717a0 100644 --- a/src/js/services/settings/editor-preview.ts +++ b/src/js/services/settings/editor-preview.ts @@ -1,4 +1,4 @@ -import '../../editor' +import '../../entries/editor' const parseSelect = (select: HTMLSelectElement) => select.options[select.selectedIndex].value const parseCheckbox = (checkbox: HTMLInputElement) => checkbox.checked diff --git a/src/js/services/settings/tabs.ts b/src/js/services/settings/tabs.ts index 6301beee..a743c95d 100644 --- a/src/js/services/settings/tabs.ts +++ b/src/js/services/settings/tabs.ts @@ -16,7 +16,7 @@ const refreshEditorPreview = (section: string) => { // Update the http referer value so that any redirections lead back to this tab. const updateHttpReferer = (section: string) => { - const httpReferer = document.querySelector('input[name=_wp_http_referer]') + const httpReferer: HTMLInputElement | null = document.querySelector('input[name=_wp_http_referer]') if (!httpReferer) { console.error('could not find http referer') return diff --git a/src/js/settings.ts b/src/js/settings.ts deleted file mode 100644 index c943ae31..00000000 --- a/src/js/settings.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { handleEditorPreviewUpdates, handleSettingsTabs } from './services/settings' - -handleSettingsTabs() -handleEditorPreviewUpdates() 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/Snippet.ts b/src/js/types/Snippet.ts index dfdec93f..d588d85b 100644 --- a/src/js/types/Snippet.ts +++ b/src/js/types/Snippet.ts @@ -11,11 +11,17 @@ 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'] +export const SNIPPET_STATUSES = ['active', 'inactive', 'recently_activated'] + +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/types/Window.ts b/src/js/types/Window.ts index f32f2a18..85ec3dce 100644 --- a/src/js/types/Window.ts +++ b/src/js/types/Window.ts @@ -1,3 +1,4 @@ +import type { ChangelogSchema, ImageLinkSchema } from './schema/WelcomeSchema' import type Prism from 'prismjs' import type tinymce from 'tinymce' import type { Snippet } from './Snippet' @@ -18,7 +19,9 @@ declare global { readonly code_snippets_editor_settings: EditorOption[] CODE_SNIPPETS_PRISM?: typeof Prism readonly CODE_SNIPPETS?: { + debug: boolean isLicensed: boolean + hideUpsell: boolean restAPI: { base: string snippets: string @@ -32,8 +35,26 @@ declare global { manage: string addNew: string edit: string + welcome: string + settings: string connectCloud: string } + banner: { + key: string + start_datetime: { date: string, timezone_type: number, timezone: string } + end_datetime: { date: string, timezone_type: number, timezone: string } + text_free: string + action_url_free: string + action_label_free: string + text_pro: string + action_url_pro: string + action_label_pro: string + } + } + readonly CODE_SNIPPETS_MANAGE?: { + snippetsList: Snippet[] + hasNetworkCap: boolean + snippetsPerPage: number } readonly CODE_SNIPPETS_EDIT?: { snippet: Snippet @@ -43,7 +64,6 @@ declare global { enableDownloads: boolean activateByDefault: boolean enableDescription: boolean - hideUpsell: boolean editorTheme: string tagOptions: { enabled: boolean @@ -55,5 +75,15 @@ declare global { mediaButtons: boolean } } + readonly CODE_SNIPPETS_WELCOME?: { + hero: { + name: string + follow_url: string + image_url: string + } + changelog: ChangelogSchema[] + features: ImageLinkSchema[] + partners: ImageLinkSchema[] + } } } 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/types/schema/WelcomeSchema.ts b/src/js/types/schema/WelcomeSchema.ts new file mode 100644 index 00000000..c9f5cd09 --- /dev/null +++ b/src/js/types/schema/WelcomeSchema.ts @@ -0,0 +1,18 @@ +export interface ChangelogSchema { + version: string + date: string + entries: ChangelogEntriesSchema +} + +export type ChangelogEntriesSchema = Partial> + +export const CHANGELOG_SECTIONS = ['Added', 'Changed', 'Fixed', 'Deprecated', 'Removed', 'Security', 'Other'] +export type ChangelogSectionTitle = typeof CHANGELOG_SECTIONS[number] + +export interface ImageLinkSchema { + title: string + image_url: string + follow_url: string + description?: string + category?: string +} diff --git a/src/js/utils/bootstrap.tsx b/src/js/utils/bootstrap.tsx new file mode 100644 index 00000000..b83fd533 --- /dev/null +++ b/src/js/utils/bootstrap.tsx @@ -0,0 +1,34 @@ +import React, { createContext, useContext } from 'react' +import { createRoot } from 'react-dom/client' +import type { Context, FunctionComponent } from 'react' + +export const loadComponent = (containerId: string, Component: FunctionComponent): void => { + const container = document.getElementById(containerId) + console.log('loading on', container) + + if (container) { + const root = createRoot(container) + root.render() + } else { + console.error(`Could not find element #${containerId}.`) + } +} + +export const createContextHook = (hookName: string): [ + Context, + () => T +] => { + const contextValue = createContext(undefined) + + const useContextHook = (): T => { + const value = useContext(contextValue) + + if (value === undefined) { + throw Error(`${hookName} can only be used within a corresponding 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/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/screen.ts b/src/js/utils/screen.ts index c073673b..333fc393 100644 --- a/src/js/utils/screen.ts +++ b/src/js/utils/screen.ts @@ -6,3 +6,6 @@ export const isMacOS = (): boolean => export const isLicensed = (): boolean => !!window.CODE_SNIPPETS?.isLicensed + +export const shouldShowUpsell = () => + !isLicensed() && !window.CODE_SNIPPETS?.hideUpsell diff --git a/src/js/utils/snippets/api.ts b/src/js/utils/snippets/api.ts index 37141a49..e9072fd9 100644 --- a/src/js/utils/snippets/api.ts +++ b/src/js/utils/snippets/api.ts @@ -58,11 +58,11 @@ export const buildSnippetsAPI = ({ get, post, del, put }: RestAPI): SnippetsAPI .then(createSnippetObject), create: snippet => - post(REST_SNIPPETS_BASE, mapToSchema(snippet)) + post(REST_SNIPPETS_BASE, mapToSchema(snippet)) .then(createSnippetObject), update: snippet => - post(snippet.id ? buildURL(snippet) : REST_SNIPPETS_BASE, mapToSchema(snippet)) + post(snippet.id ? buildURL(snippet) : REST_SNIPPETS_BASE, mapToSchema(snippet)) .then(createSnippetObject), delete: snippet => diff --git a/src/js/utils/snippets/objects.ts b/src/js/utils/snippets/objects.ts index f542ab64..1bae32a2 100644 --- a/src/js/utils/snippets/objects.ts +++ b/src/js/utils/snippets/objects.ts @@ -46,6 +46,7 @@ export const parseSnippetObject = (fields: unknown): Snippet => { ...'network' in fields && 'boolean' === typeof fields.network && { network: fields.network }, ...'shared_network' in fields && 'boolean' === typeof fields.shared_network && { shared_network: fields.shared_network }, ...'priority' in fields && 'number' === typeof fields.priority && { priority: fields.priority }, - ...'condition_id' in fields && isAbsInt(fields.condition_id) && { conditionId: fields.condition_id } + ...'condition_id' in fields && isAbsInt(fields.condition_id) && { conditionId: fields.condition_id }, + ...'last_active' in fields && { lastActive: Number(fields.last_active) } } } diff --git a/src/js/utils/snippets/snippets.ts b/src/js/utils/snippets/snippets.ts index 46f1a893..f3003da3 100644 --- a/src/js/utils/snippets/snippets.ts +++ b/src/js/utils/snippets/snippets.ts @@ -1,7 +1,16 @@ -import { __ } from '@wordpress/i18n' +import { __, sprintf } from '@wordpress/i18n' +import { addQueryArgs } from '@wordpress/url' 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']) export const createSnippetObject = (fields: unknown): Snippet => @@ -26,6 +35,17 @@ 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 getSnippetDisplayName = (snippet: Pick): string => + '' === snippet.name.trim() + // translators: %s: snippet identifier. + ? sprintf(isCondition(snippet) ? __('Condition #%d', 'code-snippets') : __('Snippet #%d', 'code-snippets'), snippet.id) + : snippet.name + export const validateSnippet = (snippet: Snippet): undefined | string => { const missingTitle = '' === snippet.name.trim() const missingCode = '' === snippet.code.trim() diff --git a/src/js/utils/urls.ts b/src/js/utils/urls.ts new file mode 100644 index 00000000..5dcbd632 --- /dev/null +++ b/src/js/utils/urls.ts @@ -0,0 +1,20 @@ +export const fetchQueryParam = (name: string): string | undefined => { + const urlParams = new URLSearchParams(window.location.search) + return urlParams.get(name) ?? undefined +} + +export const updateQueryParam = (name: string, value?: string | number) => { + if ('URLSearchParams' in window) { + const searchParams = new URLSearchParams(window.location.search) + + if (value) { + searchParams.set(name, String(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) + } +} diff --git a/src/php/admin-menus/class-admin-menu.php b/src/php/admin-menus/class-admin-menu.php index 2e731eb0..bb3ddd5d 100644 --- a/src/php/admin-menus/class-admin-menu.php +++ b/src/php/admin-menus/class-admin-menu.php @@ -42,6 +42,29 @@ abstract class Admin_Menu { */ protected string $slug; + /** + * Common JavaScript dependencies required for React components. + * + * @var string[] + */ + public static array $script_deps = [ + 'react', + 'react-dom', + 'wp-url', + 'wp-i18n', + 'wp-date', + 'wp-components', + ]; + + /** + * Common CSS dependencies required for React components. + * + * @var string[] + */ + public static array $style_deps = [ + 'wp-components', + ]; + /** * Constructor. * @@ -99,6 +122,13 @@ public function register() { $this->add_menu( $this->slug, $this->label, $this->title ); } + /** + * Render the navigation bar at the top of the admin page. + */ + protected function render_navigation() { + echo '
'; + } + /** * Render the content of a vew template * @@ -112,6 +142,7 @@ protected function render_view( string $name ) { * Render the menu */ public function render() { + $this->render_navigation(); $this->render_view( $this->name ); } diff --git a/src/php/admin-menus/class-edit-menu.php b/src/php/admin-menus/class-edit-menu.php index 75e77c86..a8d63874 100644 --- a/src/php/admin-menus/class-edit-menu.php +++ b/src/php/admin-menus/class-edit-menu.php @@ -63,7 +63,7 @@ public function register() { remove_submenu_page( $this->base_slug, $this->slug ); } - // Add New Snippet menu. + // Create New Snippet menu. $this->add_menu( code_snippets()->get_menu_slug( 'add' ), _x( 'Add New', 'menu label', 'code-snippets' ), @@ -110,10 +110,7 @@ protected function ensure_correct_page() { * @return void */ public function render() { - printf( - '
%s
', - esc_html__( 'Loading edit page…', 'code-snippets' ) - ); + echo '
'; } /** @@ -171,14 +168,7 @@ public function enqueue_assets() { wp_enqueue_script( self::JS_HANDLE, plugins_url( 'dist/edit.js', $plugin->file ), - [ - 'code-snippets-code-editor', - 'react', - 'react-dom', - 'wp-url', - 'wp-i18n', - 'wp-components', - ], + [ 'code-snippets-code-editor' ] + self::$script_deps, $plugin->version, true ); @@ -203,7 +193,6 @@ public function enqueue_assets() { 'editorTheme' => get_setting( 'editor', 'theme' ), 'enableDownloads' => apply_filters( 'code_snippets/enable_downloads', true ), 'enableDescription' => $desc_enabled, - 'hideUpsell' => get_setting( 'general', 'hide_upgrade_menu' ), 'tagOptions' => apply_filters( 'code_snippets/tag_editor_options', [ 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..b18e9fa6 100644 --- a/src/php/admin-menus/class-manage-menu.php +++ b/src/php/admin-menus/class-manage-menu.php @@ -2,7 +2,6 @@ namespace Code_Snippets; -use Code_Snippets\Cloud\Cloud_Search_List_Table; use function Code_Snippets\Settings\get_setting; /** @@ -14,18 +13,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 +162,22 @@ 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(); + add_screen_option( + 'per_page', + array( + 'label' => __( 'Snippets per page', 'code-snippets' ), + 'default' => 999, + 'option' => 'snippets_per_page', + ) + ); } /** @@ -190,45 +188,63 @@ 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 ), - [], + self::$style_deps, $plugin->version ); wp_enqueue_script( - 'code-snippets-manage-js', + self::JS_HANDLE, plugins_url( 'dist/manage.js', $plugin->file ), - [ 'wp-i18n' ], + self::$script_deps, $plugin->version, true ); - wp_set_script_translations( 'code-snippets-manage-js', 'code-snippets' ); - - if ( 'cloud' === $this->get_current_type() || 'cloud_search' === $this->get_current_type() ) { - Front_End::enqueue_all_prism_themes(); - } + Front_End::enqueue_all_prism_themes(); + + wp_set_script_translations( self::JS_HANDLE, 'code-snippets' ); + $plugin->localize_script( self::JS_HANDLE ); + + wp_localize_script( + self::JS_HANDLE, + 'CODE_SNIPPETS_MANAGE', + [ + 'hasNetworkCap' => current_user_can( code_snippets()->get_network_cap_name() ), + 'snippetsPerPage' => $this->get_snippets_per_page(), + 'snippetsList' => array_map( + function ( $snippet ) { + return $snippet->get_fields(); + }, + get_snippets() + ), + ] + ); } /** - * Get the currently displayed snippet type. + * Get the number of snippets to show per page. * - * @return string + * @return int */ - 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'; + protected function get_snippets_per_page(): int { + $per_page = (int) get_user_option( 'snippets_per_page' ); + + if ( empty( $per_page ) || $per_page < 1 ) { + $per_page = 999; + } + + return (int) apply_filters( 'snippets_per_page', $per_page ); } /** - * 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() { + echo '
'; } /** @@ -243,101 +259,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/admin-menus/class-settings-menu.php b/src/php/admin-menus/class-settings-menu.php index 1b41dbe2..9f53f195 100644 --- a/src/php/admin-menus/class-settings-menu.php +++ b/src/php/admin-menus/class-settings-menu.php @@ -2,6 +2,7 @@ namespace Code_Snippets; +use function Code_Snippets\Settings\get_settings_fields; use const Code_Snippets\Settings\CACHE_KEY; use const Code_Snippets\Settings\OPTION_GROUP; use const Code_Snippets\Settings\OPTION_NAME; @@ -23,7 +24,6 @@ class Settings_Menu extends Admin_Menu { * Constructor */ public function __construct() { - parent::__construct( 'settings', _x( 'Settings', 'menu label', 'code-snippets' ), @@ -51,16 +51,76 @@ public function load() { * Enqueue the stylesheet for the settings menu */ public function enqueue_assets() { - $plugin = code_snippets(); - - Settings\enqueue_editor_preview_assets(); + $this->enqueue_codemirror(); + $handle = 'code-snippets-settings'; wp_enqueue_style( - 'code-snippets-settings', - plugins_url( 'dist/settings.css', $plugin->file ), - [ 'code-editor' ], - $plugin->version + $handle, + plugins_url( 'dist/settings.css', PLUGIN_FILE ), + self::$style_deps + [ 'code-editor' ], + PLUGIN_VERSION + ); + + wp_enqueue_script( + $handle, + plugins_url( 'dist/settings.js', PLUGIN_FILE ), + self::$script_deps + [ 'code-snippets-code-editor' ], + PLUGIN_VERSION, + true ); + + wp_set_script_translations( $handle, 'code-snippets' ); + code_snippets()->localize_script( $handle ); + + $this->add_codemirror_settings_script( $handle ); + } + + /** + * Enqueue the CodeMirror scripts and styles, including all themes. + * + * @return void + */ + protected function enqueue_codemirror() { + enqueue_code_editor( 'php' ); + $themes = get_editor_themes(); + + foreach ( $themes as $theme ) { + wp_enqueue_style( + 'code-snippets-editor-theme-' . $theme, + plugins_url( "dist/editor-themes/$theme.css", PLUGIN_FILE ), + [ 'code-editor' ], + PLUGIN_VERSION + ); + } + } + + /** + * Load the CodeMirror settings as an inline script variable. + * + * @param string $handle The handle of the script to which the settings will be added. + * + * @return void + */ + protected function add_codemirror_settings_script( string $handle ) { + $setting_fields = get_settings_fields(); + $editor_fields = array(); + + foreach ( $setting_fields['editor'] as $name => $field ) { + if ( empty( $field['codemirror'] ) ) { + continue; + } + + $editor_fields[] = array( + 'name' => $name, + 'type' => $field['type'], + 'codemirror' => addslashes( $field['codemirror'] ), + ); + } + + // Pass the saved options to the external JavaScript file. + $inline_script = 'var code_snippets_editor_settings = ' . wp_json_encode( $editor_fields ) . ';'; + + wp_add_inline_script( $handle, $inline_script, 'before' ); } /** @@ -100,6 +160,8 @@ public function get_current_section( string $default_section = 'general' ): stri * Render the admin screen */ public function render() { + $this->render_navigation(); + $update_url = is_network_admin() ? add_query_arg( 'update_site_option', true ) : admin_url( 'options.php' ); $current_section = $this->get_current_section(); diff --git a/src/php/admin-menus/class-welcome-menu.php b/src/php/admin-menus/class-welcome-menu.php index ceb816ac..3598aaf2 100644 --- a/src/php/admin-menus/class-welcome-menu.php +++ b/src/php/admin-menus/class-welcome-menu.php @@ -26,63 +26,56 @@ public function __construct( $api ) { parent::__construct( 'welcome', _x( "What's New", 'menu label', 'code-snippets' ), - __( 'Welcome to Code Snippets', 'code-snippets' ) + __( 'Resources and Updates', 'code-snippets' ) ); $this->api = $api; } + /** + * Load the welcome menu. + * + * @return void + */ + public function render() { + echo '
'; + } + /** * Enqueue assets necessary for the welcome menu. * * @return void */ public function enqueue_assets() { + $handle = 'code-snippets-welcome'; + wp_enqueue_style( - 'code-snippets-welcome', + $handle, plugins_url( 'dist/welcome.css', PLUGIN_FILE ), - [], + self::$style_deps, PLUGIN_VERSION ); - } - /** - * Retrieve a list of links to display in the page header. - * - * @return array - */ - protected function get_header_links(): array { - $links = [ - 'cloud' => [ - 'url' => 'https://codesnippets.cloud', - 'icon' => 'cloud', - 'label' => __( 'Cloud', 'code-snippets' ), - ], - 'resources' => [ - 'url' => 'https://help.codesnippets.pro/', - 'icon' => 'sos', - 'label' => __( 'Support', 'code-snippets' ), - ], - 'facebook' => [ - 'url' => 'https://www.facebook.com/groups/282962095661875/', - 'icon' => 'facebook', - 'label' => __( 'Community', 'code-snippets' ), - ], - 'discord' => [ - 'url' => 'https://snipco.de/discord', - 'icon' => 'discord', - 'label' => __( 'Discord', 'code-snippets' ), - ], - ]; + wp_enqueue_script( + $handle, + plugins_url( 'dist/welcome.js', PLUGIN_FILE ), + self::$script_deps, + PLUGIN_VERSION, + true + ); - if ( ! code_snippets()->licensing->is_licensed() ) { - $links['pro'] = [ - 'url' => 'https://codesnippets.pro/pricing/', - 'icon' => 'cart', - 'label' => __( 'Upgrade to Pro', 'code-snippets' ), - ]; - } + code_snippets()->localize_script( $handle ); - return $links; + wp_localize_script( + $handle, + 'CODE_SNIPPETS_WELCOME', + [ + 'banner' => $this->api->get_banner(), + 'hero' => $this->api->get_hero_item(), + 'changelog' => $this->api->get_changelog(), + 'features' => $this->api->get_features(), + 'partners' => $this->api->get_partners(), + ] + ); } } diff --git a/src/php/class-admin.php b/src/php/class-admin.php index 8e1413c7..7fc7e199 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 ca5f9043..9b500004 100644 --- a/src/php/class-plugin.php +++ b/src/php/class-plugin.php @@ -98,7 +98,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' ] ); } @@ -134,22 +133,13 @@ public function load_plugin() { $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. * @@ -185,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 ) ) { @@ -360,8 +354,10 @@ 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(), + 'hideUpsell' => Settings\get_setting( 'general', 'hide_upgrade_menu' ), 'restAPI' => [ 'base' => esc_url_raw( rest_url() ), 'snippets' => esc_url_raw( rest_url( Snippets_REST_Controller::get_base_route() ) ), @@ -369,10 +365,12 @@ 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' ) ), + 'welcome' => esc_url_raw( $this->get_menu_url( 'welcome' ) ), + 'settings' => esc_url_raw( $this->get_menu_url( 'settings' ) ), ], ] ); 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/class-welcome-api.php b/src/php/class-welcome-api.php index b31534c5..9691ee5d 100644 --- a/src/php/class-welcome-api.php +++ b/src/php/class-welcome-api.php @@ -231,6 +231,7 @@ protected function build_changelog_data() { $changelog_filename = 'CHANGELOG.md'; $changelog = []; + $section_titles = [ 'Added', 'Changed', 'Deprecated', 'Removed', 'Fixed', 'Security', 'Other' ]; $changelog_dir = plugin_dir_path( PLUGIN_FILE ); while ( plugin_dir_path( $changelog_dir ) !== $changelog_dir && ! $filesystem->exists( $changelog_dir . $changelog_filename ) ) { @@ -251,30 +252,52 @@ protected function build_changelog_data() { continue; } - $header_parts = explode( '(', $sections[0], 2 ); - $version = trim( trim( $header_parts[0] ), '[]' ); - - $changelog[ $version ] = []; + $entries = array_fill_keys( $section_titles, [] ); foreach ( array_slice( $sections, 1 ) as $section_contents ) { $lines = array_filter( array_map( 'trim', explode( "\n", $section_contents ) ) ); $section_type = $lines[0]; + if ( ! isset( $entries[ $section_type ] ) ) { + $section_type = 'Other'; + } + foreach ( array_slice( $lines, 1 ) as $line ) { $entry = trim( str_replace( '(PRO)', '', str_replace( '*', '', $line ) ) ); $core_or_pro = false === strpos( $line, '(PRO)' ) ? 'core' : 'pro'; - if ( ! isset( $changelog[ $version ][ $section_type ] ) ) { - $changelog[ $version ][ $section_type ] = [ - $core_or_pro => [ $entry ], - ]; - } elseif ( ! isset( $changelog[ $version ][ $section_type ][ $core_or_pro ] ) ) { - $changelog[ $version ][ $section_type ][ $core_or_pro ] = [ $entry ]; + $entry = str_replace( '`', '', $entry ); + $entry = preg_replace( '/\[(.+?)]\(.+?\)/', '$1', $entry ); + + if ( ! isset( $entries[ $section_type ][ $core_or_pro ] ) ) { + $entries[ $section_type ][ $core_or_pro ] = [ $entry ]; } else { - $changelog[ $version ][ $section_type ][ $core_or_pro ][] = $entry; + $entries[ $section_type ][ $core_or_pro ][] = $entry; } } } + + $header_parts = explode( '(', $sections[0], 2 ); + $version = trim( trim( $header_parts[0] ), '[]' ); + $date = trim( trim( $header_parts[1] ?? '' ), '()' ); + + try { + $datetime = new DateTimeImmutable( $date ); + $parsed_date = $datetime->format( get_option( 'date_format' ) ); + } catch ( Exception $e ) { + $parsed_date = $date; + } + + $changelog[] = [ + 'version' => $version, + 'date' => $parsed_date, + 'entries' => array_filter( + $entries, + function ( $section ) { + return ! empty( $section ); + } + ), + ]; } $this->welcome_data['changelog'] = $changelog; 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..db8e4acc --- /dev/null +++ b/src/php/rest-api/class-rest-api.php @@ -0,0 +1,128 @@ +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 { + $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/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/settings/editor-preview.php b/src/php/settings/editor-preview.php index ff9f0892..736408e8 100644 --- a/src/php/settings/editor-preview.php +++ b/src/php/settings/editor-preview.php @@ -8,63 +8,11 @@ namespace Code_Snippets\Settings; +use Code_Snippets\Admin_Menu; use function Code_Snippets\code_snippets; use function Code_Snippets\enqueue_code_editor; use function Code_Snippets\get_editor_themes; -/** - * Load the CSS and JavaScript for the editor preview field - */ -function enqueue_editor_preview_assets() { - $plugin = code_snippets(); - - enqueue_code_editor( 'php' ); - - // Enqueue all editor themes. - $themes = get_editor_themes(); - - foreach ( $themes as $theme ) { - wp_enqueue_style( - 'code-snippets-editor-theme-' . $theme, - plugins_url( "dist/editor-themes/$theme.css", $plugin->file ), - [ 'code-editor' ], - $plugin->version - ); - } - - // Enqueue the menu scripts. - wp_enqueue_script( - 'code-snippets-settings-menu', - plugins_url( 'dist/settings.js', $plugin->file ), - [ 'code-snippets-code-editor' ], - $plugin->version, - true - ); - - wp_set_script_translations( 'code-snippets-settings-menu', 'code-snippets' ); - - // Extract the CodeMirror-specific editor settings. - $setting_fields = get_settings_fields(); - $editor_fields = array(); - - foreach ( $setting_fields['editor'] as $name => $field ) { - if ( empty( $field['codemirror'] ) ) { - continue; - } - - $editor_fields[] = array( - 'name' => $name, - 'type' => $field['type'], - 'codemirror' => addslashes( $field['codemirror'] ), - ); - } - - // Pass the saved options to the external JavaScript file. - $inline_script = 'var code_snippets_editor_settings = ' . wp_json_encode( $editor_fields ) . ';'; - - wp_add_inline_script( 'code-snippets-settings-menu', $inline_script, 'before' ); -} - /** * Retrieve the list of code editor themes. * 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 diff --git a/src/php/snippet-ops.php b/src/php/snippet-ops.php index b22689c6..5d9f7fc1 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 ); @@ -549,21 +556,37 @@ function save_snippet( $snippet ) { } $snippet->id = $wpdb->insert_id; - do_action( 'code_snippets/create_snippet', $snippet, $table ); + $updated = get_snippet( $snippet->id ); + do_action( 'code_snippets/create_snippet', $updated, $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; } /** 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/php/views/welcome.php b/src/php/views/welcome.php deleted file mode 100644 index 05316134..00000000 --- a/src/php/views/welcome.php +++ /dev/null @@ -1,200 +0,0 @@ -api->get_hero_item(); - -$changelog_sections = [ - 'Added' => [ - 'title' => __( 'New features', 'code-snippets' ), - 'icon' => 'lightbulb', - ], - 'Improved' => [ - 'title' => __( 'Improvements', 'code-snippets' ), - 'icon' => 'chart-line', - ], - 'Fixed' => [ - 'title' => __( 'Bug fixes', 'code-snippets' ), - 'icon' => 'buddicons-replies', - ], - 'Other' => [ - 'title' => __( 'Other', 'code-snippets' ), - 'icon' => 'open-folder', - ], -]; - -$plugin_types = [ - 'core' => __( 'Core', 'code-snippets' ), - 'pro' => __( 'Pro', 'code-snippets' ), -]; - -?> - - - - 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. 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 - } -}