diff --git a/composer.json b/composer.json
index a79cf4cb0cdefb7e5ba4f768e04c8420ea9f2d8a..98b64178f9372752f296dde89490ce589a8e56ac 100644
--- a/composer.json
+++ b/composer.json
@@ -155,7 +155,7 @@
         "drupal/recaptcha_v3": "^1.4",
         "drupal/redirect": "1.6",
         "drupal/roleassign": "1.0.0-beta1",
-        "drupal/scheduler": "1.3",
+        "drupal/scheduler": "^2.0",
         "drupal/simple_gmap": "3.0.1",
         "drupal/simple_instagram_feed": "^3.11",
         "drupal/simple_sitemap": "3.11",
diff --git a/composer.lock b/composer.lock
index af6ea3061902fcb188d0d1a189fad51600ef4857..824ac13d60d18b628ccde4ef2d0b62c33b291333 100644
--- a/composer.lock
+++ b/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": "00b3339b124e176f69a985ebf27eaa8c",
+    "content-hash": "a7e24a9a9634306f7fd5ad7930d4eaec",
     "packages": [
         {
             "name": "alchemy/zippy",
@@ -6758,39 +6758,40 @@
         },
         {
             "name": "drupal/scheduler",
-            "version": "1.3.0",
+            "version": "2.0.0-rc8",
             "source": {
                 "type": "git",
                 "url": "https://git.drupalcode.org/project/scheduler.git",
-                "reference": "8.x-1.3"
+                "reference": "2.0.0-rc8"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://ftp.drupal.org/files/projects/scheduler-8.x-1.3.zip",
-                "reference": "8.x-1.3",
-                "shasum": "704f9e289c7a42ddfb65297beb0be02e324f02c6"
+                "url": "https://ftp.drupal.org/files/projects/scheduler-2.0.0-rc8.zip",
+                "reference": "2.0.0-rc8",
+                "shasum": "1aa120cd855100fba33aeedb46ce55d8a1acd448"
             },
             "require": {
-                "drupal/core": "^8 || ^9"
+                "drupal/core": "^8 || ^9 || ^10"
             },
             "require-dev": {
-                "drupal/devel_generate": "^2.0 || 3.x-dev",
+                "drupal/commerce": "^2.0",
+                "drupal/devel_generate": ">=4",
                 "drupal/rules": "^3",
-                "drush/drush": "^9.0 || ^10"
+                "drush/drush": ">=9"
             },
             "type": "drupal-module",
             "extra": {
                 "drupal": {
-                    "version": "8.x-1.3",
-                    "datestamp": "1591436219",
+                    "version": "2.0.0-rc8",
+                    "datestamp": "1668951102",
                     "security-coverage": {
-                        "status": "covered",
-                        "message": "Covered by Drupal's security advisory policy"
+                        "status": "not-covered",
+                        "message": "RC releases are not covered by Drupal security advisories."
                     }
                 },
                 "drush": {
                     "services": {
-                        "drush.services.yml": "^9"
+                        "drush.services.yml": "^9 || ^10"
                     }
                 }
             },
@@ -13181,16 +13182,16 @@
         },
         {
             "name": "symfony/dependency-injection",
-            "version": "v4.4.44",
+            "version": "v4.4.49",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/dependency-injection.git",
-                "reference": "25502a57182ba1e15da0afd64c975cae4d0a1471"
+                "reference": "9065fe97dbd38a897e95ea254eb5ddfe1310f734"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/25502a57182ba1e15da0afd64c975cae4d0a1471",
-                "reference": "25502a57182ba1e15da0afd64c975cae4d0a1471",
+                "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/9065fe97dbd38a897e95ea254eb5ddfe1310f734",
+                "reference": "9065fe97dbd38a897e95ea254eb5ddfe1310f734",
                 "shasum": ""
             },
             "require": {
@@ -13247,7 +13248,7 @@
             "description": "Allows you to standardize and centralize the way objects are constructed in your application",
             "homepage": "https://symfony.com",
             "support": {
-                "source": "https://github.com/symfony/dependency-injection/tree/v4.4.44"
+                "source": "https://github.com/symfony/dependency-injection/tree/v4.4.49"
             },
             "funding": [
                 {
@@ -13263,7 +13264,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2022-07-20T09:59:04+00:00"
+            "time": "2022-11-16T16:18:09+00:00"
         },
         {
             "name": "symfony/deprecation-contracts",
@@ -15041,16 +15042,16 @@
         },
         {
             "name": "symfony/psr-http-message-bridge",
-            "version": "v2.1.3",
+            "version": "v2.1.4",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/psr-http-message-bridge.git",
-                "reference": "d444f85dddf65c7e57c58d8e5b3a4dbb593b1840"
+                "reference": "a125b93ef378c492e274f217874906fb9babdebb"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/psr-http-message-bridge/zipball/d444f85dddf65c7e57c58d8e5b3a4dbb593b1840",
-                "reference": "d444f85dddf65c7e57c58d8e5b3a4dbb593b1840",
+                "url": "https://api.github.com/repos/symfony/psr-http-message-bridge/zipball/a125b93ef378c492e274f217874906fb9babdebb",
+                "reference": "a125b93ef378c492e274f217874906fb9babdebb",
                 "shasum": ""
             },
             "require": {
@@ -15109,7 +15110,7 @@
             ],
             "support": {
                 "issues": "https://github.com/symfony/psr-http-message-bridge/issues",
-                "source": "https://github.com/symfony/psr-http-message-bridge/tree/v2.1.3"
+                "source": "https://github.com/symfony/psr-http-message-bridge/tree/v2.1.4"
             },
             "funding": [
                 {
@@ -15125,7 +15126,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2022-09-05T10:34:54+00:00"
+            "time": "2022-11-28T22:46:34+00:00"
         },
         {
             "name": "symfony/routing",
diff --git a/vendor/composer/installed.json b/vendor/composer/installed.json
index f5e38062421407fdffa9af75b8a1c35634088bc0..ea0e62ff1b331430ad0ae1f3b96efe5f2583546b 100644
--- a/vendor/composer/installed.json
+++ b/vendor/composer/installed.json
@@ -7003,40 +7003,41 @@
         },
         {
             "name": "drupal/scheduler",
-            "version": "1.3.0",
-            "version_normalized": "1.3.0.0",
+            "version": "2.0.0-rc8",
+            "version_normalized": "2.0.0.0-RC8",
             "source": {
                 "type": "git",
                 "url": "https://git.drupalcode.org/project/scheduler.git",
-                "reference": "8.x-1.3"
+                "reference": "2.0.0-rc8"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://ftp.drupal.org/files/projects/scheduler-8.x-1.3.zip",
-                "reference": "8.x-1.3",
-                "shasum": "704f9e289c7a42ddfb65297beb0be02e324f02c6"
+                "url": "https://ftp.drupal.org/files/projects/scheduler-2.0.0-rc8.zip",
+                "reference": "2.0.0-rc8",
+                "shasum": "1aa120cd855100fba33aeedb46ce55d8a1acd448"
             },
             "require": {
-                "drupal/core": "^8 || ^9"
+                "drupal/core": "^8 || ^9 || ^10"
             },
             "require-dev": {
-                "drupal/devel_generate": "^2.0 || 3.x-dev",
+                "drupal/commerce": "^2.0",
+                "drupal/devel_generate": ">=4",
                 "drupal/rules": "^3",
-                "drush/drush": "^9.0 || ^10"
+                "drush/drush": ">=9"
             },
             "type": "drupal-module",
             "extra": {
                 "drupal": {
-                    "version": "8.x-1.3",
-                    "datestamp": "1591436219",
+                    "version": "2.0.0-rc8",
+                    "datestamp": "1668951102",
                     "security-coverage": {
-                        "status": "covered",
-                        "message": "Covered by Drupal's security advisory policy"
+                        "status": "not-covered",
+                        "message": "RC releases are not covered by Drupal security advisories."
                     }
                 },
                 "drush": {
                     "services": {
-                        "drush.services.yml": "^9"
+                        "drush.services.yml": "^9 || ^10"
                     }
                 }
             },
@@ -13698,17 +13699,17 @@
         },
         {
             "name": "symfony/dependency-injection",
-            "version": "v4.4.44",
-            "version_normalized": "4.4.44.0",
+            "version": "v4.4.49",
+            "version_normalized": "4.4.49.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/dependency-injection.git",
-                "reference": "25502a57182ba1e15da0afd64c975cae4d0a1471"
+                "reference": "9065fe97dbd38a897e95ea254eb5ddfe1310f734"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/25502a57182ba1e15da0afd64c975cae4d0a1471",
-                "reference": "25502a57182ba1e15da0afd64c975cae4d0a1471",
+                "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/9065fe97dbd38a897e95ea254eb5ddfe1310f734",
+                "reference": "9065fe97dbd38a897e95ea254eb5ddfe1310f734",
                 "shasum": ""
             },
             "require": {
@@ -13739,7 +13740,7 @@
                 "symfony/proxy-manager-bridge": "Generate service proxies to lazy load them",
                 "symfony/yaml": ""
             },
-            "time": "2022-07-20T09:59:04+00:00",
+            "time": "2022-11-16T16:18:09+00:00",
             "type": "library",
             "installation-source": "dist",
             "autoload": {
@@ -13767,7 +13768,7 @@
             "description": "Allows you to standardize and centralize the way objects are constructed in your application",
             "homepage": "https://symfony.com",
             "support": {
-                "source": "https://github.com/symfony/dependency-injection/tree/v4.4.44"
+                "source": "https://github.com/symfony/dependency-injection/tree/v4.4.49"
             },
             "funding": [
                 {
@@ -15627,17 +15628,17 @@
         },
         {
             "name": "symfony/psr-http-message-bridge",
-            "version": "v2.1.3",
-            "version_normalized": "2.1.3.0",
+            "version": "v2.1.4",
+            "version_normalized": "2.1.4.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/psr-http-message-bridge.git",
-                "reference": "d444f85dddf65c7e57c58d8e5b3a4dbb593b1840"
+                "reference": "a125b93ef378c492e274f217874906fb9babdebb"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/psr-http-message-bridge/zipball/d444f85dddf65c7e57c58d8e5b3a4dbb593b1840",
-                "reference": "d444f85dddf65c7e57c58d8e5b3a4dbb593b1840",
+                "url": "https://api.github.com/repos/symfony/psr-http-message-bridge/zipball/a125b93ef378c492e274f217874906fb9babdebb",
+                "reference": "a125b93ef378c492e274f217874906fb9babdebb",
                 "shasum": ""
             },
             "require": {
@@ -15658,7 +15659,7 @@
             "suggest": {
                 "nyholm/psr7": "For a super lightweight PSR-7/17 implementation"
             },
-            "time": "2022-09-05T10:34:54+00:00",
+            "time": "2022-11-28T22:46:34+00:00",
             "type": "symfony-bridge",
             "extra": {
                 "branch-alias": {
@@ -15698,7 +15699,7 @@
             ],
             "support": {
                 "issues": "https://github.com/symfony/psr-http-message-bridge/issues",
-                "source": "https://github.com/symfony/psr-http-message-bridge/tree/v2.1.3"
+                "source": "https://github.com/symfony/psr-http-message-bridge/tree/v2.1.4"
             },
             "funding": [
                 {
diff --git a/vendor/composer/installed.php b/vendor/composer/installed.php
index 1299061afa43b57da0ff0cf677599a7e21c19ec6..12118ae418270720595dbf8ee918ac8fd0619e80 100644
--- a/vendor/composer/installed.php
+++ b/vendor/composer/installed.php
@@ -3,7 +3,7 @@
         'name' => 'osu-asc-webservices/d8-upstream',
         'pretty_version' => 'dev-master',
         'version' => 'dev-master',
-        'reference' => 'ba6af3568d179c683bcb49150597c4f6eb32e14e',
+        'reference' => '7b38d41b8b6ae95e9b0320d434f2220a4367f2d9',
         'type' => 'project',
         'install_path' => __DIR__ . '/../../',
         'aliases' => array(),
@@ -1145,9 +1145,9 @@
             'dev_requirement' => false,
         ),
         'drupal/scheduler' => array(
-            'pretty_version' => '1.3.0',
-            'version' => '1.3.0.0',
-            'reference' => '8.x-1.3',
+            'pretty_version' => '2.0.0-rc8',
+            'version' => '2.0.0.0-RC8',
+            'reference' => '2.0.0-rc8',
             'type' => 'drupal-module',
             'install_path' => __DIR__ . '/../../web/modules/scheduler',
             'aliases' => array(),
@@ -1594,7 +1594,7 @@
         'osu-asc-webservices/d8-upstream' => array(
             'pretty_version' => 'dev-master',
             'version' => 'dev-master',
-            'reference' => 'ba6af3568d179c683bcb49150597c4f6eb32e14e',
+            'reference' => '7b38d41b8b6ae95e9b0320d434f2220a4367f2d9',
             'type' => 'project',
             'install_path' => __DIR__ . '/../../',
             'aliases' => array(),
@@ -2190,9 +2190,9 @@
             'dev_requirement' => false,
         ),
         'symfony/dependency-injection' => array(
-            'pretty_version' => 'v4.4.44',
-            'version' => '4.4.44.0',
-            'reference' => '25502a57182ba1e15da0afd64c975cae4d0a1471',
+            'pretty_version' => 'v4.4.49',
+            'version' => '4.4.49.0',
+            'reference' => '9065fe97dbd38a897e95ea254eb5ddfe1310f734',
             'type' => 'library',
             'install_path' => __DIR__ . '/../symfony/dependency-injection',
             'aliases' => array(),
@@ -2403,9 +2403,9 @@
             'dev_requirement' => false,
         ),
         'symfony/psr-http-message-bridge' => array(
-            'pretty_version' => 'v2.1.3',
-            'version' => '2.1.3.0',
-            'reference' => 'd444f85dddf65c7e57c58d8e5b3a4dbb593b1840',
+            'pretty_version' => 'v2.1.4',
+            'version' => '2.1.4.0',
+            'reference' => 'a125b93ef378c492e274f217874906fb9babdebb',
             'type' => 'symfony-bridge',
             'install_path' => __DIR__ . '/../symfony/psr-http-message-bridge',
             'aliases' => array(),
diff --git a/vendor/symfony/dependency-injection/Compiler/DecoratorServicePass.php b/vendor/symfony/dependency-injection/Compiler/DecoratorServicePass.php
index 3b8086d0931e6c38844e07a886d1c6fb73bf39ef..185a097ebe20bb2b66a4a1ceca23ac0be34efedc 100644
--- a/vendor/symfony/dependency-injection/Compiler/DecoratorServicePass.php
+++ b/vendor/symfony/dependency-injection/Compiler/DecoratorServicePass.php
@@ -42,7 +42,7 @@ public function process(ContainerBuilder $container)
 
         $tagsToKeep = $container->hasParameter('container.behavior_describing_tags')
             ? $container->getParameter('container.behavior_describing_tags')
-            : ['container.do_not_inline', 'container.service_locator', 'container.service_subscriber'];
+            : ['container.do_not_inline', 'container.service_locator', 'container.service_subscriber', 'container.service_subscriber.locator'];
 
         foreach ($definitions as [$id, $definition]) {
             $decoratedService = $definition->getDecoratedService();
diff --git a/vendor/symfony/dependency-injection/Compiler/ServiceLocatorTagPass.php b/vendor/symfony/dependency-injection/Compiler/ServiceLocatorTagPass.php
index 5fdbe5686dbfa81017a0f4101e9592f568df5c81..72b093043bf15cff415a6e315b55f0539f01f011 100644
--- a/vendor/symfony/dependency-injection/Compiler/ServiceLocatorTagPass.php
+++ b/vendor/symfony/dependency-injection/Compiler/ServiceLocatorTagPass.php
@@ -39,6 +39,10 @@ protected function processValue($value, $isRoot = false)
             return self::register($this->container, $value->getValues());
         }
 
+        if ($value instanceof Definition) {
+            $value->setBindings(parent::processValue($value->getBindings()));
+        }
+
         if (!$value instanceof Definition || !$value->hasTag('container.service_locator')) {
             return parent::processValue($value, $isRoot);
         }
diff --git a/vendor/symfony/psr-http-message-bridge/Factory/PsrHttpFactory.php b/vendor/symfony/psr-http-message-bridge/Factory/PsrHttpFactory.php
index 61650df9f680570a249f08b924ec2b8991104871..b1b6f9ae260ff021af0cce236efb03c57e31b81c 100644
--- a/vendor/symfony/psr-http-message-bridge/Factory/PsrHttpFactory.php
+++ b/vendor/symfony/psr-http-message-bridge/Factory/PsrHttpFactory.php
@@ -142,7 +142,7 @@ public function createResponse(Response $symfonyResponse)
                     $stream->write($buffer);
 
                     return '';
-                });
+                }, 1);
 
                 $symfonyResponse->sendContent();
                 ob_end_clean();
diff --git a/web/modules/scheduler/.eslintignore b/web/modules/scheduler/.eslintignore
new file mode 100644
index 0000000000000000000000000000000000000000..a75123a85918ebe5af37836409061755df66a71d
--- /dev/null
+++ b/web/modules/scheduler/.eslintignore
@@ -0,0 +1,7 @@
+# These files fail eslint standards. In due course they will be fixed and then
+# removed from this ignore file.
+# See https://www.drupal.org/project/scheduler/issues/3314451
+drupalci.yml
+js/scheduler_default_time.js
+js/scheduler_default_time_8x.js
+js/scheduler_vertical_tabs.js
\ No newline at end of file
diff --git a/web/modules/scheduler/.eslintrc.json b/web/modules/scheduler/.eslintrc.json
new file mode 100644
index 0000000000000000000000000000000000000000..009abb96c91fbe07fbc2fe924112bca6de473a61
--- /dev/null
+++ b/web/modules/scheduler/.eslintrc.json
@@ -0,0 +1,7 @@
+// This file does not strictly need to exist in contrib, because eslint config
+// files placed futher up in the directory structure will be merged and
+// inherited. The top-level .eslintrc.json extends ./core/.eslintrc.json.
+// Drupalci eslint does not have a 'sniff all files' argument, but having this
+// in a patch or MR will cause all files to be checked.
+// See https://www.drupal.org/project/scheduler/issues/3314451
+{}
diff --git a/web/modules/scheduler/.prettierignore b/web/modules/scheduler/.prettierignore
new file mode 100644
index 0000000000000000000000000000000000000000..47a89b805332bc0a733f0adca9901ab75b16e645
--- /dev/null
+++ b/web/modules/scheduler/.prettierignore
@@ -0,0 +1,2 @@
+# Drupal ./core/.prettierignore has *.yml so contrib can do likewise.
+*.yml
diff --git a/web/modules/scheduler/.prettierrc.json b/web/modules/scheduler/.prettierrc.json
new file mode 100644
index 0000000000000000000000000000000000000000..7156dade30ff3b4024d03f3eac6ab3cafff1b402
--- /dev/null
+++ b/web/modules/scheduler/.prettierrc.json
@@ -0,0 +1,7 @@
+{
+  "_comment": "Duplicate what is in core/.prettierrc.json",
+  "printWidth": 80,
+  "semi": true,
+  "singleQuote": true,
+  "trailingComma": "all"
+}
diff --git a/web/modules/scheduler/.travis.yml b/web/modules/scheduler/.travis.yml
index 9fd0d40e689a6e168edf6238abbbcad68ef3d690..af1c8de9b508b3a9e646bbf71c6c3b9faf2399a5 100644
--- a/web/modules/scheduler/.travis.yml
+++ b/web/modules/scheduler/.travis.yml
@@ -1,30 +1,56 @@
 language: php
-
-# The Travis CI container mode has random functional test fails, so we must use
-# sudo here.
-sudo: true
-
-php:
-  - 7.1
-  - 7.3
+os: linux
+dist: xenial
 
 services:
   - mysql
 
 env:
   global:
+    # Make the script re-usable for other modules.
     - MODULE=scheduler
-    # Allow this many deprecation warnings before failing the build.
-    - SYMFONY_DEPRECATIONS_HELPER=4
-  matrix:
-    - DRUPAL_CORE=8.8.x
-    - DRUPAL_CORE=8.9.x
+    # Initialise the real SYMFONY_DEPRECATIONS_HELPER variable.
+    - SYMFONY_DEPRECATIONS_HELPER=0
+    # Create a default for the allowed deprecations per branch.
+    - DEPRECATIONS=0
 
-matrix:
+jobs:
   fast_finish: true
-  exclude:
-    - php: 7.1
-      env: DRUPAL_CORE=8.9.x
+  include:
+    - php: 7.4
+      env:
+        - DRUPAL_CORE=9.5.x
+        # Run the Node and Product tests, with Rules included.
+        - NODE=YES
+        - PRODUCT=YES
+        - RULES=YES
+        # ---- Remaining self deprecation notices (0)
+        # ---- Other deprecation notices (0)
+        - DEPRECATIONS=0
+    - php: 8.1
+      env:
+        - DRUPAL_CORE=9.5.x
+        - MEDIA=YES
+        - TAXONOMY=YES
+        # ---- Unsilenced deprecation notices (6)
+        #    6 strlen(): Passing null to parameter #1 ($string) is deprecated (SchedulerDrushTest)
+        # ---- Remaining self deprecation notices (0)
+        # ---- Other deprecation notices (0)
+        - DEPRECATIONS=6
+    - php: 8.1
+      env:
+        # Run the Node tests only.
+        - DRUPAL_CORE=10.0.x
+        - NODE=YES
+        # ---- Remaining self deprecation notices (4)
+        #    2 Behat\Mink\Element\ElementInterface::getText() might add "string"
+        #    2 Behat\Mink\Element\ElementInterface::waitFor()" might add "mixed"
+        # ---- Remaining direct deprecation notices (3)
+        #    1 PHPUnit\TextUI\DefaultResultPrinter class is considered internal
+        #    2 Drupal\Tests\Listeners\DrupalListener
+        # ---- Other deprecation notices (2)
+        #    2 PHPUnit\Framework\TestCase::addWarning() method is considered internal
+        - DEPRECATIONS=9
 
 # Be sure to cache composer downloads.
 cache:
@@ -32,6 +58,9 @@ cache:
     - $HOME/.composer
 
 before_script:
+  # At job start-up Composer is installed at 1.8.4 then self-update is run. From
+  # 24 October 2020 this bumped the version to Composer 2.
+  - composer --version
   - echo $MODULE
 
   # Remove Xdebug as we don't need it and it causes
@@ -39,8 +68,8 @@ before_script:
   # We also don't care if that file exists or not on PHP 7.
   - phpenv config-rm xdebug.ini || true
 
-  # Navigate out of module directory to prevent blown stack by recursive module
-  # lookup.
+  # Navigate up out of $TRAVIS_BUILD_DIR to prevent blown stack on recursive module lookup.
+  - pwd
   - cd ..
 
   # Create database.
@@ -59,20 +88,31 @@ before_script:
   - mkdir $DRUPAL_ROOT/modules/$MODULE
   - cp -R $TRAVIS_BUILD_DIR/* $DRUPAL_ROOT/modules/$MODULE/
 
-  # Get the latest dev versions of the test dependency modules.
-  - travis_retry git clone --branch 8.x-3.x --depth 1 https://git.drupalcode.org/project/rules.git modules/rules
-  - travis_retry git clone --branch 4.x     --depth 1 https://git.drupalcode.org/project/devel.git modules/devel
-  - travis_retry git clone --branch 8.x-1.x --depth 1 https://git.drupalcode.org/project/typed_data.git modules/typed_data
+  # Get the latest dev versions of some of the test dependency modules.
+  - travis_retry git clone --branch 8.x-1.x --depth 1 https://git.drupalcode.org/project/workbench_moderation.git modules/workbench_moderation
+  - travis_retry git clone --branch 8.x-1.x --depth 1 https://git.drupalcode.org/project/workbench_moderation_actions.git modules/workbench_moderation_actions
+  - |
+    if [ "$RULES" == "YES" ]; then
+      echo "Installing Rules and Typed Data ..."
+      travis_retry git clone --branch 8.x-3.x --depth 1 https://git.drupalcode.org/project/rules.git modules/rules
+      travis_retry git clone --branch 8.x-1.x --depth 1 https://git.drupalcode.org/project/typed_data.git modules/typed_data
+    fi
 
-  # Run composer self-update and install.
-  - travis_retry composer self-update && travis_retry composer install
+  # Install the site dependencies via Composer.
+  - travis_retry composer install
 
-  # Install drush
-  - travis_retry composer require drush/drush:"^9.0 || ^10.0"
+  # Install the other testing dependencies via Composer.
+  - travis_retry composer require drupal/devel:"^4 || ^5"
+  - travis_retry composer require drush/drush:"^9 || ^10 || ^11"
+  - travis_retry composer require drupal/commerce
 
   # Coder is already installed as part of composer install. We just need to set
-  # the installed_paths to pick up the Drupal standards.
-  - $DRUPAL_ROOT/vendor/bin/phpcs --config-set installed_paths $DRUPAL_ROOT/vendor/drupal/coder/coder_sniffer
+  # the installed_paths to pick up the Drupal standards. This is only for Coder
+  # up to version 8.3.13. From 8.3.14 onwards this is done at install time.
+  - |
+    if [[ "$DRUPAL_CORE" == "8.9.x" || "$DRUPAL_CORE" == "9.2.x" || "$DRUPAL_CORE" == "9.3.x" ]]; then
+      $DRUPAL_ROOT/vendor/bin/phpcs --config-set installed_paths $DRUPAL_ROOT/vendor/drupal/coder/coder_sniffer
+    fi
 
   # Start a web server on port 8888, run in the background.
   - php -S localhost:8888 &
@@ -80,26 +120,52 @@ before_script:
   # Export web server URL for browser tests.
   - export SIMPLETEST_BASE_URL=http://localhost:8888
 
-  # TEMP - Patch Scheduler to cater for latest Rules and Typed Data changes.
-  # See https://www.drupal.org/project/scheduler/issues/3101377
-  - cd $DRUPAL_ROOT/modules/$MODULE
-  - wget -q -O - https://www.drupal.org/files/issues/2019-12-18/3101377-7.context_definitions.patch | patch -p1
+  # Get the allowed number of deprecation warnings.
+  - SYMFONY_DEPRECATIONS_HELPER=$DEPRECATIONS || $SYMFONY_DEPRECATIONS_HELPER
+  - echo $SYMFONY_DEPRECATIONS_HELPER
 
 script:
+  - echo "NODE=$NODE MEDIA=$MEDIA PRODUCT=$PRODUCT TAXONOMY=$TAXONOMY RULES=$RULES"
+  # By default the specific entity type tests will be excluded unless explicitly
+  # included via a YES variable value.
+  - EXCLUDE=()
+  - if [ "$NODE" != "YES" ]; then EXCLUDE+=('node|HooksLegacy|Multilingual|WorkbenchModeration|Migrate'); fi
+  - if [ "$MEDIA" != "YES" ]; then EXCLUDE+=('media'); fi
+  - if [ "$PRODUCT" != "YES" ]; then EXCLUDE+=('product'); fi
+  - if [ "$TAXONOMY" != "YES" ]; then EXCLUDE+=('taxonomy'); fi
+  - if [ "$RULES" != "YES" ]; then EXCLUDE+=('rules'); fi
+  - if [ "$DRUPAL_CORE" == "10.0.x" ]; then EXCLUDE+=('HooksLegacy|WorkbenchModeration'); fi
+  - EXCLUDE=${EXCLUDE[@]}     # create a space delimited string from array
+
   # Run the PHPUnit tests.
   - cd $DRUPAL_ROOT
-  - ./vendor/bin/phpunit -c ./core/phpunit.xml.dist ./modules/$MODULE/tests/;
+  - |
+    if [ "$EXCLUDE" != "" ]; then
+      echo "Running tests excluding $EXCLUDE ..."
+      export FILTER="/^((?!(${EXCLUDE// /|})).)*$/i"   # use parameter expansion to substitute spaces with |
+      echo "FILTER=$FILTER"
+      ./vendor/bin/phpunit -c ./core/phpunit.xml.dist --debug ./modules/$MODULE/ --filter "$FILTER"
+    else
+      echo "Running all tests ..."
+      ./vendor/bin/phpunit -c ./core/phpunit.xml.dist --debug ./modules/$MODULE/
+    fi
+  # Display the parameters again at the end of the test run.
+  - echo "NODE=$NODE MEDIA=$MEDIA PRODUCT=$PRODUCT TAXONOMY=$TAXONOMY RULES=$RULES"
+  - echo "EXCLUDE=$EXCLUDE"
+
+  # Check for coding standards. First show the versions.
+  - composer show drupal/coder | egrep 'name |vers'
+  - composer show squizlabs/php_codesniffer | egrep 'name |vers'
+  - $DRUPAL_ROOT/vendor/bin/phpcs --version
+  - $DRUPAL_ROOT/vendor/bin/phpcs --config-show installed_paths
+
 
-  # Check for coding standards. First change directory to our module.
+  # Change into $MODULE directory to avoid having to add --standard=$DRUPAL_ROOT/modules/$MODULE/phpcs.xml.dist
   - cd $DRUPAL_ROOT/modules/$MODULE
 
-  # List all the sniffs that were used.
-  - $DRUPAL_ROOT/vendor/bin/phpcs --version
+  # List the standards and the sniffs that are used.
   - $DRUPAL_ROOT/vendor/bin/phpcs -i
   - $DRUPAL_ROOT/vendor/bin/phpcs -e
 
-  # Show the violations in detail and do not fail for any errors or warnings.
-  - $DRUPAL_ROOT/vendor/bin/phpcs --runtime-set ignore_warnings_on_exit 1 --runtime-set ignore_errors_on_exit 1
-
-  # Run again to give a summary and totals, and fail for errors and warnings.
-  - $DRUPAL_ROOT/vendor/bin/phpcs --report=summary
+  # Show the violations in detail, plus summary and source report.
+  - $DRUPAL_ROOT/vendor/bin/phpcs . --report-full --report-summary --report-source -s;
diff --git a/web/modules/scheduler/.tugboat/config.yml b/web/modules/scheduler/.tugboat/config.yml
new file mode 100644
index 0000000000000000000000000000000000000000..811f7e86d75f5fcad1e2834d7de9549043d1d9a8
--- /dev/null
+++ b/web/modules/scheduler/.tugboat/config.yml
@@ -0,0 +1,190 @@
+services:
+  php:
+    image: q0rban/tugboat-drupal:10.0
+    default: true
+    http: false
+    depends: mysql
+    commands:
+      init: |
+        # Install the bcmath extension, required for commerce_product
+        docker-php-ext-install bcmath
+        # JPEG support is not included by default, but it is needed when generating content.
+        docker-php-ext-configure gd --enable-gd --with-jpeg
+        docker-php-ext-install gd
+
+      update: |
+        set -eux
+
+        # Check out a branch using the unique Tugboat ID for this repository, to
+        # ensure we don't clobber an existing branch.
+        git checkout -b $TUGBOAT_REPO_ID
+
+        # Composer is hungry. You need a Tugboat project with a pretty sizeable
+        # chunk of memory.
+        export COMPOSER_MEMORY_LIMIT=-1
+
+        # This is an environment variable we added in the Dockerfile that
+        # provides the path to Drupal composer root (not the web root).
+        cd $DRUPAL_COMPOSER_ROOT
+
+        # We configure the Drupal project to use the checkout of the module as a
+        # Composer package repository.
+        composer config repositories.tugboat vcs $TUGBOAT_ROOT
+
+        # Now we can require this module, specifing the branch name we created
+        # above that uses the $TUGBOAT_REPO_ID environment variable.
+        composer require drupal/scheduler:dev-$TUGBOAT_REPO_ID
+
+        # Install Drupal on the site.
+        vendor/bin/drush \
+          --yes \
+          --db-url=mysql://tugboat:tugboat@mysql:3306/tugboat \
+          --site-name="${TUGBOAT_PREVIEW_NAME}" \
+          --account-pass=admin \
+          site:install standard
+
+        # Show site status and GD image support status.
+        vendor/bin/drush status-report
+        vendor/bin/drush php:eval 'phpinfo()' | grep GD
+        vendor/bin/drush php:eval 'print "imagepng() " . (function_exists("imagepng") ? "--yes\n" : "--no\n"); '
+        vendor/bin/drush php:eval 'print "imagejpeg() " . (function_exists("imagejpeg") ? "--yes\n" : "--no\n"); '
+        vendor/bin/drush php:eval 'print_r(gd_info());'
+
+        # Commerce 2.29 needs inline_entity_form which does not have a stable
+        # version as at March 2022. Therefore get that package here allowing RC
+        # to avoid the "does not match your minimum-stability" problem.
+        composer require drupal/inline_entity_form ^1.0@RC
+        composer require drupal/commerce
+
+        # Get other useful modules.
+        composer require drupal/devel_generate
+
+        # These modules are not compatible with Drupal 10 (yet) so only get them
+        # when running Drupal 9.
+        vendor/bin/drush core:status | awk "NR==1{print \$2\$3\$4}"
+        vendor/bin/drush core:status | awk "NR==1{print \$2\$3\$4}" | \
+          grep version:9 && composer require drupal/module_filter drupal/workbench_moderation \
+          drupal/workbench_moderation_actions:1.x-dev drupal/admin_toolbar
+
+        # Set up the files directory permissions.
+        mkdir -p $DRUPAL_DOCROOT/sites/default/files
+        chgrp -R www-data $DRUPAL_DOCROOT/sites/default/files
+        chmod 2775 $DRUPAL_DOCROOT/sites/default/files
+        chmod -R g+w $DRUPAL_DOCROOT/sites/default/files
+
+        # Enable modules.
+        vendor/bin/drush --yes pm:enable scheduler devel devel_generate media commerce_product
+
+        # Enable modules that are only available at Drupal 9.
+        vendor/bin/drush core:status | awk "NR==1{print \$2\$3\$4}" | \
+          grep version:9 && vendor/bin/drush --yes pm:enable module_filter admin_toolbar
+
+        # Show versions.
+        composer show drupal/scheduler | egrep 'name |vers'
+        vendor/bin/drush pml | grep scheduler
+        composer show drupal/devel | egrep 'name |vers'
+        vendor/bin/drush pml | grep devel
+
+        # Scheduler general settings.
+        vendor/bin/drush -y config-set scheduler.settings allow_date_only 1
+        vendor/bin/drush -y config-set scheduler.settings default_time '22:06:00'
+        vendor/bin/drush config-get scheduler.settings
+
+        # Scheduler content settings.
+        vendor/bin/drush -y config-set node.type.article third_party_settings.scheduler.publish_enable 1
+        vendor/bin/drush -y config-set node.type.article third_party_settings.scheduler.unpublish_enable 1
+        vendor/bin/drush -y config-set node.type.article third_party_settings.scheduler.expand_fieldset 'always'
+        vendor/bin/drush -y config-set node.type.article third_party_settings.scheduler.fields_display_mode 'fieldset'
+        vendor/bin/drush -y config-set node.type.article third_party_settings.scheduler.publish_past_date 'schedule'
+        vendor/bin/drush config-get node.type.article third_party_settings
+
+        # Scheduler media settings.
+        vendor/bin/drush -y config-set media.type.image third_party_settings.scheduler.publish_enable 1
+        vendor/bin/drush -y config-set media.type.image third_party_settings.scheduler.unpublish_enable 1
+        vendor/bin/drush -y config-set media.type.image third_party_settings.scheduler.expand_fieldset 'always'
+        vendor/bin/drush -y config-set media.type.image third_party_settings.scheduler.fields_display_mode 'fieldset'
+        vendor/bin/drush -y config-set media.type.image third_party_settings.scheduler.publish_past_date 'schedule'
+        vendor/bin/drush config-get media.type.image third_party_settings
+
+        # Scheduler commerce product settings.
+        vendor/bin/drush -y config-set commerce_product.commerce_product_type.default third_party_settings.scheduler.publish_enable 1
+        vendor/bin/drush -y config-set commerce_product.commerce_product_type.default third_party_settings.scheduler.unpublish_enable 1
+        vendor/bin/drush -y config-set commerce_product.commerce_product_type.default third_party_settings.scheduler.publish_past_date 'schedule'
+        vendor/bin/drush -y config-set commerce_product.commerce_product_type.default third_party_settings.scheduler.expand_fieldset 'always'
+        vendor/bin/drush -y config-set commerce_product.commerce_product_type.default third_party_settings.scheduler.fields_display_mode 'fieldset'
+        vendor/bin/drush config-get commerce_product.commerce_product_type.default third_party_settings
+
+        # Media settings.
+        vendor/bin/drush -y config-set media.settings standalone_url 1
+        vendor/bin/drush config-get media.settings
+        vendor/bin/drush -y config-set field.field.media.image.field_media_image required 0
+        vendor/bin/drush config-get field.field.media.image.field_media_image
+
+        # Create roles for each of the scheduler user permissions.
+        vendor/bin/drush role-create 'my_content_editor' 'Content Editor'
+        vendor/bin/drush role-add-perm 'my_content_editor' 'schedule publishing of nodes'
+        vendor/bin/drush role-create 'content_viewer' 'Content Viewer'
+        vendor/bin/drush role-add-perm 'content_viewer' 'view scheduled content'
+        vendor/bin/drush role-create 'media_editor' 'Media Editor'
+        vendor/bin/drush role-add-perm 'media_editor' 'schedule publishing of media'
+        vendor/bin/drush role-create 'media_viewer' 'Media Viewer'
+        vendor/bin/drush role-add-perm 'media_viewer' 'view scheduled media'
+        vendor/bin/drush role-create 'product_editor' 'Product Editor'
+        vendor/bin/drush role-add-perm 'product_editor' 'schedule publishing of commerce_product'
+        vendor/bin/drush role-create 'product_viewer' 'Product Viewer'
+        vendor/bin/drush role-add-perm 'product_viewer' 'view scheduled commerce_product'
+
+        # Add some permissions for all authenticated users.
+        vendor/bin/drush role-add-perm 'authenticated' \
+          "create article content, edit any article content, delete any article content, \
+           access content overview, view own unpublished content, switch users"
+        vendor/bin/drush role-add-perm 'authenticated' \
+          "create media, update any media, delete any media, access media overview, view own unpublished media"
+        vendor/bin/drush role-add-perm 'authenticated' \
+          "create default commerce_product, update any default commerce_product, \
+           delete any default commerce_product, access commerce_product overview, \
+           view own unpublished commerce_product, administer commerce_store"
+
+        # Create users and give them roles.
+        vendor/bin/drush user-create 'Eddy content editor'
+        vendor/bin/drush user-add-role 'my_content_editor' 'Eddy content editor'
+        vendor/bin/drush user-create 'Vera content viewer'
+        vendor/bin/drush user-add-role 'content_viewer' 'Vera content viewer'
+        vendor/bin/drush user-create 'Madeline media editor'
+        vendor/bin/drush user-add-role 'media_editor' 'Madeline media editor'
+        vendor/bin/drush user-create 'Marvin media viewer'
+        vendor/bin/drush user-add-role 'media_viewer' 'Marvin media viewer'
+        vendor/bin/drush user-create 'Prodie product editor'
+        vendor/bin/drush user-add-role 'product_editor' 'Prodie product editor'
+        vendor/bin/drush user-create 'Proctor product viewer'
+        vendor/bin/drush user-add-role 'product_viewer' 'Proctor product viewer'
+
+        # Generate content.
+        vendor/bin/drush devel-generate-content 3 --bundles=article --authors=1,2 --verbose
+        vendor/bin/drush devel-generate-content 3 --bundles=page --authors=1,2 --verbose
+        vendor/bin/drush devel-generate-media 3 --media-types=document --verbose
+        vendor/bin/drush devel-generate-media 3 --media-types=image --verbose
+
+        # @todo Place the 'Switch users' block in first sidebar.
+        # @todo Add 'content overview' and 'media overview' to tools menu.
+        # @todo Create a store for products. Then create some products.
+        # @todo Generate a vocabulary and some terms. Create users for taxonomy.
+        # @todo page entity type should not be promoted to front page.
+        # @todo Create a menu of links to the pages?
+
+      build: |
+        set -eux
+
+        # Delete and re-check out this branch in case this is built from a Base Preview.
+        git branch -D $TUGBOAT_REPO_ID && git checkout -b $TUGBOAT_REPO_ID || true
+        export COMPOSER_MEMORY_LIMIT=-1
+        cd $DRUPAL_COMPOSER_ROOT
+        composer install --optimize-autoloader
+
+        # Update this module, including all dependencies.
+        composer update drupal/scheduler --with-all-dependencies
+        vendor/bin/drush --yes updb
+        vendor/bin/drush cache:rebuild
+
+  mysql:
+    image: tugboatqa/mariadb
diff --git a/web/modules/scheduler/README.md b/web/modules/scheduler/README.md
index 0e65c3ad0c90fcfbab705c094b3f1b79ff41c9ae..94b6961fbb2451403332588face26fc3ac6f6453 100644
--- a/web/modules/scheduler/README.md
+++ b/web/modules/scheduler/README.md
@@ -2,18 +2,20 @@
 
 [![Build Status](https://travis-ci.org/jonathan1055/scheduler.svg?branch=8.x-1.x)](https://travis-ci.org/jonathan1055/scheduler)
 
-Scheduler gives content editors the ability to schedule nodes to be published
+Scheduler gives website editors the ability to schedule content to be published
 and unpublished at specified dates and times in the future.
 
-Scheduler provides hooks and events for third-party modules to interact with
-the processing during node edit and during cron publishing and unpublishing.
+Scheduler provides hooks and events for third-party modules to interact with the
+process during content editing and during cron publishing and unpublishing.
 
-For a fuller description of the module, visit the [project page on Drupal.org](https://drupal.org/project/scheduler)
+A plugin system allows support for any Drupal entity type that has the concept
+of a 'published' status. As at Scheduler version 2.0 Node content, Media
+entities, Commerce Products and Taxonomy Terms are supported.
 
 ## Requirements
 
- * Scheduler uses the following Drupal 8 Core components:
-     Actions, Datetime, Field, Node, Text, Filter, User, System, Views.
+ * Scheduler uses the following Drupal 8 Core components: Actions, Datetime,
+   Field, Node, Text, Filter, User, System, Views.
 
  * There are no special requirements outside core.
 
@@ -34,44 +36,65 @@ For a fuller description of the module, visit the [project page on Drupal.org](h
      If you use core Content Moderation then you should also install this
      sub-module, contributed by the folks at [Thunder](https://www.drupal.org/thunder)
 
+ * [Media](https://www.drupal.org/docs/8/core/modules/media):
+     Core media items can be scheduled for publishing and unpublishing.
+
+ * [Commerce](https://www.drupal.org/project/commerce):
+     Commerce products can be scheduled for publishing and unpublishing.
+
+ * [Taxonomy](https://www.drupal.org/docs/8/core/modules/taxonomy):
+     Core taxonomy terms can be scheduled for publishing and unpublishing.
+
 ## Installation
 
  * Install as you would normally install a contributed Drupal module. See:
      https://drupal.org/documentation/install/modules-themes/modules-8
      for further information.
 
+ * The [Scheduler project page on Drupal.org](https://drupal.org/project/scheduler)
+   has information regarding versions and Core compatibility.
+
 ## Configuration
 
  * Configure user permissions via url /admin/people/permissions#module-scheduler
    or Administration » People » Permissions
 
-   - View scheduled content list
+   - "Schedule publishing and unpublishing of {type}"
+
+     Users with this permission can enter dates and times for publishing and/or
+     unpublishing, when editing content of types which are Scheduler-enabled.
+
+   - "View scheduled {type}"
 
      Users can always see their own scheduled content, via a tab on their user
      page. This permissions grants additional authority to see the full list of
      scheduled content by any author, providing the user also has the core
-     permission 'access content overview'.
-
-   - Schedule content publication
+     permission 'access content overview' or the equivalent for other entity
+     types.
 
-     Users with this permission can enter dates and times for publishing and/or
-     unpublishing, when editing nodes of types which are Scheduler-enabled.
-
-   - Administer scheduler
+   - "Administer scheduler"
 
      This permission allows the user to alter all Scheduler settings. It should
      therefore only be given to trusted admin roles.
 
  * Configure the Scheduler global options via /admin/config/content/scheduler
-   or Administration » Configuration » Content Authoring
+   or Administration » Configuration » Content Authoring » Scheduler
+
+   - Basic settings: allow a date only and set a default time.
 
-   - Basic settings for date format, allowing date only, setting default time.
+   - Lightweight Cron: This gives sites admins the granularity to run
+     Scheduler's functions only on more frequent crontab jobs than the full
+     Drupal cron run.
 
-   - Lightweight Cron, which gives sites admins the granularity to run
-     Scheduler's functions only, on more frequent crontab jobs.
+ * Configure the Scheduler settings per entity type:
+   - Administration » Structure » Content Types » Edit
+   - Administration » Structure » Media Types » Edit
+   - Administration » Commerce » Configuration » Product Types » Edit
+   - Administration » Structure » Taxonomy » Vocabulary » Edit
 
- * Configure the Scheduler settings per content type via /admin/structure/types
-     or Administration » Structure » Content Types » Edit
+ * The system status report at /admin/reports/status has a Scheduler Timecheck
+   section, giving details of the server time, default site time and current
+   user time.
 
 ## Troubleshooting
 
diff --git a/web/modules/scheduler/composer.json b/web/modules/scheduler/composer.json
index ce41e044ec218b0ab06ab00ce913bebc70f942fb..d835ab3c546c324fcf0dcf6f8f4050688ae22b0e 100644
--- a/web/modules/scheduler/composer.json
+++ b/web/modules/scheduler/composer.json
@@ -6,8 +6,9 @@
     "homepage": "https://drupal.org/project/scheduler",
     "require-dev": {
         "drupal/rules": "^3",
-        "drush/drush": "^9.0 || ^10",
-        "drupal/devel_generate": "^2.0 || 3.x-dev"
+        "drush/drush": ">=9",
+        "drupal/devel_generate": ">=4",
+        "drupal/commerce": "^2.0"
     },
     "repositories": {
         "drupal": {
@@ -44,7 +45,7 @@
     "extra": {
         "drush": {
             "services": {
-                "drush.services.yml": "^9"
+                "drush.services.yml": "^9 || ^10"
             }
         }
     }
diff --git a/web/modules/scheduler/config/install/scheduler.settings.yml b/web/modules/scheduler/config/install/scheduler.settings.yml
index b9941202fdcae808e85a3e74bc826201dbb1ba3c..505fc56007c268841d2e1d6e7db15ceb8fbc694f 100644
--- a/web/modules/scheduler/config/install/scheduler.settings.yml
+++ b/web/modules/scheduler/config/install/scheduler.settings.yml
@@ -16,6 +16,7 @@ default_unpublish_enable: false
 default_unpublish_required: false
 default_unpublish_revision: false
 lightweight_cron_access_key: ''
+hide_seconds: false
 log: true
 time_letters: 'hHgGisaA'
 time_only_format: 'H:i:s'
diff --git a/web/modules/scheduler/config/install/views.view.scheduler_scheduled_content.yml b/web/modules/scheduler/config/install/views.view.scheduler_scheduled_content.yml
index 98549cb439f871d17a4091df0bb504db415b6157..12f37aef61689093e8dec7435942db99945dad2e 100644
--- a/web/modules/scheduler/config/install/views.view.scheduler_scheduled_content.yml
+++ b/web/modules/scheduler/config/install/views.view.scheduler_scheduled_content.yml
@@ -1,23 +1,31 @@
 langcode: en
 status: true
 dependencies:
+  config:
+    - system.menu.admin
   module:
     - node
-    - scheduler
     - user
+  enforced:
+    module:
+      - scheduler
 id: scheduler_scheduled_content
-label: 'Scheduled content'
+label: 'Scheduled Content'
 module: views
 description: 'Find and manage scheduled content.'
 tag: ''
 base_table: node_field_revision
 base_field: vid
-core: 8.x
 display:
+  # ----------------------------------------------------------------------------
+  # Default display
+  # ----------------------------------------------------------------------------
   default:
     display_options:
       access:
-        type: scheduler
+        type: perm
+        options:
+          perm: 'view scheduled content'
       cache:
         type: tag
         options: {  }
@@ -637,6 +645,8 @@ display:
               anonymous: '0'
               authenticated: '0'
               administrator: '0'
+            operator_limit_selection: false
+            operator_list: {  }
           is_grouped: false
           group_info:
             label: ''
@@ -677,6 +687,8 @@ display:
               authenticated: '0'
               administrator: '0'
             placeholder: ''
+            operator_limit_selection: false
+            operator_list: {  }
           is_grouped: false
           group_info:
             label: ''
@@ -718,6 +730,8 @@ display:
               authenticated: '0'
               administrator: '0'
             reduce: false
+            operator_limit_selection: false
+            operator_list: {  }
           is_grouped: false
           group_info:
             label: ''
@@ -758,6 +772,8 @@ display:
               anonymous: '0'
               authenticated: '0'
               administrator: '0'
+            operator_limit_selection: false
+            operator_list: {  }
           is_grouped: true
           group_info:
             label: 'Published status'
@@ -807,6 +823,8 @@ display:
               authenticated: '0'
               administrator: '0'
             reduce: false
+            operator_limit_selection: false
+            operator_list: {  }
           is_grouped: false
           group_info:
             label: ''
@@ -854,6 +872,8 @@ display:
               anonymous: '0'
               authenticated: '0'
               administrator: '0'
+            operator_limit_selection: false
+            operator_list: {  }
           is_grouped: false
           group_info:
             label: ''
@@ -901,6 +921,8 @@ display:
               anonymous: '0'
               authenticated: '0'
               administrator: '0'
+            operator_limit_selection: false
+            operator_list: {  }
           is_grouped: false
           group_info:
             label: ''
@@ -972,17 +994,20 @@ display:
         - 'languages:language_interface'
         - url
         - url.query_args
-        - user
         - 'user.node_grants:view'
+        - user.permissions
       max-age: 0
       tags: {  }
+  # ----------------------------------------------------------------------------
+  # Overview
+  # ----------------------------------------------------------------------------
   overview:
     display_options:
       path: admin/content/scheduled
       menu:
-        type: tab
-        title: Scheduled
-        description: ''
+        type: normal
+        title: 'Scheduled Content'
+        description: 'Content that is scheduled for publishing or unpublishing'
         expanded: false
         parent: system.admin_content
         weight: -10
@@ -1007,10 +1032,13 @@ display:
         - 'languages:language_interface'
         - url
         - url.query_args
-        - user
         - 'user.node_grants:view'
+        - user.permissions
       max-age: 0
       tags: {  }
+  # ----------------------------------------------------------------------------
+  # User page
+  # ----------------------------------------------------------------------------
   user_page:
     display_options:
       path: user/%user/scheduled
@@ -1018,7 +1046,7 @@ display:
         type: tab
         title: Scheduled
         description: ''
-        parent: system.admin_content
+        parent: user.page
         weight: -10
         context: '0'
         menu_name: admin
@@ -1034,7 +1062,7 @@ display:
         filters: true
         filter_groups: true
         arguments: false
-        access: true
+        access: false
         empty: false
       arguments:
         uid:
@@ -1091,8 +1119,13 @@ display:
           tokenize: true
           content: 'No scheduled content for user {{ arguments.uid }}'
           plugin_id: text_custom
+      access:
+        type: perm
+        options:
+          perm: 'access content'
+      display_comment: 'Access to the user view is controlled via a custom RouteSubscriber. The high-level "view published content" permission is added to satisfy the security_review module'
     display_plugin: page
-    display_title: 'User profile tab'
+    display_title: User
     id: user_page
     position: 2
     cache_metadata:
@@ -1101,7 +1134,7 @@ display:
         - 'languages:language_interface'
         - url
         - url.query_args
-        - user
         - 'user.node_grants:view'
+        - user.permissions
       max-age: 0
       tags: {  }
diff --git a/web/modules/scheduler/config/optional/views.view.scheduler_scheduled_commerce_product.yml b/web/modules/scheduler/config/optional/views.view.scheduler_scheduled_commerce_product.yml
new file mode 100644
index 0000000000000000000000000000000000000000..375725bf75398a624e97696b1692022e02271b43
--- /dev/null
+++ b/web/modules/scheduler/config/optional/views.view.scheduler_scheduled_commerce_product.yml
@@ -0,0 +1,968 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - system.menu.admin
+  enforced:
+    module:
+      - scheduler
+  module:
+    - commerce
+    - commerce_product
+    - commerce_store
+    - user
+id: scheduler_scheduled_commerce_product
+label: 'Scheduled Products'
+module: views
+description: 'Find and manage scheduled commerce products.'
+tag: ''
+base_table: commerce_product_field_data
+base_field: product_id
+display:
+  # ----------------------------------------------------------------------------
+  # Default display
+  # ----------------------------------------------------------------------------
+  default:
+    display_plugin: default
+    id: default
+    display_title: Master
+    position: 0
+    display_options:
+      access:
+        type: perm
+        options:
+          perm: 'view scheduled commerce_product'
+      cache:
+        type: tag
+        options: {  }
+      query:
+        type: views_query
+        options:
+          disable_sql_rewrite: false
+          distinct: true
+          replica: false
+          query_comment: ''
+          query_tags: {  }
+      exposed_form:
+        type: basic
+        options:
+          submit_button: Filter
+          reset_button: true
+          reset_button_label: Reset
+          exposed_sorts_label: 'Sort by'
+          expose_sort_order: true
+          sort_asc_label: Asc
+          sort_desc_label: Desc
+      pager:
+        type: full
+        options:
+          items_per_page: 50
+          offset: 0
+          id: 0
+          total_pages: null
+          expose:
+            items_per_page: false
+            items_per_page_label: 'Items per page'
+            items_per_page_options: '5, 10, 25, 50'
+            items_per_page_options_all: false
+            items_per_page_options_all_label: '- All -'
+            offset: false
+            offset_label: Offset
+          tags:
+            previous: '‹ previous'
+            next: 'next ›'
+            first: '« first'
+            last: 'last »'
+      style:
+        type: table
+        options:
+          grouping:
+            -
+              field: stores_target_id
+              rendered: true
+              rendered_strip: false
+          row_class: ''
+          default_row_class: true
+          override: true
+          sticky: true
+          caption: ''
+          summary: ''
+          description: ''
+          columns:
+            commerce_product_bulk_form: commerce_product_bulk_form
+            title: title
+            type: type
+            status: status
+            publish_on: publish_on
+            unpublish_on: unpublish_on
+            operations: operations
+            stores_target_id: stores_target_id
+          info:
+            commerce_product_bulk_form:
+              align: ''
+              separator: ''
+              empty_column: false
+              responsive: ''
+            title:
+              sortable: true
+              default_sort_order: asc
+              align: ''
+              separator: ''
+              empty_column: false
+              responsive: ''
+            type:
+              sortable: true
+              default_sort_order: asc
+              align: ''
+              separator: ''
+              empty_column: false
+              responsive: ''
+            status:
+              sortable: true
+              default_sort_order: asc
+              align: ''
+              separator: ''
+              empty_column: false
+              responsive: ''
+            publish_on:
+              sortable: true
+              default_sort_order: asc
+              align: ''
+              separator: ''
+              empty_column: false
+              responsive: ''
+            unpublish_on:
+              sortable: true
+              default_sort_order: asc
+              align: ''
+              separator: ''
+              empty_column: false
+              responsive: ''
+            operations:
+              align: ''
+              separator: ''
+              empty_column: false
+              responsive: ''
+            stores_target_id:
+              align: ''
+              separator: ''
+              empty_column: false
+              responsive: ''
+          default: publish_on
+          empty_table: true
+      row:
+        type: fields
+      fields:
+        commerce_product_bulk_form:
+          id: commerce_product_bulk_form
+          table: commerce_product
+          field: commerce_product_bulk_form
+          relationship: none
+          group_type: group
+          admin_label: ''
+          label: 'Bulk update'
+          exclude: false
+          alter:
+            alter_text: false
+            text: ''
+            make_link: false
+            path: ''
+            absolute: false
+            external: false
+            replace_spaces: false
+            path_case: none
+            trim_whitespace: false
+            alt: ''
+            rel: ''
+            link_class: ''
+            prefix: ''
+            suffix: ''
+            target: ''
+            nl2br: false
+            max_length: 0
+            word_boundary: true
+            ellipsis: true
+            more_link: false
+            more_link_text: ''
+            more_link_path: ''
+            strip_tags: false
+            trim: false
+            preserve_tags: ''
+            html: false
+          element_type: ''
+          element_class: ''
+          element_label_type: ''
+          element_label_class: ''
+          element_label_colon: false
+          element_wrapper_type: ''
+          element_wrapper_class: ''
+          element_default_classes: true
+          empty: ''
+          hide_empty: false
+          empty_zero: false
+          hide_alter_empty: true
+          action_title: Action
+          include_exclude: exclude
+          selected_actions: {  }
+          entity_type: commerce_product
+          plugin_id: bulk_form
+        title:
+          id: title
+          table: commerce_product_field_data
+          field: title
+          relationship: none
+          group_type: group
+          admin_label: ''
+          label: 'Title'
+          exclude: false
+          alter:
+            alter_text: false
+            text: ''
+            make_link: false
+            path: ''
+            absolute: false
+            external: false
+            replace_spaces: false
+            path_case: none
+            trim_whitespace: false
+            alt: ''
+            rel: ''
+            link_class: ''
+            prefix: ''
+            suffix: ''
+            target: ''
+            nl2br: false
+            max_length: 0
+            word_boundary: false
+            ellipsis: false
+            more_link: false
+            more_link_text: ''
+            more_link_path: ''
+            strip_tags: false
+            trim: false
+            preserve_tags: ''
+            html: false
+          element_type: ''
+          element_class: ''
+          element_label_type: ''
+          element_label_class: ''
+          element_label_colon: true
+          element_wrapper_type: ''
+          element_wrapper_class: ''
+          element_default_classes: true
+          empty: ''
+          hide_empty: false
+          empty_zero: false
+          hide_alter_empty: true
+          click_sort_column: value
+          type: string
+          settings:
+            link_to_entity: true
+          group_column: value
+          group_columns: {  }
+          group_rows: true
+          delta_limit: 0
+          delta_offset: 0
+          delta_reversed: false
+          delta_first_last: false
+          multi_type: separator
+          separator: ', '
+          field_api_classes: false
+          entity_type: commerce_product
+          entity_field: title
+          plugin_id: field
+        type:
+          id: type
+          table: commerce_product_field_data
+          field: type
+          relationship: none
+          group_type: group
+          admin_label: ''
+          label: Type
+          exclude: false
+          alter:
+            alter_text: false
+            text: ''
+            make_link: false
+            path: ''
+            absolute: false
+            external: false
+            replace_spaces: false
+            path_case: none
+            trim_whitespace: false
+            alt: ''
+            rel: ''
+            link_class: ''
+            prefix: ''
+            suffix: ''
+            target: ''
+            nl2br: false
+            max_length: 0
+            word_boundary: true
+            ellipsis: true
+            more_link: false
+            more_link_text: ''
+            more_link_path: ''
+            strip_tags: false
+            trim: false
+            preserve_tags: ''
+            html: false
+          element_type: ''
+          element_class: ''
+          element_label_type: ''
+          element_label_class: ''
+          element_label_colon: true
+          element_wrapper_type: ''
+          element_wrapper_class: ''
+          element_default_classes: true
+          empty: ''
+          hide_empty: false
+          empty_zero: false
+          hide_alter_empty: true
+          click_sort_column: target_id
+          type: entity_reference_label
+          settings:
+            link: true
+          group_column: target_id
+          group_columns: {  }
+          group_rows: true
+          delta_limit: 0
+          delta_offset: 0
+          delta_reversed: false
+          delta_first_last: false
+          multi_type: separator
+          separator: ', '
+          field_api_classes: false
+          hide_single_bundle: true
+          entity_type: commerce_product
+          entity_field: type
+          plugin_id: commerce_entity_bundle
+        status:
+          id: status
+          table: commerce_product_field_data
+          field: status
+          relationship: none
+          group_type: group
+          admin_label: ''
+          label: Status
+          exclude: false
+          alter:
+            alter_text: false
+            text: ''
+            make_link: false
+            path: ''
+            absolute: false
+            external: false
+            replace_spaces: false
+            path_case: none
+            trim_whitespace: false
+            alt: ''
+            rel: ''
+            link_class: ''
+            prefix: ''
+            suffix: ''
+            target: ''
+            nl2br: false
+            max_length: 0
+            word_boundary: true
+            ellipsis: true
+            more_link: false
+            more_link_text: ''
+            more_link_path: ''
+            strip_tags: false
+            trim: false
+            preserve_tags: ''
+            html: false
+          element_type: ''
+          element_class: ''
+          element_label_type: ''
+          element_label_class: ''
+          element_label_colon: true
+          element_wrapper_type: ''
+          element_wrapper_class: ''
+          element_default_classes: true
+          empty: ''
+          hide_empty: false
+          empty_zero: false
+          hide_alter_empty: true
+          click_sort_column: value
+          type: boolean
+          settings:
+            format: custom
+            format_custom_true: Published
+            format_custom_false: Unpublished
+          group_column: value
+          group_columns: {  }
+          group_rows: true
+          delta_limit: 0
+          delta_offset: 0
+          delta_reversed: false
+          delta_first_last: false
+          multi_type: separator
+          separator: ', '
+          field_api_classes: false
+          entity_type: commerce_product
+          entity_field: status
+          plugin_id: field
+        publish_on:
+          id: publish_on
+          table: commerce_product_field_data
+          field: publish_on
+          relationship: none
+          group_type: group
+          admin_label: ''
+          label: 'Publish on'
+          exclude: false
+          alter:
+            alter_text: false
+            text: ''
+            make_link: false
+            path: ''
+            absolute: false
+            external: false
+            replace_spaces: false
+            path_case: none
+            trim_whitespace: false
+            alt: ''
+            rel: ''
+            link_class: ''
+            prefix: ''
+            suffix: ''
+            target: ''
+            nl2br: false
+            max_length: 0
+            word_boundary: true
+            ellipsis: true
+            more_link: false
+            more_link_text: ''
+            more_link_path: ''
+            strip_tags: false
+            trim: false
+            preserve_tags: ''
+            html: false
+          element_type: ''
+          element_class: ''
+          element_label_type: ''
+          element_label_class: ''
+          element_label_colon: true
+          element_wrapper_type: ''
+          element_wrapper_class: ''
+          element_default_classes: true
+          empty: ''
+          hide_empty: false
+          empty_zero: false
+          hide_alter_empty: true
+          click_sort_column: value
+          type: timestamp
+          settings:
+            date_format: short
+            custom_date_format: ''
+            timezone: ''
+          group_column: value
+          group_columns: {  }
+          group_rows: true
+          delta_limit: 0
+          delta_offset: 0
+          delta_reversed: false
+          delta_first_last: false
+          multi_type: separator
+          separator: ', '
+          field_api_classes: false
+          entity_type: commerce_product
+          entity_field: publish_on
+          plugin_id: field
+        unpublish_on:
+          id: unpublish_on
+          table: commerce_product_field_data
+          field: unpublish_on
+          relationship: none
+          group_type: group
+          admin_label: ''
+          label: 'Unpublish on'
+          exclude: false
+          alter:
+            alter_text: false
+            text: ''
+            make_link: false
+            path: ''
+            absolute: false
+            external: false
+            replace_spaces: false
+            path_case: none
+            trim_whitespace: false
+            alt: ''
+            rel: ''
+            link_class: ''
+            prefix: ''
+            suffix: ''
+            target: ''
+            nl2br: false
+            max_length: 0
+            word_boundary: true
+            ellipsis: true
+            more_link: false
+            more_link_text: ''
+            more_link_path: ''
+            strip_tags: false
+            trim: false
+            preserve_tags: ''
+            html: false
+          element_type: ''
+          element_class: ''
+          element_label_type: ''
+          element_label_class: ''
+          element_label_colon: true
+          element_wrapper_type: ''
+          element_wrapper_class: ''
+          element_default_classes: true
+          empty: ''
+          hide_empty: false
+          empty_zero: false
+          hide_alter_empty: true
+          click_sort_column: value
+          type: timestamp
+          settings:
+            date_format: short
+            custom_date_format: ''
+            timezone: ''
+          group_column: value
+          group_columns: {  }
+          group_rows: true
+          delta_limit: 0
+          delta_offset: 0
+          delta_reversed: false
+          delta_first_last: false
+          multi_type: separator
+          separator: ', '
+          field_api_classes: false
+          entity_type: commerce_product
+          entity_field: unpublish_on
+          plugin_id: field
+        operations:
+          id: operations
+          table: commerce_product
+          field: operations
+          relationship: none
+          group_type: group
+          admin_label: ''
+          label: Operations
+          exclude: false
+          alter:
+            alter_text: false
+            text: ''
+            make_link: false
+            path: ''
+            absolute: false
+            external: false
+            replace_spaces: false
+            path_case: none
+            trim_whitespace: false
+            alt: ''
+            rel: ''
+            link_class: ''
+            prefix: ''
+            suffix: ''
+            target: ''
+            nl2br: false
+            max_length: 0
+            word_boundary: true
+            ellipsis: true
+            more_link: false
+            more_link_text: ''
+            more_link_path: ''
+            strip_tags: false
+            trim: false
+            preserve_tags: ''
+            html: false
+          element_type: ''
+          element_class: ''
+          element_label_type: ''
+          element_label_class: ''
+          element_label_colon: true
+          element_wrapper_type: ''
+          element_wrapper_class: ''
+          element_default_classes: true
+          empty: ''
+          hide_empty: false
+          empty_zero: false
+          hide_alter_empty: true
+          destination: false
+          entity_type: commerce_product
+          plugin_id: entity_operations
+        stores_target_id:
+          id: stores_target_id
+          table: commerce_product__stores
+          field: stores_target_id
+          relationship: none
+          group_type: group
+          admin_label: ''
+          label: Stores
+          exclude: true
+          alter:
+            alter_text: false
+            text: ''
+            make_link: false
+            path: ''
+            absolute: false
+            external: false
+            replace_spaces: false
+            path_case: none
+            trim_whitespace: false
+            alt: ''
+            rel: ''
+            link_class: ''
+            prefix: ''
+            suffix: ''
+            target: ''
+            nl2br: false
+            max_length: 0
+            word_boundary: true
+            ellipsis: true
+            more_link: false
+            more_link_text: ''
+            more_link_path: ''
+            strip_tags: false
+            trim: false
+            preserve_tags: ''
+            html: false
+          element_type: ''
+          element_class: ''
+          element_label_type: ''
+          element_label_class: ''
+          element_label_colon: true
+          element_wrapper_type: ''
+          element_wrapper_class: ''
+          element_default_classes: true
+          empty: ''
+          hide_empty: false
+          empty_zero: false
+          hide_alter_empty: true
+          click_sort_column: target_id
+          type: entity_reference_label
+          settings:
+            link: true
+          group_column: target_id
+          group_columns: {  }
+          group_rows: true
+          delta_limit: 0
+          delta_offset: 0
+          delta_reversed: false
+          delta_first_last: false
+          multi_type: separator
+          separator: ', '
+          field_api_classes: false
+          entity_type: commerce_product
+          entity_field: stores
+          plugin_id: field
+      filters:
+        type:
+          id: type
+          table: commerce_product_field_data
+          field: type
+          relationship: none
+          group_type: group
+          admin_label: ''
+          operator: in
+          value: {  }
+          group: 1
+          exposed: true
+          expose:
+            operator_id: type_op
+            label: 'Type'
+            description: ''
+            use_operator: false
+            operator: type_op
+            operator_limit_selection: false
+            operator_list: {  }
+            identifier: type
+            required: false
+            remember: false
+            multiple: false
+            remember_roles:
+              authenticated: authenticated
+              anonymous: '0'
+              administrator: '0'
+            reduce: false
+            hide_single_bundle: true
+          is_grouped: false
+          group_info:
+            label: ''
+            description: ''
+            identifier: ''
+            optional: true
+            widget: select
+            multiple: false
+            remember: false
+            default_group: All
+            default_group_multiple: {  }
+            group_items: {  }
+          entity_type: commerce_product
+          entity_field: type
+          plugin_id: commerce_entity_bundle
+        title:
+          id: title
+          table: commerce_product_field_data
+          field: title
+          relationship: none
+          group_type: group
+          admin_label: ''
+          operator: contains
+          value: ''
+          group: 1
+          exposed: true
+          expose:
+            operator_id: title_op
+            label: 'Title'
+            description: ''
+            use_operator: false
+            operator: title_op
+            operator_limit_selection: false
+            operator_list: {  }
+            identifier: title
+            required: false
+            remember: false
+            multiple: false
+            remember_roles:
+              authenticated: authenticated
+              anonymous: '0'
+              administrator: '0'
+            placeholder: ''
+          is_grouped: false
+          group_info:
+            label: ''
+            description: ''
+            identifier: ''
+            optional: true
+            widget: select
+            multiple: false
+            remember: false
+            default_group: All
+            default_group_multiple: {  }
+            group_items: {  }
+          entity_type: commerce_product
+          entity_field: title
+          plugin_id: string
+        status:
+          id: status
+          table: commerce_product_field_data
+          field: status
+          relationship: none
+          group_type: group
+          admin_label: ''
+          operator: '='
+          value: '0'
+          group: 1
+          exposed: true
+          expose:
+            operator_id: ''
+            label: Status
+            description: ''
+            use_operator: false
+            operator: status_op
+            operator_limit_selection: false
+            operator_list: {  }
+            identifier: status
+            required: false
+            remember: false
+            multiple: false
+            remember_roles:
+              authenticated: authenticated
+              anonymous: '0'
+              administrator: '0'
+          is_grouped: true
+          group_info:
+            label: 'Published status'
+            description: ''
+            identifier: status
+            optional: true
+            widget: select
+            multiple: false
+            remember: false
+            default_group: All
+            default_group_multiple: {  }
+            group_items:
+              1:
+                title: Published
+                operator: '='
+                value: '1'
+              2:
+                title: Unpublished
+                operator: '='
+                value: '0'
+          entity_type: commerce_product
+          entity_field: status
+          plugin_id: boolean
+        publish_on:
+          id: publish_on
+          table: commerce_product_field_data
+          field: publish_on
+          relationship: none
+          group_type: group
+          admin_label: ''
+          operator: 'not empty'
+          value:
+            min: ''
+            max: ''
+            value: ''
+            type: date
+          group: 2
+          exposed: false
+          expose:
+            operator_id: ''
+            label: ''
+            description: ''
+            use_operator: false
+            operator: ''
+            operator_limit_selection: false
+            operator_list: {  }
+            identifier: ''
+            required: false
+            remember: false
+            multiple: false
+            remember_roles:
+              authenticated: authenticated
+            placeholder: ''
+            min_placeholder: ''
+            max_placeholder: ''
+          is_grouped: false
+          group_info:
+            label: ''
+            description: ''
+            identifier: ''
+            optional: true
+            widget: select
+            multiple: false
+            remember: false
+            default_group: All
+            default_group_multiple: {  }
+            group_items: {  }
+          entity_type: commerce_product
+          entity_field: publish_on
+          plugin_id: date
+        unpublish_on:
+          id: unpublish_on
+          table: commerce_product_field_data
+          field: unpublish_on
+          relationship: none
+          group_type: group
+          admin_label: ''
+          operator: 'not empty'
+          value:
+            min: ''
+            max: ''
+            value: ''
+            type: date
+          group: 2
+          exposed: false
+          expose:
+            operator_id: ''
+            label: ''
+            description: ''
+            use_operator: false
+            operator: ''
+            operator_limit_selection: false
+            operator_list: {  }
+            identifier: ''
+            required: false
+            remember: false
+            multiple: false
+            remember_roles:
+              authenticated: authenticated
+            placeholder: null
+            min_placeholder: null
+            max_placeholder: null
+          is_grouped: false
+          group_info:
+            label: ''
+            description: ''
+            identifier: ''
+            optional: true
+            widget: select
+            multiple: false
+            remember: false
+            default_group: All
+            default_group_multiple: {  }
+            group_items: {  }
+          entity_type: commerce_product
+          entity_field: unpublish_on
+          plugin_id: date
+      sorts: {  }
+      header: {  }
+      footer: {  }
+      empty:
+        area_text_custom:
+          id: area_text_custom
+          table: views
+          field: area_text_custom
+          relationship: none
+          group_type: group
+          admin_label: ''
+          empty: true
+          tokenize: false
+          content: 'No scheduled products.'
+          plugin_id: text_custom
+      arguments: {  }
+      display_extenders: {  }
+      filter_groups:
+        operator: AND
+        groups:
+          1: AND
+          2: OR
+      title: 'Scheduled Products'
+      relationships:
+        stores_target_id:
+          id: stores_target_id
+          table: commerce_product__stores
+          field: stores_target_id
+          relationship: none
+          group_type: group
+          admin_label: Store
+          required: false
+          entity_type: commerce_product
+          entity_field: stores
+          plugin_id: standard
+      group_by: true
+    cache_metadata:
+      max-age: 0
+      contexts:
+        - 'languages:language_content'
+        - 'languages:language_interface'
+        - url
+        - url.query_args
+        - user.permissions
+      tags: {  }
+  # ----------------------------------------------------------------------------
+  # Overview
+  # ----------------------------------------------------------------------------
+  overview:
+    display_plugin: page
+    id: overview
+    display_title: 'Products Overview'
+    position: 1
+    display_options:
+      display_extenders: {  }
+      path: admin/commerce/products/scheduled
+      display_description: 'Overview of all scheduled products, via main commerce product page'
+      menu:
+        type: normal
+        title: 'Scheduled Products'
+        description: 'Commerce products that are scheduled for publishing or unpublishing'
+        expanded: false
+        parent: entity.commerce_product.collection
+        weight: 0
+        context: '0'
+        menu_name: admin
+      tab_options:
+        type: none
+        title: ''
+        description: ''
+        weight: 0
+    cache_metadata:
+      max-age: 0
+      contexts:
+        - 'languages:language_content'
+        - 'languages:language_interface'
+        - url
+        - url.query_args
+        - user.permissions
+      tags: {  }
diff --git a/web/modules/scheduler/config/optional/views.view.scheduler_scheduled_media.yml b/web/modules/scheduler/config/optional/views.view.scheduler_scheduled_media.yml
new file mode 100644
index 0000000000000000000000000000000000000000..d55e27c33312bea1d30e57006cd511d4d118ec29
--- /dev/null
+++ b/web/modules/scheduler/config/optional/views.view.scheduler_scheduled_media.yml
@@ -0,0 +1,1097 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - system.menu.admin
+  module:
+    - media
+    - user
+  enforced:
+    module:
+      - scheduler
+id: scheduler_scheduled_media
+label: 'Scheduled Media'
+module: views
+description: 'Find and manage scheduled media.'
+tag: ''
+base_table: media_field_revision
+base_field: vid
+display:
+  # ----------------------------------------------------------------------------
+  # Default display
+  # ----------------------------------------------------------------------------
+  default:
+    display_plugin: default
+    id: default
+    display_title: Master
+    position: 0
+    display_options:
+      access:
+        type: perm
+        options:
+          perm: 'view scheduled media'
+      cache:
+        type: tag
+        options: {  }
+      query:
+        type: views_query
+        options:
+          disable_sql_rewrite: false
+          distinct: false
+          replica: false
+          query_comment: ''
+          query_tags: {  }
+      exposed_form:
+        type: basic
+        options:
+          submit_button: Filter
+          reset_button: true
+          reset_button_label: Reset
+          exposed_sorts_label: 'Sort by'
+          expose_sort_order: true
+          sort_asc_label: Asc
+          sort_desc_label: Desc
+      pager:
+        type: full
+        options:
+          items_per_page: 50
+          offset: 0
+          id: 0
+          total_pages: null
+          expose:
+            items_per_page: false
+            items_per_page_label: 'Items per page'
+            items_per_page_options: '5, 10, 25, 50'
+            items_per_page_options_all: false
+            items_per_page_options_all_label: '- All -'
+            offset: false
+            offset_label: Offset
+          tags:
+            previous: '‹ previous'
+            next: 'next ›'
+            first: '« first'
+            last: 'last »'
+      style:
+        type: table
+        options:
+          grouping: {  }
+          row_class: ''
+          default_row_class: true
+          override: true
+          sticky: true
+          caption: ''
+          summary: ''
+          description: ''
+          columns:
+            media_bulk_form: media_bulk_form
+            name: name
+            bundle: bundle
+            uid: uid
+            status: status
+            publish_on: publish_on
+            unpublish_on: unpublish_on
+            operations: operations
+          info:
+            media_bulk_form:
+              align: ''
+              separator: ''
+              empty_column: false
+              responsive: ''
+            name:
+              sortable: true
+              default_sort_order: asc
+              align: ''
+              separator: ''
+              empty_column: false
+              responsive: ''
+            bundle:
+              sortable: true
+              default_sort_order: asc
+              align: ''
+              separator: ''
+              empty_column: false
+              responsive: ''
+            uid:
+              sortable: true
+              default_sort_order: asc
+              align: ''
+              separator: ''
+              empty_column: false
+              responsive: ''
+            status:
+              sortable: true
+              default_sort_order: asc
+              align: ''
+              separator: ''
+              empty_column: false
+              responsive: ''
+            publish_on:
+              sortable: true
+              default_sort_order: asc
+              align: ''
+              separator: ''
+              empty_column: false
+              responsive: ''
+            unpublish_on:
+              sortable: true
+              default_sort_order: asc
+              align: ''
+              separator: ''
+              empty_column: false
+              responsive: ''
+            operations:
+              align: ''
+              separator: ''
+              empty_column: false
+              responsive: ''
+          default: publish_on
+          empty_table: true
+      row:
+        type: fields
+      fields:
+        media_bulk_form:
+          id: media_bulk_form
+          table: media
+          field: media_bulk_form
+          relationship: none
+          group_type: group
+          admin_label: ''
+          label: 'Bulk update'
+          exclude: false
+          alter:
+            alter_text: false
+            text: ''
+            make_link: false
+            path: ''
+            absolute: false
+            external: false
+            replace_spaces: false
+            path_case: none
+            trim_whitespace: false
+            alt: ''
+            rel: ''
+            link_class: ''
+            prefix: ''
+            suffix: ''
+            target: ''
+            nl2br: false
+            max_length: 0
+            word_boundary: true
+            ellipsis: true
+            more_link: false
+            more_link_text: ''
+            more_link_path: ''
+            strip_tags: false
+            trim: false
+            preserve_tags: ''
+            html: false
+          element_type: ''
+          element_class: ''
+          element_label_type: ''
+          element_label_class: ''
+          element_label_colon: false
+          element_wrapper_type: ''
+          element_wrapper_class: ''
+          element_default_classes: true
+          empty: ''
+          hide_empty: false
+          empty_zero: false
+          hide_alter_empty: true
+          action_title: Action
+          include_exclude: exclude
+          selected_actions: {  }
+          entity_type: media
+          plugin_id: bulk_form
+        name:
+          id: name
+          table: media_field_revision
+          field: name
+          relationship: none
+          group_type: group
+          admin_label: ''
+          label: 'Media Name'
+          exclude: false
+          alter:
+            alter_text: false
+            text: ''
+            make_link: false
+            path: ''
+            absolute: false
+            external: false
+            replace_spaces: false
+            path_case: none
+            trim_whitespace: false
+            alt: ''
+            rel: ''
+            link_class: ''
+            prefix: ''
+            suffix: ''
+            target: ''
+            nl2br: false
+            max_length: 0
+            word_boundary: false
+            ellipsis: false
+            more_link: false
+            more_link_text: ''
+            more_link_path: ''
+            strip_tags: false
+            trim: false
+            preserve_tags: ''
+            html: false
+          element_type: ''
+          element_class: ''
+          element_label_type: ''
+          element_label_class: ''
+          element_label_colon: true
+          element_wrapper_type: ''
+          element_wrapper_class: ''
+          element_default_classes: true
+          empty: ''
+          hide_empty: false
+          empty_zero: false
+          hide_alter_empty: true
+          click_sort_column: value
+          type: string
+          settings:
+            link_to_entity: true
+          group_column: value
+          group_columns: {  }
+          group_rows: true
+          delta_limit: 0
+          delta_offset: 0
+          delta_reversed: false
+          delta_first_last: false
+          multi_type: separator
+          separator: ', '
+          field_api_classes: false
+          entity_type: media
+          entity_field: name
+          plugin_id: field
+        bundle:
+          id: bundle
+          table: media_field_data
+          field: bundle
+          relationship: mid
+          group_type: group
+          admin_label: ''
+          label: 'Media type'
+          exclude: false
+          alter:
+            alter_text: false
+            text: ''
+            make_link: false
+            path: ''
+            absolute: false
+            external: false
+            replace_spaces: false
+            path_case: none
+            trim_whitespace: false
+            alt: ''
+            rel: ''
+            link_class: ''
+            prefix: ''
+            suffix: ''
+            target: ''
+            nl2br: false
+            max_length: 0
+            word_boundary: true
+            ellipsis: true
+            more_link: false
+            more_link_text: ''
+            more_link_path: ''
+            strip_tags: false
+            trim: false
+            preserve_tags: ''
+            html: false
+          element_type: ''
+          element_class: ''
+          element_label_type: ''
+          element_label_class: ''
+          element_label_colon: true
+          element_wrapper_type: ''
+          element_wrapper_class: ''
+          element_default_classes: true
+          empty: ''
+          hide_empty: false
+          empty_zero: false
+          hide_alter_empty: true
+          click_sort_column: target_id
+          type: entity_reference_label
+          settings:
+            link: true
+          group_column: target_id
+          group_columns: {  }
+          group_rows: true
+          delta_limit: 0
+          delta_offset: 0
+          delta_reversed: false
+          delta_first_last: false
+          multi_type: separator
+          separator: ', '
+          field_api_classes: false
+          entity_type: media
+          entity_field: bundle
+          plugin_id: field
+        uid:
+          id: uid
+          table: media_field_revision
+          field: uid
+          relationship: none
+          group_type: group
+          admin_label: ''
+          label: Author
+          exclude: false
+          alter:
+            alter_text: false
+            text: ''
+            make_link: false
+            path: ''
+            absolute: false
+            external: false
+            replace_spaces: false
+            path_case: none
+            trim_whitespace: false
+            alt: ''
+            rel: ''
+            link_class: ''
+            prefix: ''
+            suffix: ''
+            target: ''
+            nl2br: false
+            max_length: 0
+            word_boundary: true
+            ellipsis: true
+            more_link: false
+            more_link_text: ''
+            more_link_path: ''
+            strip_tags: false
+            trim: false
+            preserve_tags: ''
+            html: false
+          element_type: ''
+          element_class: ''
+          element_label_type: ''
+          element_label_class: ''
+          element_label_colon: true
+          element_wrapper_type: ''
+          element_wrapper_class: ''
+          element_default_classes: true
+          empty: ''
+          hide_empty: false
+          empty_zero: false
+          hide_alter_empty: true
+          click_sort_column: target_id
+          type: entity_reference_label
+          settings:
+            link: true
+          group_column: target_id
+          group_columns: {  }
+          group_rows: true
+          delta_limit: 0
+          delta_offset: 0
+          delta_reversed: false
+          delta_first_last: false
+          multi_type: separator
+          separator: ', '
+          field_api_classes: false
+          entity_type: media
+          entity_field: uid
+          plugin_id: field
+        status:
+          id: status
+          table: media_field_revision
+          field: status
+          relationship: none
+          group_type: group
+          admin_label: ''
+          label: Status
+          exclude: false
+          alter:
+            alter_text: false
+            text: ''
+            make_link: false
+            path: ''
+            absolute: false
+            external: false
+            replace_spaces: false
+            path_case: none
+            trim_whitespace: false
+            alt: ''
+            rel: ''
+            link_class: ''
+            prefix: ''
+            suffix: ''
+            target: ''
+            nl2br: false
+            max_length: 0
+            word_boundary: true
+            ellipsis: true
+            more_link: false
+            more_link_text: ''
+            more_link_path: ''
+            strip_tags: false
+            trim: false
+            preserve_tags: ''
+            html: false
+          element_type: ''
+          element_class: ''
+          element_label_type: ''
+          element_label_class: ''
+          element_label_colon: true
+          element_wrapper_type: ''
+          element_wrapper_class: ''
+          element_default_classes: true
+          empty: ''
+          hide_empty: false
+          empty_zero: false
+          hide_alter_empty: true
+          click_sort_column: value
+          type: boolean
+          settings:
+            format: custom
+            format_custom_true: Published
+            format_custom_false: Unpublished
+          group_column: value
+          group_columns: {  }
+          group_rows: true
+          delta_limit: 0
+          delta_offset: 0
+          delta_reversed: false
+          delta_first_last: false
+          multi_type: separator
+          separator: ', '
+          field_api_classes: false
+          entity_type: media
+          entity_field: status
+          plugin_id: field
+        publish_on:
+          id: publish_on
+          table: media_field_revision
+          field: publish_on
+          relationship: none
+          group_type: group
+          admin_label: ''
+          label: 'Publish on'
+          exclude: false
+          alter:
+            alter_text: false
+            text: ''
+            make_link: false
+            path: ''
+            absolute: false
+            external: false
+            replace_spaces: false
+            path_case: none
+            trim_whitespace: false
+            alt: ''
+            rel: ''
+            link_class: ''
+            prefix: ''
+            suffix: ''
+            target: ''
+            nl2br: false
+            max_length: 0
+            word_boundary: true
+            ellipsis: true
+            more_link: false
+            more_link_text: ''
+            more_link_path: ''
+            strip_tags: false
+            trim: false
+            preserve_tags: ''
+            html: false
+          element_type: ''
+          element_class: ''
+          element_label_type: ''
+          element_label_class: ''
+          element_label_colon: true
+          element_wrapper_type: ''
+          element_wrapper_class: ''
+          element_default_classes: true
+          empty: ''
+          hide_empty: false
+          empty_zero: false
+          hide_alter_empty: true
+          click_sort_column: value
+          type: timestamp
+          settings:
+            date_format: short
+            custom_date_format: ''
+            timezone: ''
+          group_column: value
+          group_columns: {  }
+          group_rows: true
+          delta_limit: 0
+          delta_offset: 0
+          delta_reversed: false
+          delta_first_last: false
+          multi_type: separator
+          separator: ', '
+          field_api_classes: false
+          entity_type: media
+          entity_field: publish_on
+          plugin_id: field
+        unpublish_on:
+          id: unpublish_on
+          table: media_field_revision
+          field: unpublish_on
+          relationship: none
+          group_type: group
+          admin_label: ''
+          label: 'Unpublish on'
+          exclude: false
+          alter:
+            alter_text: false
+            text: ''
+            make_link: false
+            path: ''
+            absolute: false
+            external: false
+            replace_spaces: false
+            path_case: none
+            trim_whitespace: false
+            alt: ''
+            rel: ''
+            link_class: ''
+            prefix: ''
+            suffix: ''
+            target: ''
+            nl2br: false
+            max_length: 0
+            word_boundary: true
+            ellipsis: true
+            more_link: false
+            more_link_text: ''
+            more_link_path: ''
+            strip_tags: false
+            trim: false
+            preserve_tags: ''
+            html: false
+          element_type: ''
+          element_class: ''
+          element_label_type: ''
+          element_label_class: ''
+          element_label_colon: true
+          element_wrapper_type: ''
+          element_wrapper_class: ''
+          element_default_classes: true
+          empty: ''
+          hide_empty: false
+          empty_zero: false
+          hide_alter_empty: true
+          click_sort_column: value
+          type: timestamp
+          settings:
+            date_format: short
+            custom_date_format: ''
+            timezone: ''
+          group_column: value
+          group_columns: {  }
+          group_rows: true
+          delta_limit: 0
+          delta_offset: 0
+          delta_reversed: false
+          delta_first_last: false
+          multi_type: separator
+          separator: ', '
+          field_api_classes: false
+          entity_type: media
+          entity_field: unpublish_on
+          plugin_id: field
+        operations:
+          id: operations
+          table: media_revision
+          field: operations
+          relationship: none
+          group_type: group
+          admin_label: ''
+          label: Operations
+          exclude: false
+          alter:
+            alter_text: false
+            text: ''
+            make_link: false
+            path: ''
+            absolute: false
+            external: false
+            replace_spaces: false
+            path_case: none
+            trim_whitespace: false
+            alt: ''
+            rel: ''
+            link_class: ''
+            prefix: ''
+            suffix: ''
+            target: ''
+            nl2br: false
+            max_length: 0
+            word_boundary: true
+            ellipsis: true
+            more_link: false
+            more_link_text: ''
+            more_link_path: ''
+            strip_tags: false
+            trim: false
+            preserve_tags: ''
+            html: false
+          element_type: ''
+          element_class: ''
+          element_label_type: ''
+          element_label_class: ''
+          element_label_colon: true
+          element_wrapper_type: ''
+          element_wrapper_class: ''
+          element_default_classes: true
+          empty: ''
+          hide_empty: false
+          empty_zero: false
+          hide_alter_empty: true
+          destination: false
+          entity_type: media
+          plugin_id: entity_operations
+      filters:
+        latest_revision:
+          id: latest_revision
+          table: media_revision
+          field: latest_revision
+          relationship: none
+          group_type: group
+          admin_label: ''
+          operator: '='
+          value: ''
+          group: 1
+          exposed: false
+          expose:
+            operator_id: ''
+            label: ''
+            description: ''
+            use_operator: false
+            operator: ''
+            operator_limit_selection: false
+            operator_list: {  }
+            identifier: ''
+            required: false
+            remember: false
+            multiple: false
+            remember_roles:
+              authenticated: authenticated
+          is_grouped: false
+          group_info:
+            label: ''
+            description: ''
+            identifier: ''
+            optional: true
+            widget: select
+            multiple: false
+            remember: false
+            default_group: All
+            default_group_multiple: {  }
+            group_items: {  }
+          entity_type: media
+          plugin_id: latest_revision
+        name:
+          id: name
+          table: media_field_revision
+          field: name
+          relationship: none
+          group_type: group
+          admin_label: ''
+          operator: contains
+          value: ''
+          group: 1
+          exposed: true
+          expose:
+            operator_id: name_op
+            label: 'Media name'
+            description: ''
+            use_operator: false
+            operator: name_op
+            operator_limit_selection: false
+            operator_list: {  }
+            identifier: name
+            required: false
+            remember: false
+            multiple: false
+            remember_roles:
+              authenticated: authenticated
+              anonymous: '0'
+              administrator: '0'
+            placeholder: ''
+          is_grouped: false
+          group_info:
+            label: ''
+            description: ''
+            identifier: ''
+            optional: true
+            widget: select
+            multiple: false
+            remember: false
+            default_group: All
+            default_group_multiple: {  }
+            group_items: {  }
+          entity_type: media
+          entity_field: name
+          plugin_id: string
+        bundle:
+          id: bundle
+          table: media_field_data
+          field: bundle
+          relationship: mid
+          group_type: group
+          admin_label: ''
+          operator: in
+          value: {  }
+          group: 1
+          exposed: true
+          expose:
+            operator_id: bundle_op
+            label: Type
+            description: ''
+            use_operator: false
+            operator: bundle_op
+            operator_limit_selection: false
+            operator_list: {  }
+            identifier: bundle
+            required: false
+            remember: false
+            multiple: false
+            remember_roles:
+              authenticated: authenticated
+              anonymous: '0'
+              administrator: '0'
+            reduce: false
+          is_grouped: false
+          group_info:
+            label: ''
+            description: ''
+            identifier: ''
+            optional: true
+            widget: select
+            multiple: false
+            remember: false
+            default_group: All
+            default_group_multiple: {  }
+            group_items: {  }
+          entity_type: media
+          entity_field: bundle
+          plugin_id: bundle
+        status:
+          id: status
+          table: media_field_revision
+          field: status
+          relationship: none
+          group_type: group
+          admin_label: ''
+          operator: '='
+          value: ''
+          group: 1
+          exposed: true
+          expose:
+            operator_id: ''
+            label: Status
+            description: ''
+            use_operator: false
+            operator: status_op
+            operator_limit_selection: false
+            operator_list: {  }
+            identifier: status
+            required: false
+            remember: false
+            multiple: false
+            remember_roles:
+              authenticated: authenticated
+              anonymous: '0'
+              administrator: '0'
+          is_grouped: true
+          group_info:
+            label: 'Published status'
+            description: ''
+            identifier: status
+            optional: true
+            widget: select
+            multiple: false
+            remember: false
+            default_group: All
+            default_group_multiple: {  }
+            group_items:
+              1:
+                title: Published
+                operator: '='
+                value: '1'
+              2:
+                title: Unpublished
+                operator: '='
+                value: '0'
+          entity_type: media
+          entity_field: status
+          plugin_id: boolean
+        langcode:
+          id: langcode
+          table: media_field_revision
+          field: langcode
+          relationship: none
+          group_type: group
+          admin_label: ''
+          operator: in
+          value: {  }
+          group: 1
+          exposed: true
+          expose:
+            operator_id: langcode_op
+            label: Language
+            description: ''
+            use_operator: false
+            operator: langcode_op
+            operator_limit_selection: false
+            operator_list: {  }
+            identifier: langcode
+            required: false
+            remember: false
+            multiple: false
+            remember_roles:
+              authenticated: authenticated
+              anonymous: '0'
+              administrator: '0'
+            reduce: false
+          is_grouped: false
+          group_info:
+            label: ''
+            description: ''
+            identifier: ''
+            optional: true
+            widget: select
+            multiple: false
+            remember: false
+            default_group: All
+            default_group_multiple: {  }
+            group_items: {  }
+          entity_type: media
+          entity_field: langcode
+          plugin_id: language
+        publish_on:
+          id: publish_on
+          table: media_field_revision
+          field: publish_on
+          relationship: none
+          group_type: group
+          admin_label: ''
+          operator: 'not empty'
+          value:
+            min: ''
+            max: ''
+            value: ''
+            type: date
+          group: 2
+          exposed: false
+          expose:
+            operator_id: ''
+            label: ''
+            description: ''
+            use_operator: false
+            operator: ''
+            operator_limit_selection: false
+            operator_list: {  }
+            identifier: ''
+            required: false
+            remember: false
+            multiple: false
+            remember_roles:
+              authenticated: authenticated
+            placeholder: ''
+            min_placeholder: ''
+            max_placeholder: ''
+          is_grouped: false
+          group_info:
+            label: ''
+            description: ''
+            identifier: ''
+            optional: true
+            widget: select
+            multiple: false
+            remember: false
+            default_group: All
+            default_group_multiple: {  }
+            group_items: {  }
+          entity_type: media
+          entity_field: publish_on
+          plugin_id: date
+        unpublish_on:
+          id: unpublish_on
+          table: media_field_revision
+          field: unpublish_on
+          relationship: none
+          group_type: group
+          admin_label: ''
+          operator: 'not empty'
+          value:
+            min: ''
+            max: ''
+            value: ''
+            type: date
+          group: 2
+          exposed: false
+          expose:
+            operator_id: ''
+            label: ''
+            description: ''
+            use_operator: false
+            operator: ''
+            operator_limit_selection: false
+            operator_list: {  }
+            identifier: ''
+            required: false
+            remember: false
+            multiple: false
+            remember_roles:
+              authenticated: authenticated
+            placeholder: null
+            min_placeholder: null
+            max_placeholder: null
+          is_grouped: false
+          group_info:
+            label: ''
+            description: ''
+            identifier: ''
+            optional: true
+            widget: select
+            multiple: false
+            remember: false
+            default_group: All
+            default_group_multiple: {  }
+            group_items: {  }
+          entity_type: media
+          entity_field: unpublish_on
+          plugin_id: date
+      sorts: {  }
+      title: 'Scheduled Media'
+      header: {  }
+      footer: {  }
+      empty:
+        area_text_custom:
+          id: area_text_custom
+          table: views
+          field: area_text_custom
+          relationship: none
+          group_type: group
+          admin_label: ''
+          empty: true
+          tokenize: false
+          content: 'No scheduled media.'
+          plugin_id: text_custom
+      arguments: {  }
+      relationships:
+        mid:
+          id: mid
+          table: media_field_revision
+          field: mid
+          relationship: none
+          group_type: group
+          admin_label: 'Media Field'
+          required: false
+          entity_type: media
+          entity_field: mid
+          plugin_id: standard
+      display_extenders: {  }
+      filter_groups:
+        operator: AND
+        groups:
+          1: AND
+          2: OR
+    cache_metadata:
+      max-age: 0
+      contexts:
+        - 'languages:language_content'
+        - 'languages:language_interface'
+        - url
+        - url.query_args
+        - user.permissions
+      tags: {  }
+  # ----------------------------------------------------------------------------
+  # Overview
+  # ----------------------------------------------------------------------------
+  overview:
+    display_plugin: page
+    id: overview
+    display_title: 'Media Overview'
+    position: 1
+    display_options:
+      display_extenders: {  }
+      path: admin/content/media/scheduled
+      display_description: 'Overview of all scheduled media, via main admin content page'
+      menu:
+        type: normal
+        title: 'Scheduled Media'
+        description: 'Media items that are scheduled for publishing or unpublishing'
+        expanded: false
+        parent: 'system.admin_content'
+        weight: 0
+        context: '0'
+        menu_name: admin
+    cache_metadata:
+      max-age: 0
+      contexts:
+        - 'languages:language_content'
+        - 'languages:language_interface'
+        - url
+        - url.query_args
+        - user.permissions
+      tags: {  }
+  # ----------------------------------------------------------------------------
+  # User page
+  # ----------------------------------------------------------------------------
+  user_page:
+    display_plugin: page
+    id: user_page
+    display_title: User
+    position: 2
+    display_options:
+      display_extenders: {  }
+      display_description: 'Scheduled media on user profile, showing just that user''s scheduled media'
+      path: user/%user/scheduled_media
+      menu:
+        type: tab
+        title: 'Scheduled Media'
+        description: 'Scheduled Media by this user'
+        expanded: false
+        parent: user.page
+        weight: -1
+        context: '0'
+        menu_name: account
+      empty:
+        area_text_custom:
+          id: area_text_custom
+          table: views
+          field: area_text_custom
+          relationship: none
+          group_type: group
+          admin_label: ''
+          empty: true
+          tokenize: true
+          content: 'No scheduled media for user {{ arguments.uid }}'
+          plugin_id: text_custom
+      defaults:
+        empty: false
+        arguments: false
+        access: false
+      arguments:
+        uid:
+          id: uid
+          table: media_field_revision
+          field: uid
+          entity_type: media
+          entity_field: uid
+          plugin_id: numeric
+      access:
+        type: perm
+        options:
+          perm: 'view media'
+      display_comment: 'Access to the user view is controlled via a custom RouteSubscriber. The high-level "view media" permission is added to satisfy the security_review module'
+    cache_metadata:
+      max-age: 0
+      contexts:
+        - 'languages:language_content'
+        - 'languages:language_interface'
+        - url
+        - url.query_args
+        - user.permissions
+      tags: {  }
diff --git a/web/modules/scheduler/config/optional/views.view.scheduler_scheduled_taxonomy_term.yml b/web/modules/scheduler/config/optional/views.view.scheduler_scheduled_taxonomy_term.yml
new file mode 100644
index 0000000000000000000000000000000000000000..0042da07957a1e922a8af0fc22e86ed321d01ea4
--- /dev/null
+++ b/web/modules/scheduler/config/optional/views.view.scheduler_scheduled_taxonomy_term.yml
@@ -0,0 +1,866 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - system.menu.admin
+  enforced:
+    module:
+      - scheduler
+  module:
+    - taxonomy
+    - user
+id: scheduler_scheduled_taxonomy_term
+label: 'Scheduled Taxonomy Terms'
+module: views
+description: 'Find and manage scheduled taxonomy terms.'
+tag: ''
+base_table: taxonomy_term_field_data
+base_field: tid
+display:
+  # ----------------------------------------------------------------------------
+  # Default display
+  # ----------------------------------------------------------------------------
+  default:
+    display_plugin: default
+    id: default
+    display_title: Master
+    position: 0
+    display_options:
+      access:
+        type: perm
+        options:
+          perm: 'view scheduled taxonomy_term'
+      cache:
+        type: tag
+        options: {  }
+      query:
+        type: views_query
+        options:
+          disable_sql_rewrite: false
+          distinct: false
+          replica: false
+          query_comment: ''
+          query_tags: {  }
+      exposed_form:
+        type: basic
+        options:
+          submit_button: Filter
+          reset_button: true
+          reset_button_label: Reset
+          exposed_sorts_label: 'Sort by'
+          expose_sort_order: true
+          sort_asc_label: Asc
+          sort_desc_label: Desc
+      pager:
+        type: full
+        options:
+          items_per_page: 50
+          offset: 0
+          id: 0
+          total_pages: null
+          expose:
+            items_per_page: false
+            items_per_page_label: 'Items per page'
+            items_per_page_options: '5, 10, 25, 50'
+            items_per_page_options_all: false
+            items_per_page_options_all_label: '- All -'
+            offset: false
+            offset_label: Offset
+          tags:
+            previous: '‹ previous'
+            next: 'next ›'
+            first: '« first'
+            last: 'last »'
+      style:
+        type: table
+        options:
+          grouping: {  }
+          row_class: ''
+          default_row_class: true
+          override: true
+          sticky: true
+          caption: ''
+          summary: ''
+          description: ''
+          columns:
+            taxonomy_term_bulk_form: taxonomy_term_bulk_form
+            name: name
+            vid: vid
+            status: status
+            publish_on: publish_on
+            unpublish_on: unpublish_on
+          info:
+            taxonomy_term_bulk_form:
+              align: ''
+              separator: ''
+              empty_column: false
+              responsive: ''
+            name:
+              sortable: true
+              default_sort_order: asc
+              align: ''
+              separator: ''
+              empty_column: false
+              responsive: ''
+            vid:
+              sortable: true
+              default_sort_order: asc
+              align: ''
+              separator: ''
+              empty_column: false
+              responsive: ''
+            status:
+              sortable: true
+              default_sort_order: asc
+              align: ''
+              separator: ''
+              empty_column: false
+              responsive: ''
+            publish_on:
+              sortable: true
+              default_sort_order: asc
+              align: ''
+              separator: ''
+              empty_column: false
+              responsive: ''
+            unpublish_on:
+              sortable: true
+              default_sort_order: asc
+              align: ''
+              separator: ''
+              empty_column: false
+              responsive: ''
+          default: publish_on
+          empty_table: true
+      row:
+        type: fields
+        options:
+          inline: {  }
+          separator: ''
+          hide_empty: false
+          default_field_elements: true
+      fields:
+        taxonomy_term_bulk_form:
+          id: taxonomy_term_bulk_form
+          table: taxonomy_term_data
+          field: taxonomy_term_bulk_form
+          relationship: none
+          group_type: group
+          admin_label: ''
+          label: 'Bulk update'
+          exclude: false
+          alter:
+            alter_text: false
+            text: ''
+            make_link: false
+            path: ''
+            absolute: false
+            external: false
+            replace_spaces: false
+            path_case: none
+            trim_whitespace: false
+            alt: ''
+            rel: ''
+            link_class: ''
+            prefix: ''
+            suffix: ''
+            target: ''
+            nl2br: false
+            max_length: 0
+            word_boundary: true
+            ellipsis: true
+            more_link: false
+            more_link_text: ''
+            more_link_path: ''
+            strip_tags: false
+            trim: false
+            preserve_tags: ''
+            html: false
+          element_type: ''
+          element_class: ''
+          element_label_type: ''
+          element_label_class: ''
+          element_label_colon: false
+          element_wrapper_type: ''
+          element_wrapper_class: ''
+          element_default_classes: true
+          empty: ''
+          hide_empty: false
+          empty_zero: false
+          hide_alter_empty: true
+          action_title: Action
+          include_exclude: exclude
+          selected_actions: {  }
+          entity_type: taxonomy_term
+          plugin_id: bulk_form
+        name:
+          id: name
+          table: taxonomy_term_field_data
+          field: name
+          relationship: none
+          group_type: group
+          admin_label: ''
+          label: Term
+          exclude: false
+          alter:
+            alter_text: false
+            text: ''
+            make_link: false
+            path: ''
+            absolute: false
+            external: false
+            replace_spaces: false
+            path_case: none
+            trim_whitespace: false
+            alt: ''
+            rel: ''
+            link_class: ''
+            prefix: ''
+            suffix: ''
+            target: ''
+            nl2br: false
+            max_length: 0
+            word_boundary: false
+            ellipsis: false
+            more_link: false
+            more_link_text: ''
+            more_link_path: ''
+            strip_tags: false
+            trim: false
+            preserve_tags: ''
+            html: false
+          element_type: ''
+          element_class: ''
+          element_label_type: ''
+          element_label_class: ''
+          element_label_colon: true
+          element_wrapper_type: ''
+          element_wrapper_class: ''
+          element_default_classes: true
+          empty: ''
+          hide_empty: false
+          empty_zero: false
+          hide_alter_empty: true
+          click_sort_column: value
+          type: string
+          settings:
+            link_to_entity: true
+          group_column: value
+          group_columns: {  }
+          group_rows: true
+          delta_limit: 0
+          delta_offset: 0
+          delta_reversed: false
+          delta_first_last: false
+          multi_type: separator
+          separator: ', '
+          field_api_classes: false
+          convert_spaces: false
+          entity_type: taxonomy_term
+          entity_field: name
+          plugin_id: term_name
+        vid:
+          id: vid
+          table: taxonomy_term_field_data
+          field: vid
+          relationship: none
+          group_type: group
+          admin_label: ''
+          label: Vocabulary
+          exclude: false
+          alter:
+            alter_text: false
+            text: ''
+            make_link: false
+            path: ''
+            absolute: false
+            external: false
+            replace_spaces: false
+            path_case: none
+            trim_whitespace: false
+            alt: ''
+            rel: ''
+            link_class: ''
+            prefix: ''
+            suffix: ''
+            target: ''
+            nl2br: false
+            max_length: 0
+            word_boundary: true
+            ellipsis: true
+            more_link: false
+            more_link_text: ''
+            more_link_path: ''
+            strip_tags: false
+            trim: false
+            preserve_tags: ''
+            html: false
+          element_type: ''
+          element_class: ''
+          element_label_type: ''
+          element_label_class: ''
+          element_label_colon: true
+          element_wrapper_type: ''
+          element_wrapper_class: ''
+          element_default_classes: true
+          empty: ''
+          hide_empty: false
+          empty_zero: false
+          hide_alter_empty: true
+          click_sort_column: target_id
+          type: entity_reference_label
+          settings:
+            link: true
+          group_column: target_id
+          group_columns: {  }
+          group_rows: true
+          delta_limit: 0
+          delta_offset: 0
+          delta_reversed: false
+          delta_first_last: false
+          multi_type: separator
+          separator: ', '
+          field_api_classes: false
+          entity_type: taxonomy_term
+          entity_field: vid
+          plugin_id: field
+        status:
+          id: status
+          table: taxonomy_term_field_data
+          field: status
+          relationship: none
+          group_type: group
+          admin_label: ''
+          label: Status
+          exclude: false
+          alter:
+            alter_text: false
+            text: ''
+            make_link: false
+            path: ''
+            absolute: false
+            external: false
+            replace_spaces: false
+            path_case: none
+            trim_whitespace: false
+            alt: ''
+            rel: ''
+            link_class: ''
+            prefix: ''
+            suffix: ''
+            target: ''
+            nl2br: false
+            max_length: 0
+            word_boundary: true
+            ellipsis: true
+            more_link: false
+            more_link_text: ''
+            more_link_path: ''
+            strip_tags: false
+            trim: false
+            preserve_tags: ''
+            html: false
+          element_type: ''
+          element_class: ''
+          element_label_type: ''
+          element_label_class: ''
+          element_label_colon: true
+          element_wrapper_type: ''
+          element_wrapper_class: ''
+          element_default_classes: true
+          empty: ''
+          hide_empty: false
+          empty_zero: false
+          hide_alter_empty: true
+          click_sort_column: value
+          type: boolean
+          settings:
+            format: custom
+            format_custom_true: Published
+            format_custom_false: Unpublished
+          group_column: value
+          group_columns: {  }
+          group_rows: true
+          delta_limit: 0
+          delta_offset: 0
+          delta_reversed: false
+          delta_first_last: false
+          multi_type: separator
+          separator: ', '
+          field_api_classes: false
+          entity_type: taxonomy_term
+          entity_field: status
+          plugin_id: field
+        publish_on:
+          id: publish_on
+          table: taxonomy_term_field_data
+          field: publish_on
+          relationship: none
+          group_type: group
+          admin_label: ''
+          label: 'Publish on'
+          exclude: false
+          alter:
+            alter_text: false
+            text: ''
+            make_link: false
+            path: ''
+            absolute: false
+            external: false
+            replace_spaces: false
+            path_case: none
+            trim_whitespace: false
+            alt: ''
+            rel: ''
+            link_class: ''
+            prefix: ''
+            suffix: ''
+            target: ''
+            nl2br: false
+            max_length: 0
+            word_boundary: true
+            ellipsis: true
+            more_link: false
+            more_link_text: ''
+            more_link_path: ''
+            strip_tags: false
+            trim: false
+            preserve_tags: ''
+            html: false
+          element_type: ''
+          element_class: ''
+          element_label_type: ''
+          element_label_class: ''
+          element_label_colon: true
+          element_wrapper_type: ''
+          element_wrapper_class: ''
+          element_default_classes: true
+          empty: ''
+          hide_empty: false
+          empty_zero: false
+          hide_alter_empty: true
+          click_sort_column: value
+          type: timestamp
+          settings:
+            date_format: short
+            custom_date_format: ''
+            timezone: ''
+          group_column: value
+          group_columns: {  }
+          group_rows: true
+          delta_limit: 0
+          delta_offset: 0
+          delta_reversed: false
+          delta_first_last: false
+          multi_type: separator
+          separator: ', '
+          field_api_classes: false
+          entity_type: taxonomy_term
+          entity_field: publish_on
+          plugin_id: field
+        unpublish_on:
+          id: unpublish_on
+          table: taxonomy_term_field_data
+          field: unpublish_on
+          relationship: none
+          group_type: group
+          admin_label: ''
+          label: 'Unpublish on'
+          exclude: false
+          alter:
+            alter_text: false
+            text: ''
+            make_link: false
+            path: ''
+            absolute: false
+            external: false
+            replace_spaces: false
+            path_case: none
+            trim_whitespace: false
+            alt: ''
+            rel: ''
+            link_class: ''
+            prefix: ''
+            suffix: ''
+            target: ''
+            nl2br: false
+            max_length: 0
+            word_boundary: true
+            ellipsis: true
+            more_link: false
+            more_link_text: ''
+            more_link_path: ''
+            strip_tags: false
+            trim: false
+            preserve_tags: ''
+            html: false
+          element_type: ''
+          element_class: ''
+          element_label_type: ''
+          element_label_class: ''
+          element_label_colon: true
+          element_wrapper_type: ''
+          element_wrapper_class: ''
+          element_default_classes: true
+          empty: ''
+          hide_empty: false
+          empty_zero: false
+          hide_alter_empty: true
+          click_sort_column: value
+          type: timestamp
+          settings:
+            date_format: short
+            custom_date_format: ''
+            timezone: ''
+          group_column: value
+          group_columns: {  }
+          group_rows: true
+          delta_limit: 0
+          delta_offset: 0
+          delta_reversed: false
+          delta_first_last: false
+          multi_type: separator
+          separator: ', '
+          field_api_classes: false
+          entity_type: taxonomy_term
+          entity_field: unpublish_on
+          plugin_id: field
+        operations:
+          id: operations
+          table: taxonomy_term_data
+          field: operations
+          relationship: none
+          group_type: group
+          admin_label: ''
+          label: Operations
+          exclude: false
+          alter:
+            alter_text: false
+            text: ''
+            make_link: false
+            path: ''
+            absolute: false
+            external: false
+            replace_spaces: false
+            path_case: none
+            trim_whitespace: false
+            alt: ''
+            rel: ''
+            link_class: ''
+            prefix: ''
+            suffix: ''
+            target: ''
+            nl2br: false
+            max_length: 0
+            word_boundary: true
+            ellipsis: true
+            more_link: false
+            more_link_text: ''
+            more_link_path: ''
+            strip_tags: false
+            trim: false
+            preserve_tags: ''
+            html: false
+          element_type: ''
+          element_class: ''
+          element_label_type: ''
+          element_label_class: ''
+          element_label_colon: true
+          element_wrapper_type: ''
+          element_wrapper_class: ''
+          element_default_classes: true
+          empty: ''
+          hide_empty: false
+          empty_zero: false
+          hide_alter_empty: true
+          destination: false
+          entity_type: taxonomy_term
+          plugin_id: entity_operations
+      filters:
+        name:
+          id: name
+          table: taxonomy_term_field_data
+          field: name
+          relationship: none
+          group_type: group
+          admin_label: ''
+          operator: contains
+          value: ''
+          group: 1
+          exposed: true
+          expose:
+            operator_id: name_op
+            label: 'Term name'
+            description: ''
+            use_operator: false
+            operator: name_op
+            operator_limit_selection: false
+            operator_list: {  }
+            identifier: name
+            required: false
+            remember: false
+            multiple: false
+            remember_roles:
+              authenticated: authenticated
+            placeholder: ''
+          is_grouped: false
+          group_info:
+            label: ''
+            description: ''
+            identifier: ''
+            optional: true
+            widget: select
+            multiple: false
+            remember: false
+            default_group: All
+            default_group_multiple: {  }
+            group_items: {  }
+          entity_type: taxonomy_term
+          entity_field: name
+          plugin_id: string
+        vid:
+          id: vid
+          table: taxonomy_term_field_data
+          field: vid
+          relationship: none
+          group_type: group
+          admin_label: ''
+          operator: in
+          value: {  }
+          group: 1
+          exposed: true
+          expose:
+            operator_id: vid_op
+            label: Vocabulary
+            description: ''
+            use_operator: false
+            operator: vid_op
+            operator_limit_selection: false
+            operator_list: {  }
+            identifier: vid
+            required: false
+            remember: false
+            multiple: false
+            remember_roles:
+              authenticated: authenticated
+            reduce: false
+          is_grouped: false
+          group_info:
+            label: ''
+            description: ''
+            identifier: ''
+            optional: true
+            widget: select
+            multiple: false
+            remember: false
+            default_group: All
+            default_group_multiple: {  }
+            group_items: {  }
+          entity_type: taxonomy_term
+          entity_field: vid
+          plugin_id: bundle
+        status:
+          id: status
+          table: taxonomy_term_field_data
+          field: status
+          relationship: none
+          group_type: group
+          admin_label: ''
+          operator: '='
+          value: ''
+          group: 1
+          exposed: true
+          expose:
+            operator_id: ''
+            label: Status
+            description: ''
+            use_operator: false
+            operator: status_op
+            operator_limit_selection: false
+            operator_list: {  }
+            identifier: status
+            required: false
+            remember: false
+            multiple: false
+            remember_roles:
+              authenticated: authenticated
+          is_grouped: true
+          group_info:
+            label: 'Published status'
+            description: ''
+            identifier: status
+            optional: true
+            widget: select
+            multiple: false
+            remember: false
+            default_group: All
+            default_group_multiple: {  }
+            group_items:
+              1:
+                title: Published
+                operator: '='
+                value: '1'
+              2:
+                title: Unpublished
+                operator: '='
+                value: '0'
+          entity_type: taxonomy_term
+          entity_field: status
+          plugin_id: boolean
+        publish_on:
+          id: publish_on
+          table: taxonomy_term_field_revision
+          field: publish_on
+          relationship: none
+          group_type: group
+          admin_label: ''
+          operator: 'not empty'
+          value:
+            min: ''
+            max: ''
+            value: ''
+            type: date
+          group: 2
+          exposed: false
+          expose:
+            operator_id: ''
+            label: ''
+            description: ''
+            use_operator: false
+            operator: ''
+            operator_limit_selection: false
+            operator_list: {  }
+            identifier: ''
+            required: false
+            remember: false
+            multiple: false
+            remember_roles:
+              authenticated: authenticated
+            placeholder: null
+            min_placeholder: null
+            max_placeholder: null
+          is_grouped: false
+          group_info:
+            label: ''
+            description: ''
+            identifier: ''
+            optional: true
+            widget: select
+            multiple: false
+            remember: false
+            default_group: All
+            default_group_multiple: {  }
+            group_items: {  }
+          entity_type: taxonomy_term
+          entity_field: publish_on
+          plugin_id: date
+        unpublish_on:
+          id: unpublish_on
+          table: taxonomy_term_field_revision
+          field: unpublish_on
+          relationship: none
+          group_type: group
+          admin_label: ''
+          operator: 'not empty'
+          value:
+            min: ''
+            max: ''
+            value: ''
+            type: date
+          group: 2
+          exposed: false
+          expose:
+            operator_id: ''
+            label: ''
+            description: ''
+            use_operator: false
+            operator: ''
+            operator_limit_selection: false
+            operator_list: {  }
+            identifier: ''
+            required: false
+            remember: false
+            multiple: false
+            remember_roles:
+              authenticated: authenticated
+            placeholder: ''
+            min_placeholder: ''
+            max_placeholder: ''
+          is_grouped: false
+          group_info:
+            label: ''
+            description: ''
+            identifier: ''
+            optional: true
+            widget: select
+            multiple: false
+            remember: false
+            default_group: All
+            default_group_multiple: {  }
+            group_items: {  }
+          entity_type: taxonomy_term
+          entity_field: unpublish_on
+          plugin_id: date
+      sorts: {  }
+      header: {  }
+      footer: {  }
+      empty:
+        area_text_custom:
+          id: area_text_custom
+          table: views
+          field: area_text_custom
+          relationship: none
+          group_type: group
+          admin_label: ''
+          empty: true
+          tokenize: false
+          content: 'No scheduled taxonomy terms'
+          plugin_id: text_custom
+      relationships: {  }
+      arguments: {  }
+      display_extenders: {  }
+      filter_groups:
+        operator: AND
+        groups:
+          1: AND
+          2: OR
+      title: 'Scheduled Taxonomy Terms'
+    cache_metadata:
+      max-age: 0
+      contexts:
+        - 'languages:language_content'
+        - 'languages:language_interface'
+        - url
+        - url.query_args
+        - user.permissions
+      tags: {  }
+  # ----------------------------------------------------------------------------
+  # Overview
+  # ----------------------------------------------------------------------------
+  overview:
+    display_plugin: page
+    id: overview
+    display_title: 'Taxonomy Terms'
+    position: 1
+    display_options:
+      display_extenders: {  }
+      display_description: 'Overview of all scheduled terms, via main admin taxonomy page'
+      path: admin/structure/taxonomy/scheduled
+      menu:
+        type: normal
+        title: 'Scheduled Taxonomy Terms'
+        description: 'Taxonomy Terms that are scheduled for publishing or unpublishing'
+        expanded: false
+        parent: entity.taxonomy_vocabulary.collection
+        weight: 0
+        context: '0'
+        menu_name: admin
+    cache_metadata:
+      max-age: 0
+      contexts:
+        - 'languages:language_content'
+        - 'languages:language_interface'
+        - url
+        - url.query_args
+        - user.permissions
+      tags: {  }
diff --git a/web/modules/scheduler/config/schema/scheduler.schema.yml b/web/modules/scheduler/config/schema/scheduler.schema.yml
index 7242b1b3e913a80ef09f551fd00697a608288e23..b1e1ab97ec84e2e17f778540a0a2153937fdfe65 100644
--- a/web/modules/scheduler/config/schema/scheduler.schema.yml
+++ b/web/modules/scheduler/config/schema/scheduler.schema.yml
@@ -18,43 +18,46 @@ scheduler.settings:
       label: 'Date part of the full format'
     default_expand_fieldset:
       type: string
-      label: 'Default value for nodetype setting expand_fieldset'
+      label: 'Default value for entity type setting expand_fieldset'
     default_fields_display_mode:
       type: string
-      label: 'Default value for nodetype setting fields_display_mode'
+      label: 'Default value for entity type setting fields_display_mode'
     default_publish_enable:
       type: boolean
-      label: 'Default value for nodetype setting publish_enable'
+      label: 'Default value for entity type setting publish_enable'
     default_publish_past_date:
       type: string
-      label: 'Default value for nodetype setting publish_past_date'
+      label: 'Default value for entity type setting publish_past_date'
     default_publish_past_date_created:
       type: boolean
-      label: 'Default value for nodetype setting publish_past_date_created'
+      label: 'Default value for entity type setting publish_past_date_created'
     default_publish_required:
       type: boolean
-      label: 'Default value for nodetype setting publish_required'
+      label: 'Default value for entity type setting publish_required'
     default_publish_revision:
       type: boolean
-      label: 'Default value for nodetype setting publish_revision'
+      label: 'Default value for entity type setting publish_revision'
     default_publish_touch:
       type: boolean
-      label: 'Default value for nodetype setting publish_touch'
+      label: 'Default value for entity type setting publish_touch'
     default_show_message_after_update:
       type: boolean
-      label: 'Default value for nodetype setting show_message_after_update'
+      label: 'Default value for entity type setting show_message_after_update'
     default_time:
       type: string
       label: 'Default Scheduling Time. This is used with the option to allow date only'
     default_unpublish_enable:
       type: boolean
-      label: 'Default value for nodetype setting unpublish_enable'
+      label: 'Default value for entity type setting unpublish_enable'
     default_unpublish_required:
       type: boolean
-      label: 'Default value for nodetype setting unpublish_required'
+      label: 'Default value for entity type setting unpublish_required'
     default_unpublish_revision:
       type: boolean
-      label: 'Default value for nodetype setting unpublish_revision'
+      label: 'Default value for entity type setting unpublish_revision'
+    hide_seconds:
+      type: boolean
+      label: 'Hide the seconds on the input control when entering a time'
     lightweight_cron_access_key:
       type: string
       label: 'Lightweight cron access key'
@@ -68,25 +71,25 @@ scheduler.settings:
       type: string
       label: 'Time part of the full format'
 
-node.type.*.third_party.scheduler:
+node.type.*.third_party.scheduler: &third_party_settings_alias
   type: mapping
-  label: 'Scheduler content type settings'
+  label: 'Scheduler entity type settings'
   mapping:
     expand_fieldset:
       type: string
-      label: 'Conditions under which to expand the date input fieldset or vertical tab'
+      label: 'Conditions under which to expand the date input fieldset or vertical tab ("when_required" or "always")'
     fields_display_mode:
       type: string
-      label: 'The way the scheduling fields are displayed in the node form'
+      label: 'The way the scheduling fields are displayed in the edit form ("vertical_tab" or "fieldset")'
     publish_enable:
       type: boolean
       label: 'Enable scheduled publishing'
     publish_past_date:
       type: string
-      label: 'Action to be taken for publication dates in the past'
+      label: 'Action to be taken for publication dates in the past ("error", "publish" or "schedule")'
     publish_past_date_created:
       type: boolean
-      label: 'Change content creation date for past dates to avoid "changed" being earlier than "created"'
+      label: 'Change entity creation date for past dates to avoid "changed" being earlier than "created"'
     publish_required:
       type: boolean
       label: 'Require scheduled publishing'
@@ -95,10 +98,10 @@ node.type.*.third_party.scheduler:
       label: 'Create a new revision on publishing'
     publish_touch:
       type: boolean
-      label: 'Change content creation time to match the scheduled publish time'
+      label: 'Change entity creation time to match the scheduled publish time'
     show_message_after_update:
       type: boolean
-      label: 'Show a message after updating content which is scheduled'
+      label: 'Show a message after updating an entity which is scheduled'
     unpublish_enable:
       type: boolean
       label: 'Enable scheduled unpublishing'
@@ -108,3 +111,15 @@ node.type.*.third_party.scheduler:
     unpublish_revision:
       type: boolean
       label: 'Create a new revision on unpublishing'
+
+# Use the saved alias to repeat the same schema for media.type and
+# commerce_product.commerce_product_type. Adding a separate comment betweeen the
+# two definitions below causes a "cannot parse" error.
+media.type.*.third_party.scheduler:
+  *third_party_settings_alias
+
+commerce_product.commerce_product_type.*.third_party.scheduler:
+  *third_party_settings_alias
+
+taxonomy.vocabulary.*.third_party.scheduler:
+  *third_party_settings_alias
diff --git a/web/modules/scheduler/config/schema/workbench_moderation_actions_fix.schema.yml b/web/modules/scheduler/config/schema/workbench_moderation_actions_fix.schema.yml
new file mode 100644
index 0000000000000000000000000000000000000000..0469c74d98a63fefb59ca25e25c9acf88f5f3da3
--- /dev/null
+++ b/web/modules/scheduler/config/schema/workbench_moderation_actions_fix.schema.yml
@@ -0,0 +1,5 @@
+# Temporary fix for workbench_moderation_actions
+# See https://www.drupal.org/project/workbench_moderation_actions/issues/3281948 
+action.configuration.state_change:*:
+  type: action_configuration_default
+  label: 'State Change configuration'
diff --git a/web/modules/scheduler/css/styling.css b/web/modules/scheduler/css/styling.css
new file mode 100644
index 0000000000000000000000000000000000000000..a36281c564d56d7df8920de2ef749dddf7a3f396
--- /dev/null
+++ b/web/modules/scheduler/css/styling.css
@@ -0,0 +1,12 @@
+/* Without a width value, the button extends 100% of the form */
+.scheduler-admin-form > .dropbutton-wrapper {
+  width: 30%;
+}
+
+/* Add default background to dropbutton items in Claro theme. This should only
+   affect the non-link group title items, and avoid them being transparent.
+   See https://www.drupal.org/project/scheduler/issues/3316719
+   and https://www.drupal.org/project/drupal/issues/3317323 */
+.scheduler-admin-form .dropbutton__item:first-of-type ~ .dropbutton__item {
+  background-color: #f2f4f5;
+}
diff --git a/web/modules/scheduler/drupalci.yml b/web/modules/scheduler/drupalci.yml
index 7940044000ac8ba53862c00813f0f78a8fb4bc8e..394613c021b953d37e2c5ebd771dedad40d20d58 100644
--- a/web/modules/scheduler/drupalci.yml
+++ b/web/modules/scheduler/drupalci.yml
@@ -4,18 +4,41 @@ build:
     validate_codebase:
       phplint:
       container_composer:
+      container_command:
+        commands:
+      host_command:
+        commands:
+          # Apply patch from https://www.drupal.org/project/drupalci_testbot/issues/3251817
+          # @todo Remove this when Drupal 9.4.9 and 9.5.0 and 10.0.0 have been released.
+          - "cd ${SOURCE_DIR} && sudo -u www-data curl https://www.drupal.org/files/issues/2021-11-30/3251817-4.run-tests-with-multiple-groups.patch | sudo -u www-data patch -p1 --verbose"
       csslint:
       eslint:
       phpcs:
-      # Static analysis for uses of @deprecated code.
       phpstan:
         halt-on-fail: false
     testing:
-      run_tests.standard:
+      container_command:
+        commands:
+          # Rule 3.0-alpha7 is not compatible with PHP8.1 but the dev version has been fixed.
+          - "cd ${SOURCE_DIR} && sudo -u www-data composer require drupal/rules:3.x-dev"
+          # Get workbench moderation modules when testing with Drupal 9.
+          # Use * because only the dev version of WBMA is compatible with D9.
+          - 'drush core:status | awk "NR==1{print \$2\$3\$4}"'
+          - 'drush core:status | awk "NR==1{print \$2\$3\$4}" | grep version:9 && sudo -u www-data composer require drupal/workbench_moderation drupal/workbench_moderation_actions:*'
+          # Show the eslint version
+          - "${SOURCE_DIR}/core/node_modules/.bin/eslint --version"
+      run_tests.functional:
         types: 'PHPUnit-Functional'
-        suppress-deprecations: true
+        testgroups: '--all'
+        # The groups are 'scheduler,scheduler_api,scheduler_rules_integration'
+        suppress-deprecations: false
+      run_tests.kernel:
+        types: 'PHPUnit-Kernel'
+        testgroups: 'scheduler_kernel'
+        suppress-deprecations: false
       run_tests.js:
         types: 'PHPUnit-FunctionalJavascript'
-        suppress-deprecations: true
+        testgroups: 'scheduler_js'
+        suppress-deprecations: false
         concurrency: 1
         halt-on-fail: false
diff --git a/web/modules/scheduler/js/scheduler_default_time.js b/web/modules/scheduler/js/scheduler_default_time.js
index 65d1c2a362053938c473e27dc08d260215ad1c61..c1b90e3a7b4bf125b4cc57fb29a12eec255dfeec 100644
--- a/web/modules/scheduler/js/scheduler_default_time.js
+++ b/web/modules/scheduler/js/scheduler_default_time.js
@@ -3,22 +3,28 @@
  * JQuery to set default time for Scheduler DateTime Widget.
  */
 
-(function ($, drupalSettings) {
+(function ($, drupalSettings, once) {
 
   'use strict';
 
   /**
    * Provide default time if schedulerDefaultTime is set.
    *
-   * schedulerDefaultTime is defined in scheduler_form_node_form_alter when the
-   * user is allowed to enter just a date. The value need to be pre-filled here
+   * schedulerDefaultTime is defined in _scheduler_entity_form_alter when the
+   * user is allowed to enter just a date. The values need to be pre-filled here
    * to avoid the browser validation 'please fill in this field' pop-up error
    * which is produced before the date widget valueCallback() can set the value.
    * @see https://www.drupal.org/project/scheduler/issues/2913829
    */
   Drupal.behaviors.setSchedulerDefaultTime = {
     attach: function (context) {
-      if (typeof drupalSettings.schedulerDefaultTime !== "undefined") {
+
+      // Drupal.behaviors are called many times per page. Using .once() adds the
+      // class onto the matched DOM element and uses this to prevent it running
+      // on subsequent calls.
+      const $default_time = once('default-time-done', '#edit-scheduler-settings', context);
+
+      if ($default_time.length && typeof drupalSettings.schedulerDefaultTime !== "undefined") {
         var operations = ["publish", "unpublish"];
         operations.forEach(function (value) {
           var element = $("input#edit-" + value + "-on-0-value-time", context);
@@ -28,6 +34,19 @@
           }
         });
       }
+
+      // Also use this jQuery behaviors function to set any pre-existing time
+      // values with seconds removed if those drupalSettings values exist. This
+      // is required by some browsers to make the seconds hidden.
+      if (typeof drupalSettings.schedulerHideSecondsPublishOn !== "undefined") {
+        var element = $("input#edit-publish-on-0-value-time", context);
+        element.val(drupalSettings.schedulerHideSecondsPublishOn);
+      }
+      if (typeof drupalSettings.schedulerHideSecondsUnpublishOn !== "undefined") {
+        var element = $("input#edit-unpublish-on-0-value-time", context);
+        element.val(drupalSettings.schedulerHideSecondsUnpublishOn);
+      }
+
     }
   };
-})(jQuery, drupalSettings);
+})(jQuery, drupalSettings, once);
diff --git a/web/modules/scheduler/js/scheduler_default_time_8x.js b/web/modules/scheduler/js/scheduler_default_time_8x.js
new file mode 100644
index 0000000000000000000000000000000000000000..7e2363f1e2f87eb09169dcf55c84ab6d4fed16ea
--- /dev/null
+++ b/web/modules/scheduler/js/scheduler_default_time_8x.js
@@ -0,0 +1,54 @@
+/**
+ * @file
+ * JQuery to set default time for Scheduler DateTime Widget.
+ *
+ * This is a legacy version to maintain compatibility with Drupal 8.9.
+ */
+
+(function ($, drupalSettings) {
+
+  'use strict';
+
+  /**
+   * Provide default time if schedulerDefaultTime is set.
+   *
+   * schedulerDefaultTime is defined in _scheduler_entity_form_alter when the
+   * user is allowed to enter just a date. The values need to be pre-filled here
+   * to avoid the browser validation 'please fill in this field' pop-up error
+   * which is produced before the date widget valueCallback() can set the value.
+   * @see https://www.drupal.org/project/scheduler/issues/2913829
+   */
+  Drupal.behaviors.setSchedulerDefaultTime = {
+    attach: function (context) {
+
+      // Drupal.behaviors are called many times per page. Using .once() adds the
+      // class onto the matched DOM element and uses this to prevent it running
+      // on subsequent calls.
+      const $default_time = $(context).find('#edit-scheduler-settings').once('default-time-done');
+
+      if ($default_time.length && typeof drupalSettings.schedulerDefaultTime !== "undefined") {
+        var operations = ["publish", "unpublish"];
+        operations.forEach(function (value) {
+          var element = $("input#edit-" + value + "-on-0-value-time", context);
+          // Only set the time when there is no value and the field is required.
+          if (element.val() === "" && element.prop("required")) {
+            element.val(drupalSettings.schedulerDefaultTime);
+          }
+        });
+      }
+
+      // Also use this jQuery behaviors function to set any pre-existing time
+      // values with seconds removed if those drupalSettings values exist. This
+      // is required by some browsers to make the seconds hidden.
+      if (typeof drupalSettings.schedulerHideSecondsPublishOn !== "undefined") {
+        var element = $("input#edit-publish-on-0-value-time", context);
+        element.val(drupalSettings.schedulerHideSecondsPublishOn);
+      }
+      if (typeof drupalSettings.schedulerHideSecondsUnpublishOn !== "undefined") {
+        var element = $("input#edit-unpublish-on-0-value-time", context);
+        element.val(drupalSettings.schedulerHideSecondsUnpublishOn);
+      }
+
+    }
+  };
+})(jQuery, drupalSettings);
diff --git a/web/modules/scheduler/migrations/d7_scheduler_settings.yml b/web/modules/scheduler/migrations/d7_scheduler_settings.yml
new file mode 100644
index 0000000000000000000000000000000000000000..6371cc80fab4d6f9982543642ec0f2ac385c567f
--- /dev/null
+++ b/web/modules/scheduler/migrations/d7_scheduler_settings.yml
@@ -0,0 +1,28 @@
+id: d7_scheduler_settings
+label: Scheduler configuration
+migration_tags:
+  - Drupal 7
+  - Configuration
+source:
+  plugin: variable
+  variables:
+    - scheduler_allow_date_only
+    - scheduler_default_time
+    - scheduler_date_format
+  source_module: scheduler
+process:
+  allow_date_only:
+    plugin: default_value
+    default_value: false
+    source: scheduler_allow_date_only
+  default_time:
+    plugin: default_value
+    default_value: '00:00:00'
+    source: scheduler_default_time
+  hide_seconds:
+    plugin: scheduler_hide_seconds
+    default_value: false
+    source: scheduler_date_format
+destination:
+  plugin: config
+  config_name: scheduler.settings
diff --git a/web/modules/scheduler/migrations/state/scheduler.migrate_drupal.yml b/web/modules/scheduler/migrations/state/scheduler.migrate_drupal.yml
new file mode 100644
index 0000000000000000000000000000000000000000..58d04cb5d3a0bedf813447b43396895d9fcdcfb0
--- /dev/null
+++ b/web/modules/scheduler/migrations/state/scheduler.migrate_drupal.yml
@@ -0,0 +1,3 @@
+finished:
+  7:
+    scheduler: scheduler
diff --git a/web/modules/scheduler/phpcs.xml.dist b/web/modules/scheduler/phpcs.xml.dist
index 07acbf8954df2df04ca1d40c1dcd7bb65716c7f2..89c0152cd92ef272b91d863fe186ad1b02dfd953 100644
--- a/web/modules/scheduler/phpcs.xml.dist
+++ b/web/modules/scheduler/phpcs.xml.dist
@@ -3,6 +3,11 @@
   <description>Default PHP CodeSniffer configuration for Scheduler.</description>
   <file>.</file>
 
+  <!-- Temporary fix until drupal.org testbot script is changed. This is also
+    compatible with running phpcs locally, and on Travis, so can be committed.
+    See https://www.drupal.org/project/drupalci_testbot/issues/3283978 -->
+  <config name="installed_paths" value="../../drupal/coder/coder_sniffer/,../../sirbrillig/phpcs-variable-analysis/,../../slevomat/coding-standard/"/>
+
   <!-- Initially include all Drupal and DrupalPractice sniffs. -->
   <rule ref="Drupal"/>
   <rule ref="DrupalPractice"/>
@@ -17,11 +22,31 @@
 
   <rule ref="DrupalPractice">
     <!-- Allow empty lines after comments, we don't like this rule. -->
-    <exclude name="DrupalPractice.Commenting.CommentEmptyLine"/>
+    <exclude name="DrupalPractice.Commenting.CommentEmptyLine.SpacingAfter"/>
+  </rule>
+  <rule ref="Drupal">
+    <!-- There appears to be two rules checking nearly the same thing. -->
+    <exclude name="Drupal.Commenting.InlineComment.SpacingAfter"/>
+  </rule>
+
+  <!-- This rule is disabled in Coder 8.3.10, but undefined variables will -->
+  <!-- be reported when using earlier versions. Hence re-enable the rule so -->
+  <!-- we do not get surprises when testing with other versions. -->
+  <rule ref="DrupalPractice.CodeAnalysis.VariableAnalysis.UndefinedVariable">
+    <severity>5</severity>
   </rule>
 
-  <!-- ignore these files -->
-  <exclude-pattern>_ignore*</exclude-pattern>
-  <exclude-pattern>*interdif*</exclude-pattern>
+  <!-- Ignore all files that match these patterns. By default the full file -->
+  <!-- path is checked, unless type=relative is used. There is an implied * -->
+  <!-- wildcard at each end and periods and slashes must be escaped using \ -->
+  <exclude-pattern>\/_+ignore</exclude-pattern>
+  <exclude-pattern>interdif</exclude-pattern>
+
+  <!-- Increase the allowed line length for inline array declarations. -->
+  <rule ref="Drupal.Arrays.Array">
+    <properties>
+      <property name="lineLimit" value="120"/>
+    </properties>
+  </rule>
 
 </ruleset>
diff --git a/web/modules/scheduler/plugins/content_types/scheduler_form_pane.inc b/web/modules/scheduler/plugins/content_types/scheduler_form_pane.inc
index 73fa1359f9ae6caf55c74d26969b2e1969b0e25d..9e6e97804337eec5a6e72c974a59245e16f283dd 100644
--- a/web/modules/scheduler/plugins/content_types/scheduler_form_pane.inc
+++ b/web/modules/scheduler/plugins/content_types/scheduler_form_pane.inc
@@ -5,16 +5,20 @@
  * Scheduling options for node forms.
  *
  * This content type contains the publish and unpublish scheduling date fields.
+ *
+ * @todo This file was introduced in Scheduler 7.x - is it still usable in 8.x?
+ * The Ctools module no longer has a path /plugins/content_types/node_form
  */
 
 use Drupal\Core\Form\FormStateInterface;
 
+// phpcs:ignore DrupalPractice.CodeAnalysis.VariableAnalysis.UnusedVariable
 $plugin = [
   'single' => TRUE,
   'edit form' => 'scheduler_form_pane_node_form_menu_content_type_edit_form',
   'render callback' => 'scheduler_form_pane_content_type_render',
   'title' => t('Node form scheduler'),
-  'icon' => drupal_get_path('module', 'ctools') . '/plugins/content_types/node_form/icon_node_form.png',
+  'icon' => \Drupal::service('extension.list.module')->getPath('ctools') . '/plugins/content_types/node_form/icon_node_form.png',
   'description' => t('Scheduler date options on the Node form.'),
   'required context' => new ctools_context_required(t('Form'), 'node_form'),
   'category' => t('Form'),
diff --git a/web/modules/scheduler/scheduler.admin.inc b/web/modules/scheduler/scheduler.admin.inc
deleted file mode 100644
index a1b9b0f8d4e2b69319536a8a350f709a5b5f9ffd..0000000000000000000000000000000000000000
--- a/web/modules/scheduler/scheduler.admin.inc
+++ /dev/null
@@ -1,207 +0,0 @@
-<?php
-
-/**
- * @file
- * Administration forms for the Scheduler module.
- */
-
-use Drupal\Core\Form\FormStateInterface;
-use Drupal\node\NodeTypeInterface;
-
-/**
- * Helper function for the real hook_form_node_type_form_alter().
- *
- * @see scheduler_form_node_type_form_alter()
- */
-function _scheduler_form_node_type_form_alter(array &$form, FormStateInterface $form_state) {
-  $config = \Drupal::config('scheduler.settings');
-
-  /** @var \Drupal\node\NodeTypeInterface $type */
-  $type = $form_state->getFormObject()->getEntity();
-
-  $form['#attached']['library'][] = 'scheduler/admin';
-  $form['#attached']['library'][] = 'scheduler/vertical-tabs';
-
-  $form['scheduler'] = [
-    '#type' => 'details',
-    '#title' => t('Scheduler'),
-    '#weight' => 35,
-    '#group' => 'additional_settings',
-  ];
-
-  // Publishing options.
-  $form['scheduler']['publish'] = [
-    '#type' => 'details',
-    '#title' => t('Publishing'),
-    '#weight' => 1,
-    '#group' => 'scheduler',
-    '#open' => TRUE,
-  ];
-  $form['scheduler']['publish']['scheduler_publish_enable'] = [
-    '#type' => 'checkbox',
-    '#title' => t('Enable scheduled publishing for this content type'),
-    '#default_value' => $type->getThirdPartySetting('scheduler', 'publish_enable', $config->get('default_publish_enable')),
-  ];
-  $form['scheduler']['publish']['scheduler_publish_touch'] = [
-    '#type' => 'checkbox',
-    '#title' => t('Change content creation time to match the scheduled publish time'),
-    '#default_value' => $type->getThirdPartySetting('scheduler', 'publish_touch', $config->get('default_publish_touch')),
-    '#states' => [
-      'visible' => [
-        ':input[name="scheduler_publish_enable"]' => ['checked' => TRUE],
-      ],
-    ],
-  ];
-  $form['scheduler']['publish']['scheduler_publish_required'] = [
-    '#type' => 'checkbox',
-    '#title' => t('Require scheduled publishing'),
-    '#default_value' => $type->getThirdPartySetting('scheduler', 'publish_required', $config->get('default_publish_required')),
-    '#states' => [
-      'visible' => [
-        ':input[name="scheduler_publish_enable"]' => ['checked' => TRUE],
-      ],
-    ],
-  ];
-  $form['scheduler']['publish']['scheduler_publish_revision'] = [
-    '#type' => 'checkbox',
-    '#title' => t('Create a new revision on publishing'),
-    '#default_value' => $type->getThirdPartySetting('scheduler', 'publish_revision', $config->get('default_publish_revision')),
-    '#states' => [
-      'visible' => [
-        ':input[name="scheduler_publish_enable"]' => ['checked' => TRUE],
-      ],
-    ],
-  ];
-  $form['scheduler']['publish']['advanced'] = [
-    '#type' => 'details',
-    '#title' => t('Advanced options'),
-    '#open' => FALSE,
-    '#states' => [
-      'visible' => [
-        ':input[name="scheduler_publish_enable"]' => ['checked' => TRUE],
-      ],
-    ],
-  ];
-  $form['scheduler']['publish']['advanced']['scheduler_publish_past_date'] = [
-    '#type' => 'radios',
-    '#title' => t('Action to be taken for publication dates in the past'),
-    '#default_value' => $type->getThirdPartySetting('scheduler', 'publish_past_date', $config->get('default_publish_past_date')),
-    '#options' => [
-      'error' => t('Display an error message - do not allow dates in the past'),
-      'publish' => t('Publish the content immediately after saving'),
-      'schedule' => t('Schedule the content for publication on the next cron run'),
-    ],
-  ];
-  $form['scheduler']['publish']['advanced']['scheduler_publish_past_date_created'] = [
-    '#type' => 'checkbox',
-    '#title' => t("Change content creation time to match the published time for dates before the content was created"),
-    '#description' => t("The created time will only be altered when the scheduled publishing time is earlier than the existing content creation time"),
-    '#default_value' => $type->getThirdPartySetting('scheduler', 'publish_past_date_created', $config->get('default_publish_past_date_created')),
-    // This option is not relevant if the full 'change creation time' option is
-    // selected, or when past dates are not allowed. Hence only show it when
-    // the main option is not checked and the past dates option is not 'error'.
-    '#states' => [
-      'visible' => [
-        ':input[name="scheduler_publish_touch"]' => ['checked' => FALSE],
-        ':input[name="scheduler_publish_past_date"]' => ['!value' => 'error'],
-      ],
-    ],
-  ];
-
-  // Unpublishing options.
-  $form['scheduler']['unpublish'] = [
-    '#type' => 'details',
-    '#title' => t('Unpublishing'),
-    '#weight' => 2,
-    '#group' => 'scheduler',
-    '#open' => TRUE,
-  ];
-  $form['scheduler']['unpublish']['scheduler_unpublish_enable'] = [
-    '#type' => 'checkbox',
-    '#title' => t('Enable scheduled unpublishing for this content type'),
-    '#default_value' => $type->getThirdPartySetting('scheduler', 'unpublish_enable', $config->get('default_unpublish_enable')),
-  ];
-  $form['scheduler']['unpublish']['scheduler_unpublish_required'] = [
-    '#type' => 'checkbox',
-    '#title' => t('Require scheduled unpublishing'),
-    '#default_value' => $type->getThirdPartySetting('scheduler', 'unpublish_required', $config->get('default_unpublish_required')),
-    '#states' => [
-      'visible' => [
-        ':input[name="scheduler_unpublish_enable"]' => ['checked' => TRUE],
-      ],
-    ],
-  ];
-  $form['scheduler']['unpublish']['scheduler_unpublish_revision'] = [
-    '#type' => 'checkbox',
-    '#title' => t('Create a new revision on unpublishing'),
-    '#default_value' => $type->getThirdPartySetting('scheduler', 'unpublish_revision', $config->get('default_unpublish_revision')),
-    '#states' => [
-      'visible' => [
-        ':input[name="scheduler_unpublish_enable"]' => ['checked' => TRUE],
-      ],
-    ],
-  ];
-
-  // The 'node_edit_layout' fieldset contains options to alter the layout of
-  // node edit pages.
-  $form['scheduler']['node_edit_layout'] = [
-    '#type' => 'details',
-    '#title' => t('Node edit page'),
-    '#weight' => 3,
-    '#group' => 'scheduler',
-    // The #states processing only caters for AND and does not do OR. So to set
-    // the state to visible if either of the boxes are ticked we use the fact
-    // that logical 'X = A or B' is equivalent to 'not X = not A and not B'.
-    '#states' => [
-      '!visible' => [
-        ':input[name="scheduler_publish_enable"]' => ['!checked' => TRUE],
-        ':input[name="scheduler_unpublish_enable"]' => ['!checked' => TRUE],
-      ],
-    ],
-  ];
-  $form['scheduler']['node_edit_layout']['scheduler_fields_display_mode'] = [
-    '#type' => 'radios',
-    '#title' => t('Display scheduling options as'),
-    '#default_value' => $type->getThirdPartySetting('scheduler', 'fields_display_mode', $config->get('default_fields_display_mode')),
-    '#options' => [
-      'vertical_tab' => t('Vertical tab'),
-      'fieldset' => t('Separate fieldset'),
-    ],
-    '#description' => t('Use this option to specify how the scheduling options will be displayed when editing a node.'),
-  ];
-  $form['scheduler']['node_edit_layout']['scheduler_expand_fieldset'] = [
-    '#type' => 'radios',
-    '#title' => t('Expand fieldset or vertical tab'),
-    '#default_value' => $type->getThirdPartySetting('scheduler', 'expand_fieldset', $config->get('default_expand_fieldset')),
-    '#options' => [
-      'when_required' => t('Expand only when a scheduled date exists or when a date is required'),
-      'always' => t('Always open the fieldset or vertical tab'),
-    ],
-  ];
-  $form['scheduler']['node_edit_layout']['scheduler_show_message_after_update'] = [
-    '#type' => 'checkbox',
-    '#prefix' => '<strong>' . t('Show message') . '</strong>',
-    '#title' => t('Show a confirmation message when scheduled content is saved'),
-    '#default_value' => $type->getThirdPartySetting('scheduler', 'show_message_after_update', $config->get('default_show_message_after_update')),
-  ];
-
-  $form['#entity_builders'][] = 'scheduler_form_node_type_form_builder';
-}
-
-/**
- * Entity builder for the node type form with scheduler options.
- */
-function scheduler_form_node_type_form_builder($entity_type, NodeTypeInterface $type, &$form, FormStateInterface $form_state) {
-  $type->setThirdPartySetting('scheduler', 'expand_fieldset', $form_state->getValue('scheduler_expand_fieldset'));
-  $type->setThirdPartySetting('scheduler', 'fields_display_mode', $form_state->getValue('scheduler_fields_display_mode'));
-  $type->setThirdPartySetting('scheduler', 'publish_enable', $form_state->getValue('scheduler_publish_enable'));
-  $type->setThirdPartySetting('scheduler', 'publish_past_date', $form_state->getValue('scheduler_publish_past_date'));
-  $type->setThirdPartySetting('scheduler', 'publish_past_date_created', $form_state->getValue('scheduler_publish_past_date_created'));
-  $type->setThirdPartySetting('scheduler', 'publish_required', $form_state->getValue('scheduler_publish_required'));
-  $type->setThirdPartySetting('scheduler', 'publish_revision', $form_state->getValue('scheduler_publish_revision'));
-  $type->setThirdPartySetting('scheduler', 'publish_touch', $form_state->getValue('scheduler_publish_touch'));
-  $type->setThirdPartySetting('scheduler', 'show_message_after_update', $form_state->getValue('scheduler_show_message_after_update'));
-  $type->setThirdPartySetting('scheduler', 'unpublish_enable', $form_state->getValue('scheduler_unpublish_enable'));
-  $type->setThirdPartySetting('scheduler', 'unpublish_required', $form_state->getValue('scheduler_unpublish_required'));
-  $type->setThirdPartySetting('scheduler', 'unpublish_revision', $form_state->getValue('scheduler_unpublish_revision'));
-}
diff --git a/web/modules/scheduler/scheduler.api.php b/web/modules/scheduler/scheduler.api.php
index 3304947102555660676f30bf2ab9ea009b3c6d2f..5ecec2f69bd406eb6418c9c4590178a940ee0389 100644
--- a/web/modules/scheduler/scheduler.api.php
+++ b/web/modules/scheduler/scheduler.api.php
@@ -3,6 +3,12 @@
 /**
  * @file
  * API documentation for the Scheduler module.
+ *
+ * Each of these hook functions has a general version which is invoked for all
+ * entity types, and a specific variant with _{type}_ in the name, invoked when
+ * processing that specific entity type.
+ *
+ * phpcs:disable DrupalPractice.CodeAnalysis.VariableAnalysis.UndefinedVariable
  */
 
 /**
@@ -11,229 +17,326 @@
  */
 
 /**
- * Hook function to add node ids to the list being processed.
+ * Hook function to add entity ids to the list being processed.
  *
- * This hook allows modules to add more node ids into the list being processed
+ * This hook allows modules to add more entity ids into the list being processed
  * in the current cron run. It is invoked during cron runs only. This function
  * is retained for backwards compatibility but is superceded by the more
- * flexible hook_scheduler_nid_list_alter().
+ * flexible hook_scheduler_list_alter().
  *
- * @param string $action
- *   The action being done to the node - 'publish' or 'unpublish'.
+ * @param string $process
+ *   The process being done - 'publish' or 'unpublish'.
+ * @param string $entityTypeId
+ *   The type of the entity being processed, for example 'node' or 'media'.
  *
  * @return array
- *   Array of node ids to add to the existing list of nodes to be processed.
+ *   Array of ids to add to the existing list to be processed. Duplicates are
+ *   removed when all hooks have been invoked.
  */
-function hook_scheduler_nid_list($action) {
-  $nids = [];
-  // Do some processing to add new node ids into $nids.
-  return $nids;
+function hook_scheduler_list($process, $entityTypeId) {
+  $ids = [];
+  // Do some processing to add ids to the $ids array.
+  return $ids;
 }
 
 /**
- * Hook function to manipulate the list of nodes being processed.
+ * Entity-type specific version of hook_scheduler_list().
  *
- * This hook allows modules to add or remove node ids from the list being
- * processed in the current cron run. It is invoked during cron runs only. It
- * can do everything that hook_scheduler_nid_list() does, plus more.
+ * The parameters and return value match the general variant of this hook. The
+ * $entityTypeId parameter is included for ease and consistency, but is not
+ * strictly necessary as it will always match the TYPE in the function name.
+ */
+function hook_scheduler_TYPE_list($process, $entityTypeId) {
+}
+
+/**
+ * Hook function to manipulate the list of entity ids being processed.
  *
- * @param array $nids
- *   An array of node ids being processed.
- * @param string $action
- *   The action being done to the node - 'publish' or 'unpublish'.
+ * This hook allows modules to add or remove entity ids from the list being
+ * processed in the current cron run. It is invoked during cron runs only.
  *
- * @return array
- *   The full array of node ids to process, adjusted as required.
+ * @param array $ids
+ *   The array of entity ids being processed.
+ * @param string $process
+ *   The process being done - 'publish' or 'unpublish'.
+ * @param string $entityTypeId
+ *   The type of the entity being processed, for example 'node' or 'media'.
  */
-function hook_scheduler_nid_list_alter(array &$nids, $action) {
-  // Do some processing to add or remove node ids.
-  return $nids;
+function hook_scheduler_list_alter(array &$ids, $process, $entityTypeId) {
+  if ($process == 'publish' && $some_condition) {
+    // Set a publish_on date and add the id.
+    $entity->set('publish_on', \Drupal::time()->getRequestTime())->save();
+    $ids[] = $id;
+  }
+  if ($process == 'unpublish' && $some_other_condition) {
+    // Remove the id.
+    $ids = array_diff($ids, [$id]);
+  }
+  // No return is necessary because $ids is passed by reference. Duplicates are
+  // removed when all hooks have been invoked.
 }
 
 /**
- * Hook function to deny or allow a node to be published.
+ * Entity-type specific version of hook_scheduler_list_alter().
  *
- * This hook gives modules the ability to prevent publication of a node at the
- * scheduled time. The node may be scheduled, and an attempt to publish it will
- * be made during the first cron run after the publishing time. If this hook
- * returns FALSE the node will not be published. Attempts at publishing will
- * continue on each subsequent cron run until this hook returns TRUE.
+ * The parameters match the general variant of this hook.
+ */
+function hook_scheduler_TYPE_list_alter(array &$ids, $process, $entityTypeId) {
+}
+
+/**
+ * Hook function to deny publishing of an entity.
  *
- * @param \Drupal\node\NodeInterface $node
- *   The scheduled node that is about to be published.
+ * This hook gives modules the ability to prevent publication of an entity. The
+ * entity may be scheduled, and an attempt to publish it will be made during the
+ * first cron run after the publishing time. If any implementation of this hook
+ * function returns FALSE the entity will not be published. Attempts to publish
+ * will continue on each subsequent cron run, and the entity will be published
+ * when no hook prevents it.
  *
- * @return bool
- *   TRUE if the node can be published, FALSE if it should not be published.
+ * @param \Drupal\Core\Entity\EntityInterface $entity
+ *   The scheduled entity that is about to be published.
+ *
+ * @return bool|null
+ *   FALSE if the entity should not be published. TRUE or NULL will not affect
+ *   the outcome.
  */
-function hook_scheduler_allow_publishing(NodeInterface $node) {
-  // If there is no 'approved' field do nothing to change the result.
-  if (!isset($node->field_approved)) {
-    $allowed = TRUE;
-  }
-  else {
-    // Prevent publication of nodes that do not have the 'Approved for
-    // publication by the CEO' checkbox ticked.
-    $allowed = !empty($node->field_approved->value);
-
-    // If publication is denied then inform the user why. This message will be
-    // displayed during node edit and save.
-    if (!$allowed) {
-      \Drupal::messenger()->addMessage(t('The content will only be published after approval by the CEO.'), 'status', FALSE);
+function hook_scheduler_publishing_allowed(EntityInterface $entity) {
+  // Do some logic here ...
+  $allowed = !empty($entity->field_approved->value);
+  // If publication is denied then inform the user why. This message will be
+  // displayed during entity edit and save.
+  if (!$allowed) {
+    \Drupal::messenger()->addMessage(t('The content will only be published after approval.'), 'status', FALSE);
+    // If the time is in the past it means that the action has been prevented,
+    // so write a dblog message to show this.
+    if ($entity->publish_on->value <= \Drupal::time()->getRequestTime()) {
+      if ($entity->id() && $entity->hasLinkTemplate('canonical')) {
+        $link = $entity->toLink(t('View'))->toString();
+      }
+      \Drupal::logger('scheduler_api_test')->warning('Publishing of "%title" is prevented until approved.', [
+        '%title' => $entity->label(),
+        'link' => $link ?? NULL,
+      ]);
     }
   }
-
   return $allowed;
 }
 
 /**
- * Hook function to deny or allow a node to be unpublished.
+ * Entity-type specific version of hook_scheduler_publishing_allowed().
+ *
+ * The parameters and return match the general variant of this hook.
+ */
+function hook_scheduler_TYPE_publishing_allowed(EntityInterface $entity) {
+}
+
+/**
+ * Hook function to deny unpublishing of an entity.
  *
- * This hook gives modules the ability to prevent unpblication of a node at the
- * scheduled time. The node may be scheduled, and an attempt to unpublish it
- * will be made during the first cron run after the unpublishing time. If this
- * hook returns FALSE the node will not be unpublished. Attempts at unpublishing
- * will continue on each subsequent cron run until this hook returns TRUE.
+ * This hook gives modules the ability to prevent unpublication of an entity.
+ * The entity may be scheduled, and an attempt to unpublish it will be made
+ * during the first cron run after the unpublishing time. If any implementation
+ * of this hook function returns FALSE the entity will not be unpublished.
+ * Attempts to unpublish will continue on each subsequent cron run, and the
+ * entity will be unpublished when no hook prevents it.
  *
- * @param \Drupal\node\NodeInterface $node
- *   The scheduled node that is about to be unpublished.
+ * @param \Drupal\Core\Entity\EntityInterface $entity
+ *   The scheduled entity that is about to be unpublished.
  *
- * @return bool
- *   TRUE if the node can be unpublished, FALSE if it should not be unpublished.
+ * @return bool|null
+ *   FALSE if the entity should not be unpublished. TRUE or NULL will not affect
+ *   the outcome.
  */
-function hook_scheduler_allow_unpublishing(NodeInterface $node) {
+function hook_scheduler_unpublishing_allowed(EntityInterface $entity) {
   $allowed = TRUE;
-
-  // Prevent unpublication of competition entries if not all prizes have been
-  // claimed.
-  if ($node->getType() == 'competition' && $items = $node->field_competition_prizes->getValue()) {
+  // Prevent unpublication of competitions if not all prizes have been claimed.
+  if ($entity->getEntityTypeId() == 'competition' && $items = $entity->field_competition_prizes->getValue()) {
     $allowed = (bool) count($items);
 
     // If unpublication is denied then inform the user why. This message will be
-    // displayed during node edit and save.
+    // displayed during entity edit and save.
     if (!$allowed) {
-      \Drupal::messenger()->addMessage(t('The competition will only be unpublished after all prizes have been claimed by the winners.'));
+      \Drupal::messenger()->addMessage(t('The competition will only be unpublished after all prizes have been claimed.'));
     }
   }
-
   return $allowed;
 }
 
+/**
+ * Entity-type specific version of hook_scheduler_unpublishing_allowed().
+ *
+ * The parameters and return match the general variant of this hook.
+ */
+function hook_scheduler_TYPE_unpublishing_allowed(EntityInterface $entity) {
+}
+
 /**
  * Hook function to hide the Publish On field.
  *
- * This hook is called from scheduler_form_node_form_alter(). It gives modules
- * the ability to hide the scheduler publish_on input field on the node edit
- * form. Note that it does not give the ability to force the field to be
- * displayed, as that could override a more significant setting. It can only be
- * used to hide the field.
+ * This hook is called from scheduler_form_alter() when adding or editing an
+ * entity. It gives modules the ability to hide the scheduler publish_on input
+ * field so that a date may not be entered or changed. Note that it does not
+ * give the ability to force the field to be displayed, as that could override a
+ * more significant setting. It can only be used to hide the field.
  *
  * This hook was introduced for scheduler_content_moderation_integration.
+ * See https://www.drupal.org/project/scheduler/issues/2798689
  *
  * @param array $form
  *   An associative array containing the structure of the form, as used in
  *   hook_form_alter().
  * @param \Drupal\Core\Form\FormStateInterface $form_state
  *   The current state of the form, as used in hook_form_alter().
- * @param \Drupal\node\NodeInterface $node
- *   The $node object of the node being editted.
- *
- * @see https://www.drupal.org/project/scheduler/issues/2798689
+ * @param \Drupal\Core\Entity\EntityInterface $entity
+ *   The entity object being added or edited.
  *
  * @return bool
  *   TRUE to hide the publish_on field.
  *   FALSE or NULL to leave the setting unchanged.
  */
-function hook_scheduler_hide_publish_on_field(array $form, FormStateInterface $form_state, NodeInterface $node) {
-  return FALSE;
+function hook_scheduler_hide_publish_date(array $form, FormStateInterface $form_state, EntityInterface $entity) {
+  if ($some_condition) {
+    return TRUE;
+  }
+}
+
+/**
+ * Entity-type specific version of hook_scheduler_hide_publish_date().
+ *
+ * The parameters and return match the general variant of this hook.
+ */
+function hook_scheduler_TYPE_hide_publish_date(array $form, FormStateInterface $form_state, EntityInterface $entity) {
 }
 
 /**
  * Hook function to hide the Unpublish On field.
  *
- * This hook is called from scheduler_form_node_form_alter(). It gives modules
- * the ability to hide the scheduler unpublish_on input field on the node edit
- * form. Note that it does not give the ability to force the field to be
- * displayed, as that could override a more significant setting. It can only be
- * used to hide the field.
+ * This hook is called from scheduler_form_alter() when adding or editing an
+ * entity. It gives modules the ability to hide the scheduler unpublish_on input
+ * field so that a date may not be entered or changed. Note that it does not
+ * give the ability to force the field to be displayed, as that could override a
+ * more significant setting. It can only be used to hide the field.
  *
  * This hook was introduced for scheduler_content_moderation_integration.
+ * See https://www.drupal.org/project/scheduler/issues/2798689
  *
  * @param array $form
  *   An associative array containing the structure of the form, as used in
  *   hook_form_alter().
  * @param \Drupal\Core\Form\FormStateInterface $form_state
  *   The current state of the form, as used in hook_form_alter().
- * @param \Drupal\node\NodeInterface $node
- *   The $node object of the node being editted.
- *
- * @see https://www.drupal.org/project/scheduler/issues/2798689
+ * @param \Drupal\Core\Entity\EntityInterface $entity
+ *   The entity object being added or edited.
  *
  * @return bool
  *   TRUE to hide the unpublish_on field.
  *   FALSE or NULL to leave the setting unchanged.
  */
-function hook_scheduler_hide_unpublish_on_field(array $form, FormStateInterface $form_state, NodeInterface $node) {
-  return FALSE;
+function hook_scheduler_hide_unpublish_date(array $form, FormStateInterface $form_state, EntityInterface $entity) {
+  if ($some_condition) {
+    return TRUE;
+  }
 }
 
 /**
- * Hook function to process the publish action for a node.
+ * Entity-type specific version of hook_scheduler_hide_unpublish_date().
  *
- * This hook is called from schedulerManger::publish() and allows oher modules
- * to process the publish action on a node during a cron run. The other module
+ * The parameters and return match the general variant of this hook.
+ */
+function hook_scheduler_TYPE_hide_unpublish_date(array $form, FormStateInterface $form_state, EntityInterface $entity) {
+}
+
+/**
+ * Hook function to process the publish action for an entity.
+ *
+ * This hook is called from schedulerManger::publish() and allows other modules
+ * to process the publish action on the entity during a cron run. That module
  * may require different functionality to be executed instead of the default
- * publish process. If none of the invoked hook functions return a TRUE value
- * then Scheduler will process the node using the default publish action, just
- * as if no other hooks had been called.
+ * publish action. If all of the invoked hook functions return 0 then Scheduler
+ * will process the entity using the default publish action, just as if no hook
+ * functions had been called.
  *
  * This hook was introduced for scheduler_content_moderation_integration.
+ * See https://www.drupal.org/project/scheduler/issues/2798689
  *
- * @param \Drupal\node\NodeInterface $node
- *   The $node object of the node being published.
- *
- * @see https://www.drupal.org/project/scheduler/issues/2798689
+ * @param \Drupal\Core\Entity\EntityInterface $entity
+ *   The scheduled entity that is about to be published.
  *
  * @return int
- *   1 if this function has published the node or performed other such action
+ *   1 if this function has published the entity or performed other such action
  *     meaning that Scheduler should NOT process the default publish action.
  *   0 if nothing has been done and Scheduler should process the default publish
  *     action just as if this hook function did not exist.
  *   -1 if an error has occurred and Scheduler should abandon processing this
- *     node with no further action and move on to the next one.
+ *     entity with no further action and move on to the next one.
  */
-function hook_scheduler_publish_action(NodeInterface $node) {
+function hook_scheduler_publish_process(EntityInterface $entity) {
+  if ($big_problem) {
+    // Throw an exception here.
+    return -1;
+  }
+  if ($some_condition) {
+    // Do the publish processing here on the $entity.
+    $entity->setSomeValue();
+    return 1;
+  }
   return 0;
 }
 
 /**
- * Hook function to process the unpublish action for a node.
+ * Entity-type specific version of hook_scheduler_publish_process().
  *
- * This hook is called from schedulerManger::unpublish() and allows oher modules
- * to process the unpublish action on a node during a cron run. The other module
- * may require different functionality to be executed instead of the default
- * unpublish process. If none of the invoked hook functions return a TRUE value
- * then Scheduler will process the node using the default unpublish action, just
- * as if no other hooks had been called.
+ * The parameters and return match the general variant of this hook.
+ */
+function hook_scheduler_TYPE_publish_process(EntityInterface $entity) {
+}
+
+/**
+ * Hook function to process the unpublish action for an entity.
  *
- * This hook was introduced for scheduler_content_moderation_integration.
+ * This hook is called from schedulerManger::unpublish() and allows other
+ * modules to process the unpublish action on the entity during a cron run. That
+ * module may require different functionality to be executed instead of the
+ * default unpublish action. If all of the invoked hook functions return 0 then
+ * Scheduler will process the entity using the default unpublish action, just as
+ * if no hook functions had been called.
  *
- * @param \Drupal\node\NodeInterface $node
- *   The $node object of the node being unpublished.
+ * This hook was introduced for scheduler_content_moderation_integration.
+ * See https://www.drupal.org/project/scheduler/issues/2798689
  *
- * @see https://www.drupal.org/project/scheduler/issues/2798689
+ * @param \Drupal\Core\Entity\EntityInterface $entity
+ *   The scheduled entity that is about to be unpublished.
  *
  * @return int
- *   1 if this function has published the node or performed other such action
- *     meaning that Scheduler should NOT process the default publish action.
- *   0 if nothing has been done and Scheduler should process the default publish
- *     action just as if this hook function did not exist.
+ *   1 if this function has unpublished the entity or performed other actions
+ *     meaning that Scheduler should NOT process the default unpublish action.
+ *   0 if nothing has been done and Scheduler should process the default
+ *     unpublish action just as if this hook function did not exist.
  *   -1 if an error has occurred and Scheduler should abandon processing this
- *     node with no further action and move on to the next one.
+ *     entity with no further action and move on to the next one.
  */
-function hook_scheduler_unpublish_action(NodeInterface $node) {
+function hook_scheduler_unpublish_process(EntityInterface $entity) {
+  if ($big_problem) {
+    // Throw an exception here.
+    return -1;
+  }
+  if ($some_condition) {
+    // Do the unpublish processing here on the $entity.
+    $entity->setSomeValue();
+    return 1;
+  }
   return 0;
 }
 
+/**
+ * Entity-type specific version of hook_scheduler_unpublish_process().
+ *
+ * The parameters and return match the general variant of this hook.
+ */
+function hook_scheduler_TYPE_unpublish_process(EntityInterface $entity) {
+}
+
 /**
  * @} End of "addtogroup hooks".
  */
diff --git a/web/modules/scheduler/scheduler.drush.inc b/web/modules/scheduler/scheduler.drush.inc
deleted file mode 100644
index 09ebbfe3d8d0f006c9943e7564c98d3d02156fd0..0000000000000000000000000000000000000000
--- a/web/modules/scheduler/scheduler.drush.inc
+++ /dev/null
@@ -1,34 +0,0 @@
-<?php
-
-/**
- * @file
- * Drush commands for Scheduler.
- */
-
-/**
- * Implements hook_drush_command().
- */
-function scheduler_drush_command() {
-  $items = [];
-
-  $items['scheduler-cron'] = [
-    'description' => 'Lightweight cron to process scheduler tasks.',
-    'core' => ['8+'],
-    'aliases' => ['sch-cron'],
-    'category' => 'scheduler',
-    'options' => [
-      'nomsg' => 'to avoid the "cron completed" message being written to the terminal.',
-    ],
-  ];
-
-  return $items;
-}
-
-/**
- * Run lightweight scheduler cron.
- */
-function drush_scheduler_cron() {
-  \Drupal::service('scheduler.manager')->runLightweightCron();
-  $nomsg = drush_get_option('nomsg', NULL);
-  $nomsg ? NULL : \Drupal::messenger()->addMessage(t('Scheduler lightweight cron completed'));
-}
diff --git a/web/modules/scheduler/scheduler.info.yml b/web/modules/scheduler/scheduler.info.yml
index 9771102c617a589ce7192b0c15e51fe64282b85f..bf40ce1cf7d2608304539205c2fd0861f6678b97 100644
--- a/web/modules/scheduler/scheduler.info.yml
+++ b/web/modules/scheduler/scheduler.info.yml
@@ -1,11 +1,10 @@
 name: Scheduler
 type: module
-description: 'Publish and unpublish content automatically on specified dates and times.'
-core: 8.x
-core_version_requirement: ^8 || ^9
+description: 'Publish and unpublish content and entities automatically on specified dates and times.'
+core_version_requirement: ^8 || ^9 || ^10
 configure: scheduler.admin_form
 dependencies:
-  - drupal:system (>= 8.5)
+  - drupal:system (>= 8.8.3)
   - drupal:datetime
   - drupal:field
   - drupal:node
@@ -13,12 +12,15 @@ dependencies:
 test_dependencies:
   - rules:rules
   - devel:devel_generate
+  - drupal:media
+  - commerce:commerce
+  - drupal:taxonomy
 libraries:
-  - scheduler/admin
+  - admin-css
   - vertical-tabs
   - default-time
 
-# Information added by Drupal.org packaging script on 2020-06-06
-version: '8.x-1.3'
+# Information added by Drupal.org packaging script on 2022-11-20
+version: '2.0.0-rc8'
 project: 'scheduler'
-datestamp: 1591431340
+datestamp: 1668951020
diff --git a/web/modules/scheduler/scheduler.install b/web/modules/scheduler/scheduler.install
index 2561b11a1e16537bc7717663ec1840f42b44fb48..060feca6fea7b5c4acf446fdcd9c3557a73890b9 100644
--- a/web/modules/scheduler/scheduler.install
+++ b/web/modules/scheduler/scheduler.install
@@ -6,6 +6,7 @@
  */
 
 use Drupal\Core\Url;
+use Drupal\views\Entity\View;
 
 /**
  * Implements hook_requirements().
@@ -81,15 +82,19 @@ function scheduler_install() {
  * Implements hook_uninstall().
  */
 function scheduler_uninstall() {
-  // Delete the scheduled content view.
-  \Drupal::configFactory()->getEditable('views.view.scheduler_scheduled_content')->delete();
+  // This should not be necessary but incase the entity db tables or config have
+  // got out-of-step with the Scheduler plugins make sure all is up to date so
+  // that uninstalling will run OK.
+  $scheduler_manager = \Drupal::service('scheduler.manager');
+  $scheduler_manager->entityUpdate();
+  $scheduler_manager->entityRevert();
 }
 
 /**
  * Reset date and time formats to default.
  */
 function scheduler_update_8001() {
-  // See https://www.drupal.org/node/2799869
+  // See https://www.drupal.org/project/scheduler/issues/2799869
   $config = \Drupal::configFactory()->getEditable('scheduler.settings');
   $config
     ->set('date_format', 'Y-m-d H:i:s')
@@ -110,3 +115,150 @@ function scheduler_update_8101() {
     ->save();
   return t('Default set on for new option "Show a message after updating content"');
 }
+
+/**
+ * Update view - Move 'Scheduled' tab to be a local task under 'Content'.
+ */
+function scheduler_update_8102() {
+  // The text in the doc block above is shown on the update.php list.
+  // See https://www.drupal.org/project/scheduler/issues/3167193
+  $view = View::load('scheduler_scheduled_content');
+  if ($view) {
+    $display =& $view->getDisplay('overview');
+    $display['display_options']['menu']['description'] = 'Content scheduled for publishing and unpublishing';
+    $display['display_options']['menu']['type'] = 'normal';
+    $view->save();
+    return t('The "Scheduled" tab is now a "Scheduled content" sub-task under the "Content" tab');
+  }
+}
+
+/**
+ * Add date fields to any newly supported entity types.
+ */
+function scheduler_update_8201() {
+  // If modules that have scheduler plugin support are already installed when
+  // Scheduler is then upgraded to a version which includes the entity plugins,
+  // this update function will add the missing db fields.
+  $scheduler_manager = \Drupal::service('scheduler.manager');
+  if ($result = $scheduler_manager->entityUpdate()) {
+    return t('Added Scheduler date fields to the following entity types: %updated.', [
+      '%updated' => implode(', ', $result),
+    ]);
+  }
+  else {
+    return t('No database fields had to be added.');
+  }
+}
+
+/**
+ * Refresh views for supported entity types.
+ */
+function scheduler_update_8202() {
+  // The scheduled content view needs to be refreshed from source when upgrading
+  // to the entity plugin version of Scheduler. If the media or commerce modules
+  // are already enabled this will also load those new views from source.
+  $scheduler_manager = \Drupal::service('scheduler.manager');
+  if ($result = $scheduler_manager->viewsUpdate(['node', 'media', 'commerce_product'])) {
+    return t('Updated views: %updated.', ['%updated' => implode(', ', $result)]);
+  }
+  else {
+    return t('No views require updating.');
+  }
+}
+
+/**
+ * Update entity fields and scheduled view for Taxonomy Terms.
+ */
+function scheduler_update_8203() {
+  $scheduler_manager = \Drupal::service('scheduler.manager');
+  $output = [];
+  if ($result = $scheduler_manager->entityUpdate()) {
+    $output[] = t('Added Scheduler date fields to the following entity types: %updated.', [
+      '%updated' => implode(', ', $result),
+    ]);
+  }
+  if ($result = $scheduler_manager->viewsUpdate(['taxonomy_term'])) {
+    $output[] = t('Updated views: %updated.', ['%updated' => implode(', ', $result)]);
+  }
+  return $output ? implode('<br>', $output) : t('Nothing requires updating for Taxonomy Terms.');
+}
+
+/**
+ * Update Rules actions and conditions to use 'entity' context.
+ */
+function scheduler_update_8204() {
+  // The entity context names need to be 'entity' for all entity types, not
+  // 'node', 'media', 'commerce_product' or 'taxonomy_term'. This is for PHP8
+  // compatibility, fixing "Unknown named parameter in call_user_func_array()"
+  // See https://www.drupal.org/project/scheduler/issues/3276637
+  $rules = \Drupal::configFactory()->listAll('rules.reaction');
+  $rules_updated = [];
+  foreach ($rules as $config_id) {
+    $rule = \Drupal::configFactory()->getEditable($config_id);
+    $changed = FALSE;
+
+    // The expression array has 'conditions' and 'actions' elements which have
+    // the same structure, so can be fixed using the same loop process.
+    $expression = $rule->get('expression');
+    foreach (['condition_id' => 'conditions', 'action_id' => 'actions'] as $idx => $group) {
+      foreach ($expression[$group][$group] as $key => $cond_act) {
+        if (substr($cond_act[$idx], 0, 10) == 'scheduler_' && !empty($cond_act['context_mapping'])) {
+          foreach ($cond_act['context_mapping'] as $name => $value) {
+            if (in_array($name, ['node', 'media', 'commerce_product', 'taxonomy_term'])) {
+              // Replace the node/media/commerce_product key with 'entity'.
+              unset($expression[$group][$group][$key]['context_mapping'][$name]);
+              $expression[$group][$group][$key]['context_mapping']['entity'] = $value;
+              // Only add the rule label once.
+              $changed ?: $rules_updated[] = $rule->get('label');
+              $changed = TRUE;
+            }
+          }
+        }
+      }
+    }
+
+    // Replace the config value with the updated expression array.
+    if ($changed) {
+      $rule->set('expression', $expression);
+      $rule->save();
+    }
+  }
+
+  $output = empty($rules_updated) ? t('No reaction rules required updating with entity context.') :
+    \Drupal::translation()->formatPlural(count($rules_updated), '1 reaction rule updated with entity context', '@count reaction rules updated with entity context')
+    . '<br>' . implode('<br>', $rules_updated);
+  return $output;
+}
+
+/**
+ * Add date fields to any newly supported entity types, specifically Paragraphs.
+ */
+function scheduler_update_8205() {
+  return scheduler_update_8201();
+}
+
+/**
+ * Remove Scheduler fields and third_party_settings for Paragraph entity types.
+ */
+function scheduler_update_8207() {
+  // update_8206 had a typo, therefore replaced with update_8207.
+  // Check the module is enabled, to avoid 'Unknown entity type' message.
+  if (\Drupal::moduleHandler()->moduleExists('paragraphs') && ($result = \Drupal::service('scheduler.manager')->entityRevert(['paragraph']))) {
+    return t('%updated.', ['%updated' => implode(', ', $result)]);
+  }
+  else {
+    return t('No update required.');
+  }
+}
+
+/**
+ * Show/hide entity form fields to match Scheduling enabled/disabled settings.
+ */
+function scheduler_update_8208() {
+  if ($result = \Drupal::service('scheduler.manager')->resetFormDisplayFields()) {
+    return implode('</li><li>', $result);
+  }
+  else {
+    return t('No update required.');
+  }
+}
diff --git a/web/modules/scheduler/scheduler.libraries.yml b/web/modules/scheduler/scheduler.libraries.yml
index 861dca8351a70be51c012a4ce432135522b1b9f1..d0e9fb9b754560e34df99e94d9b3edaf95e4130e 100644
--- a/web/modules/scheduler/scheduler.libraries.yml
+++ b/web/modules/scheduler/scheduler.libraries.yml
@@ -2,10 +2,20 @@ vertical-tabs:
   js:
     js/scheduler_vertical_tabs.js: {}
   dependencies:
-      - core/jquery
-      - core/drupal.ajax
+    - core/jquery
+    - core/drupal.ajax
 default-time:
   js:
     js/scheduler_default_time.js: {}
   dependencies:
     - core/jquery
+    - core/once
+default-time-8x:
+  js:
+    js/scheduler_default_time_8x.js: {}
+  dependencies:
+    - core/jquery
+admin-css:
+  css:
+    component:
+      css/styling.css: { }
diff --git a/web/modules/scheduler/scheduler.links.task.yml b/web/modules/scheduler/scheduler.links.task.yml
index cc7ad7467a7e53a7906040796b8e1c2af9b37c33..df2debf193cff399a7b1e59e459819c6e75210c7 100644
--- a/web/modules/scheduler/scheduler.links.task.yml
+++ b/web/modules/scheduler/scheduler.links.task.yml
@@ -9,3 +9,9 @@ scheduler.cron_tab:
   title: Lightweight cron
   weight: 10
   base_route: scheduler.admin_form
+
+# Modules must not add hardcoded local tasks that depend on configuration, such
+# as a view which could be disabled. Therefore we use a deriver to allow
+# conditional logic to check for the views.
+scheduler.local_tasks:
+  deriver: 'Drupal\scheduler\Plugin\Derivative\DynamicLocalTasks'
diff --git a/web/modules/scheduler/scheduler.module b/web/modules/scheduler/scheduler.module
index 905502b05e2e757e2aff4e95269a601119990a99..2ae2be069418906ca18c24742e23036e2ac73a82 100644
--- a/web/modules/scheduler/scheduler.module
+++ b/web/modules/scheduler/scheduler.module
@@ -2,20 +2,27 @@
 
 /**
  * @file
- * Scheduler publishes and unpublishes nodes on dates specified by the user.
+ * Scheduler publishes and unpublishes entities on dates specified by the user.
  */
 
+use Drupal\Component\Plugin\Exception\PluginException;
 use Drupal\Component\Utility\Xss;
+use Drupal\Core\Action\Plugin\Action\UnpublishAction;
 use Drupal\Core\Entity\Display\EntityViewDisplayInterface;
+use Drupal\Core\Entity\EntityChangedInterface;
 use Drupal\Core\Entity\EntityInterface;
 use Drupal\Core\Entity\EntityTypeInterface;
 use Drupal\Core\Field\BaseFieldDefinition;
 use Drupal\Core\Form\FormStateInterface;
 use Drupal\Core\Routing\RouteMatchInterface;
 use Drupal\Core\Url;
-use Drupal\node\Entity\NodeType;
-use Drupal\scheduler\SchedulerEvent;
-use Drupal\scheduler\SchedulerEvents;
+use Drupal\migrate_drupal\Plugin\migrate\source\DrupalSqlBase;
+use Drupal\migrate\Exception\RequirementsException;
+use Drupal\migrate\Plugin\MigrateSourceInterface;
+use Drupal\migrate\Plugin\MigrationInterface;
+use Drupal\migrate\Row;
+use Drupal\workbench_moderation\Plugin\Action\ModerationOptOutPublishNode;
+use Drupal\workbench_moderation\Plugin\Action\ModerationOptOutUnpublishNode;
 
 /**
  * Implements hook_help().
@@ -25,18 +32,13 @@ function scheduler_help($route_name, RouteMatchInterface $route_match) {
   switch ($route_name) {
     case 'help.page.scheduler':
       $output = '<h3>' . t('About') . '</h3>';
-      $output .= '<p>' . t('The Scheduler module provides the functionality for automatic publishing and unpublishing of nodes at specified future dates.') . '</p>';
+      $output .= '<p>' . t('The Scheduler module provides the functionality for automatic publishing and unpublishing of entities, such and nodes and media items, at specified future dates.') . '</p>';
       $output .= '<p>' . t('You can read more in the <a href="@readme">readme</a> file or our <a href="@project">project page on Drupal.org</a>.', [
-        '@readme' => $GLOBALS['base_url'] . '/' . drupal_get_path('module', 'scheduler') . '/README.md',
+        '@readme' => $GLOBALS['base_url'] . '/' . \Drupal::service('extension.list.module')->getPath('scheduler') . '/README.md',
         '@project' => 'https://drupal.org/project/scheduler',
       ]) . '</p>';
       break;
 
-    case 'scheduler.admin_form':
-      $output = '<p>' . t('Most of the Scheduler options are set for each different content type, and are accessed via the <a href="@link">admin content type</a> list.', ['@link' => Url::fromRoute('entity.node_type.collection')->toString()]) . '</br>';
-      $output .= t('The options and settings below are common to all content types.') . '</p>';
-      break;
-
     case 'scheduler.cron_form':
       $base_url = $GLOBALS['base_url'];
       $access_key = \Drupal::config('scheduler.settings')->get('lightweight_cron_access_key');
@@ -54,54 +56,104 @@ function scheduler_help($route_name, RouteMatchInterface $route_match) {
 }
 
 /**
- * Implements hook_form_FORM_ID_alter() for node_type_form().
+ * Implements hook_form_alter().
  */
-function scheduler_form_node_type_form_alter(array &$form, FormStateInterface $form_state) {
-  // Load the real code only when needed.
-  module_load_include('inc', 'scheduler', 'scheduler.admin');
-  _scheduler_form_node_type_form_alter($form, $form_state);
+function scheduler_form_alter(&$form, FormStateInterface $form_state, $form_id) {
+  $scheduler_manager = \Drupal::service('scheduler.manager');
+
+  if (in_array($form_id, $scheduler_manager->getEntityFormIds())) {
+    /** @var \Drupal\Core\Entity\EntityInterface $entity */
+    $entity = $form_state->getFormObject()->getEntity();
+    _scheduler_entity_form_alter($form, $form_state, $form_id, $entity);
+  }
+  elseif (in_array($form_id, $scheduler_manager->getEntityTypeFormIds())) {
+    _scheduler_entity_type_form_alter($form, $form_state, $form_id);
+  }
+  elseif ($entityTypeId = array_search($form_id, $scheduler_manager->getDevelGenerateFormIds())) {
+    // Devel Generate forms are different from the other types above. There is
+    // only one form id per entity type, but also no direct way to get the
+    // entity from the form. Hence we add the entityTypeId as a key in the array
+    // of returned possible form ids, and pass that on to the helper function.
+    _scheduler_devel_generate_form_alter($form, $form_state, $form_id, $entityTypeId);
+  }
+  elseif (in_array($form_id, ['media_library_add_form_oembed', 'media_library_add_form_upload'])) {
+    if (isset($form['media'])) {
+      $media = $form_state->get('media');
+      // Call the entity form alter function for each of the new media items
+      // being uploaded.
+      foreach ($media as $key => $entity) {
+        _scheduler_entity_form_alter($form['media'][$key]['fields'], $form_state, $form_id, $entity);
+      }
+    }
+  }
 }
 
 /**
- * Implements hook_form_FORM_ID_alter() for node_form().
+ * Form alter handling for entity forms - add and edit.
  */
-function scheduler_form_node_form_alter(&$form, FormStateInterface $form_state) {
+function _scheduler_entity_form_alter(&$form, FormStateInterface $form_state, $form_id, $entity) {
+  // When a content type, such as a page, has an entity reference field linked
+  // to media items the Media Library module allows new media to be uploaded and
+  // inserted when adding/editing the page. The media upload form is not an
+  // 'entity' form and does not have a getFormDisplay() method. Hence we cannot
+  // properly amend the form in this scenario and the best we can do is remove
+  // the Scheduler fields for safety. The Scheduler issue discussing this is
+  // https://www.drupal.org/project/scheduler/issues/2916730
+  if (!method_exists($form_state->getFormObject(), 'getFormDisplay')) {
+    unset($form['publish_on']);
+    unset($form['unpublish_on']);
+    return;
+  }
+
+  // Get the form display object. If this does not exist because the form is
+  // prevented from displaying, such as in Commerce Add Product before any store
+  // has been created, we can not (and do not need to) do anything, so exit.
+  $param = ($form_id == 'media_library_add_form_upload') ? $entity : $form_state;
+  if (!$display = $form_state->getFormObject()->getFormDisplay($param)) {
+    return;
+  }
+
   $config = \Drupal::config('scheduler.settings');
-  /** @var \Drupal\node\NodeTypeInterface $type */
-  $type = $form_state->getFormObject()->getEntity()->type->entity;
-  $publishing_enabled = $type->getThirdPartySetting('scheduler', 'publish_enable', $config->get('default_publish_enable'));
-  $unpublishing_enabled = $type->getThirdPartySetting('scheduler', 'unpublish_enable', $config->get('default_unpublish_enable'));
+  $scheduler_manager = \Drupal::service('scheduler.manager');
+  $entityTypeId = $entity->getEntityTypeId();
+
+  $publishing_enabled = $scheduler_manager->getThirdPartySetting($entity, 'publish_enable', $config->get('default_publish_enable'));
+  $unpublishing_enabled = $scheduler_manager->getThirdPartySetting($entity, 'unpublish_enable', $config->get('default_unpublish_enable'));
+
+  // If neither publishing nor unpublishing are enabled then there is nothing to
+  // do so remove the fields from the form and exit early.
+  if (!$publishing_enabled && !$unpublishing_enabled) {
+    unset($form['publish_on']);
+    unset($form['unpublish_on']);
+    return;
+  }
 
   // Determine if the scheduler fields have been set to hidden (disabled).
-  $display = $form_state->getFormObject()->getFormDisplay($form_state);
   $publishing_displayed = !empty($display->getComponent('publish_on'));
   $unpublishing_displayed = !empty($display->getComponent('unpublish_on'));
 
-  /* @var $node \Drupal\node\NodeInterface */
-  $node = $form_state->getFormObject()->getEntity();
-
-  // Invoke all implementations of hook_scheduler_hide_publish_on_field() to
-  // allow other modules to hide the field on the node edit form.
+  // Invoke all implementations of hook_scheduler_hide_publish_date() and
+  // hook_scheduler_{type}_hide_publish_date() to allow other modules to hide
+  // the field on the entity edit form.
   if ($publishing_enabled && $publishing_displayed) {
-    $hook = 'scheduler_hide_publish_on_field';
-    foreach (\Drupal::moduleHandler()->getImplementations($hook) as $module) {
-      $function = $module . '_' . $hook;
-      $publishing_displayed = ($function($form, $form_state, $node) !== TRUE) && $publishing_displayed;
+    $hook_implementations = $scheduler_manager->getHookImplementations('hide_publish_date', $entity);
+    foreach ($hook_implementations as $function) {
+      $publishing_displayed = ($function($form, $form_state, $entity) !== TRUE) && $publishing_displayed;
     }
   }
-  // Invoke all implementations of hook_scheduler_hide_unpublish_on_field() to
-  // allow other modules to hide the field on the node edit form.
+  // Invoke all implementations of hook_scheduler_hide_unpublish_date() and
+  // hook_scheduler_{type}_hide_unpublish_date() to allow other modules to hide
+  // the field on the entity edit form.
   if ($unpublishing_enabled && $unpublishing_displayed) {
-    $hook = 'scheduler_hide_unpublish_on_field';
-    foreach (\Drupal::moduleHandler()->getImplementations($hook) as $module) {
-      $function = $module . '_' . $hook;
-      $unpublishing_displayed = ($function($form, $form_state, $node) !== TRUE) && $unpublishing_displayed;
+    $hook_implementations = $scheduler_manager->getHookImplementations('hide_unpublish_date', $entity);
+    foreach ($hook_implementations as $function) {
+      $unpublishing_displayed = ($function($form, $form_state, $entity) !== TRUE) && $unpublishing_displayed;
     }
   }
 
   // If both publishing and unpublishing are either not enabled or are hidden
-  // for this node type then the only thing to do is remove the fields from the
-  // form, then exit.
+  // for this entity type then the only thing to do is remove the fields from
+  // the form, then exit.
   if ((!$publishing_enabled || !$publishing_displayed) && (!$unpublishing_enabled || !$unpublishing_displayed)) {
     unset($form['publish_on']);
     unset($form['unpublish_on']);
@@ -111,24 +163,34 @@ function scheduler_form_node_form_alter(&$form, FormStateInterface $form_state)
   $allow_date_only = $config->get('allow_date_only');
 
   // A publish_on date is required if the content type option is set and the
-  // node is being created or it currently has a scheduled publishing date.
-  $publishing_required = $type->getThirdPartySetting('scheduler', 'publish_required', $config->get('default_publish_required'))
-    && ($node->isNew() || (!$node->isPublished() && !empty($node->publish_on->value)));
+  // entity is being created or it is currently not published but has a
+  // scheduled publishing date.
+  $publishing_required = $publishing_enabled
+    && $scheduler_manager->getThirdPartySetting($entity, 'publish_required', $config->get('default_publish_required'))
+    && ($entity->isNew() || (!$entity->isPublished() && !empty($entity->publish_on->value)));
 
   // An unpublish_on date is required if the content type option is set and the
-  // node is being created or the current status is published or the node is
+  // entity is being created or the current status is published or the entity is
   // scheduled to be published.
-  $unpublishing_required = $type->getThirdPartySetting('scheduler', 'unpublish_required', $config->get('default_unpublish_required')) && ($node->isNew() || $node->isPublished() || !empty($node->publish_on->value));
+  $unpublishing_required = $unpublishing_enabled
+    && $scheduler_manager->getThirdPartySetting($entity, 'unpublish_required', $config->get('default_unpublish_required'))
+    && ($entity->isNew() || $entity->isPublished() || !empty($entity->publish_on->value));
 
   // Create a 'details' field group to wrap the scheduling fields, and expand it
   // if publishing or unpublishing is required, if a date already exists or the
   // fieldset is configured to be always expanded.
-  $has_data = !empty($node->publish_on->value) || !empty($node->unpublish_on->value);
-  $always_expand = $type->getThirdPartySetting('scheduler', 'expand_fieldset', $config->get('default_expand_fieldset')) === 'always';
+  $has_data = isset($entity->publish_on->value) || isset($entity->unpublish_on->value);
+  $always_expand = $scheduler_manager->getThirdPartySetting($entity, 'expand_fieldset', $config->get('default_expand_fieldset')) === 'always';
   $expand_details = $publishing_required || $unpublishing_required || $has_data || $always_expand;
 
-  // Create the group for the fields.
-  $form['scheduler_settings'] = [
+  // Create the group for the fields. The array key has to be distinct when more
+  // than one $entity appears in the form, for example in Media Library uploads.
+  // Keep the first key the same as before, without any suffix, as this is used
+  // in drupal.behaviors javascript and could be used by third-party modules.
+  static $group_number;
+  $group_number += 1;
+  $scheduler_field_group = ($group_number == 1) ? 'scheduler_settings' : "scheduler_settings_{$group_number}";
+  $form[$scheduler_field_group] = [
     '#type' => 'details',
     '#title' => t('Scheduling options'),
     '#open' => $expand_details,
@@ -138,36 +200,37 @@ function scheduler_form_node_form_alter(&$form, FormStateInterface $form_state)
   ];
 
   // Attach the fields to group.
-  $form['unpublish_on']['#group'] = 'scheduler_settings';
-  $form['publish_on']['#group'] = 'scheduler_settings';
+  $form['publish_on']['#group'] = $form['unpublish_on']['#group'] = $scheduler_field_group;
 
   // Show the field group as a vertical tab if this option is enabled.
-  $use_vertical_tabs = $type->getThirdPartySetting('scheduler', 'fields_display_mode', $config->get('default_fields_display_mode')) === 'vertical_tab';
+  $use_vertical_tabs = $scheduler_manager->getThirdPartySetting($entity, 'fields_display_mode', $config->get('default_fields_display_mode')) === 'vertical_tab';
   if ($use_vertical_tabs) {
-    $form['scheduler_settings']['#group'] = 'advanced';
+    $form[$scheduler_field_group]['#group'] = 'advanced';
 
     // Attach the javascript for the vertical tabs.
-    $form['scheduler_settings']['#attached']['library'][] = 'scheduler/vertical-tabs';
+    $form[$scheduler_field_group]['#attached']['library'][] = 'scheduler/vertical-tabs';
   }
 
+  // The 'once' library was moved from jQuery into core at 9.2. The original js
+  // library file is kept to maintain compatibility with Drupal 8.9.
+  // @see https://www.drupal.org/project/scheduler/issues/3314158
+  $default_time_library = version_compare(\Drupal::VERSION, '9.2', '>=') ? 'scheduler/default-time' : 'scheduler/default-time-8x';
+
   // Define the descriptions depending on whether the time can be skipped.
-  $date_formatter = \Drupal::service('date.formatter');
   $descriptions = [];
   if ($allow_date_only) {
     $descriptions['format'] = t('Enter a date. The time part is optional.');
     // Show the default time so users know what they will get if they do not
     // enter a time.
-    $default_time = strtotime($config->get('default_time'));
-    $default_time_formatted = $date_formatter->format($default_time, 'custom', 'H:i:s');
     $descriptions['default'] = t('The default time is @default_time.', [
-      '@default_time' => $default_time_formatted,
+      '@default_time' => $config->get('default_time'),
     ]);
 
     // Use javascript to pre-fill the time parts if the dates are required.
     // See js/scheduler_default_time.js for more details.
     if ($publishing_required || $unpublishing_required) {
-      $form['scheduler_settings']['#attached']['library'][] = 'scheduler/default-time';
-      $form['scheduler_settings']['#attached']['drupalSettings']['schedulerDefaultTime'] = $default_time_formatted;
+      $form[$scheduler_field_group]['#attached']['library'][] = $default_time_library;
+      $form[$scheduler_field_group]['#attached']['drupalSettings']['schedulerDefaultTime'] = $config->get('default_time');
     }
   }
   else {
@@ -193,12 +256,31 @@ function scheduler_form_node_form_alter(&$form, FormStateInterface $form_state)
   $form['unpublish_on']['widget'][0]['value']['#required'] = $unpublishing_required;
   $form['unpublish_on']['widget'][0]['value']['#description'] = Xss::filter(implode(' ', $descriptions));
 
-  if (!\Drupal::currentUser()->hasPermission('schedule publishing of nodes')) {
+  // When hiding the seconds on time input, we need to remove the seconds from
+  // the form value, as some browsers HTML5 rendering still show the seconds.
+  // We can use the same jQuery drupal behaviors file as for default time.
+  // This functionality is not covered by tests.
+  if ($config->get('hide_seconds')) {
+    // If there is a publish_on time, then use jQuery to remove the seconds.
+    if (isset($entity->publish_on->value)) {
+      $form[$scheduler_field_group]['#attached']['library'][] = $default_time_library;
+      $form[$scheduler_field_group]['#attached']['drupalSettings']['schedulerHideSecondsPublishOn'] = date('H:i', $entity->publish_on->value);
+    }
+    // Likewise for the unpublish_on time.
+    if (isset($entity->unpublish_on->value)) {
+      $form[$scheduler_field_group]['#attached']['library'][] = $default_time_library;
+      $form[$scheduler_field_group]['#attached']['drupalSettings']['schedulerHideSecondsUnpublishOn'] = date('H:i', $entity->unpublish_on->value);
+    }
+  }
+
+  // Check the permission for entering scheduled dates.
+  $permission = $scheduler_manager->permissionName($entityTypeId, 'schedule');
+  if (!\Drupal::currentUser()->hasPermission($permission)) {
     // Do not show the scheduler fields for users who do not have permission.
-    // Setting #access to FALSE for 'scheduler_settings' is enough to hide the
+    // Setting #access to FALSE for the group fieldset is enough to hide the
     // fields. Setting FALSE for the individual fields is necessary to keep any
     // existing scheduled dates preserved and remain unchanged on saving.
-    $form['scheduler_settings']['#access'] = FALSE;
+    $form[$scheduler_field_group]['#access'] = FALSE;
     $form['publish_on']['#access'] = FALSE;
     $form['unpublish_on']['#access'] = FALSE;
 
@@ -211,9 +293,12 @@ function scheduler_form_node_form_alter(&$form, FormStateInterface $form_state)
     $form['unpublish_on']['widget'][0]['value']['#required'] = FALSE;
   }
 
-  // Check which widget type is set for the scheduler fields, and give a warning
-  // if the wrong one has been set and provide a hint and link to fix it.
+  // Check which widget is set for the scheduler fields, and give a warning and
+  // provide a hint and link for how to fix it. Allow third-party modules to
+  // provide their own custom widget, we are only interested in checking that it
+  // has not reverted back to the core 'datetime_timestamp' widget.
   $pluginDefinitions = $display->get('pluginManager')->getDefinitions();
+  $fields_to_check = [];
   if ($publishing_enabled && $publishing_displayed) {
     $fields_to_check[] = 'publish_on';
   }
@@ -223,12 +308,15 @@ function scheduler_form_node_form_alter(&$form, FormStateInterface $form_state)
   $correct_widget_id = 'datetime_timestamp_no_default';
   foreach ($fields_to_check as $field) {
     $actual_widget_id = $display->getComponent($field)['type'];
-    if ($actual_widget_id != $correct_widget_id) {
-      \Drupal::messenger()->addMessage(t('The widget for field %field is incorrectly set to %wrong. This should be changed to %correct by an admin user via Field UI <a href="@link">content type form display</a> :not_available', [
+    if ($actual_widget_id == 'datetime_timestamp') {
+      $link = \Drupal::moduleHandler()->moduleExists('field_ui') ?
+        Url::fromRoute("entity.entity_form_display.$entityTypeId.default", ["{$entityTypeId}_type" => $entity->bundle()])->toString()
+        : '#';
+      \Drupal::messenger()->addMessage(t('The widget for field %field is incorrectly set to %wrong. This should be changed to %correct by an admin user via the <a href="@link">Field UI form display</a> :not_available', [
         '%field' => (string) $form[$field]['widget']['#title'],
         '%correct' => (string) $pluginDefinitions[$correct_widget_id]['label'],
         '%wrong' => (string) $pluginDefinitions[$actual_widget_id]['label'],
-        '@link' => \Drupal::moduleHandler()->moduleExists('field_ui') ? Url::fromRoute('entity.entity_form_display.node.default', ['node_type' => $type->get('type')])->toString() : '#',
+        '@link' => $link,
         ':not_available' => \Drupal::moduleHandler()->moduleExists('field_ui') ? '' : ('(' . t('not available') . ')'),
       ]), 'warning', FALSE);
     }
@@ -236,46 +324,360 @@ function scheduler_form_node_form_alter(&$form, FormStateInterface $form_state)
 }
 
 /**
- * Implements hook_form_FORM_ID_alter() for devel_generate_form_content.
+ * Form alter handling for entity type forms.
  */
-function scheduler_form_devel_generate_form_content_alter(array &$form, FormStateInterface $form_state) {
-  // Add an extra column to the node_types table to show which type are enabled
-  // for scheduled publishing and unpublishing.
-  $publishing_enabled_types = array_keys(_scheduler_get_scheduler_enabled_node_types('publish'));
-  $unpublishing_enabled_types = array_keys(_scheduler_get_scheduler_enabled_node_types('unpublish'));
+function _scheduler_entity_type_form_alter(&$form, FormStateInterface $form_state, $form_id) {
+  $config = \Drupal::config('scheduler.settings');
 
-  $form['node_types']['#header']['scheduler'] = t('Scheduler settings');
+  /** @var \Drupal\Core\Entity\EntityTypeInterface $type */
+  $type = $form_state->getFormObject()->getEntity();
+
+  /** @var Drupal\Core\Entity\ContentEntityTypeInterface $contentEntityType */
+  $contentEntityType = \Drupal::entityTypeManager()->getDefinition($type->getEntityType()->getBundleOf());
+
+  /** @var \Drupal\Core\Entity\ContentEntityInterface $contentEntity */
+  $contentEntity = \Drupal::entityTypeManager()->getStorage($contentEntityType->id())->create([$contentEntityType->getKey('bundle') => 'scaffold']);
+
+  $params = [
+    '@type' => $type->label() ?? '',
+    '%type' => strtolower($type->label() ?? ''),
+    '@singular' => $contentEntityType->getSingularLabel(),
+    '@plural' => $contentEntityType->getPluralLabel(),
+  ];
 
-  foreach (array_keys($form['node_types']['#options']) as $type) {
-    $items = [];
-    if (in_array($type, $publishing_enabled_types)) {
-      $items[] = t('Enabled for publishing');
+  $form['#attached']['library'][] = 'scheduler/vertical-tabs';
+
+  $form['scheduler'] = [
+    '#type' => 'details',
+    '#title' => t('Scheduler'),
+    '#weight' => 35,
+    '#group' => 'additional_settings',
+  ];
+
+  // Publishing options.
+  $form['scheduler']['publish'] = [
+    '#type' => 'details',
+    '#title' => t('Publishing'),
+    '#weight' => 1,
+    '#group' => 'scheduler',
+    '#open' => TRUE,
+  ];
+  $form['scheduler']['publish']['scheduler_publish_enable'] = [
+    '#type' => 'checkbox',
+    '#title' => t('Enable scheduled publishing for %type @plural', $params),
+    '#default_value' => $type->getThirdPartySetting('scheduler', 'publish_enable', $config->get('default_publish_enable')),
+  ];
+  $form['scheduler']['publish']['scheduler_publish_touch'] = [
+    '#type' => 'checkbox',
+    '#title' => t('Change %type creation time to match the scheduled publish time', $params),
+    '#default_value' => $type->getThirdPartySetting('scheduler', 'publish_touch', $config->get('default_publish_touch')),
+    '#states' => [
+      'visible' => [
+        ':input[name="scheduler_publish_enable"]' => ['checked' => TRUE],
+      ],
+    ],
+  ];
+
+  // Entity types that do not implement the 'getCreatedTime' method should have
+  // the option set to FALSE and the field disabled and hidden.
+  if (!method_exists($contentEntity, 'getCreatedTime')) {
+    $form['scheduler']['publish']['scheduler_publish_touch']['#disabled'] = TRUE;
+    $form['scheduler']['publish']['scheduler_publish_touch']['#description'] = t('The entity type does not support the change of creation time.');
+    $form['scheduler']['publish']['scheduler_publish_touch']['#default_value'] = FALSE;
+    $form['scheduler']['publish']['scheduler_publish_touch']['#access'] = FALSE;
+  }
+
+  $form['scheduler']['publish']['scheduler_publish_required'] = [
+    '#type' => 'checkbox',
+    '#title' => t('Require scheduled publishing'),
+    '#default_value' => $type->getThirdPartySetting('scheduler', 'publish_required', $config->get('default_publish_required')),
+    '#states' => [
+      'visible' => [
+        ':input[name="scheduler_publish_enable"]' => ['checked' => TRUE],
+      ],
+    ],
+  ];
+  if ($contentEntityType->isRevisionable()) {
+    $form['scheduler']['publish']['scheduler_publish_revision'] = [
+      '#type' => 'checkbox',
+      '#title' => t('Create a new revision on publishing'),
+      '#default_value' => $type->getThirdPartySetting('scheduler', 'publish_revision', $config->get('default_publish_revision')),
+      '#states' => [
+        'visible' => [
+          ':input[name="scheduler_publish_enable"]' => ['checked' => TRUE],
+        ],
+      ],
+    ];
+  }
+  $form['scheduler']['publish']['advanced'] = [
+    '#type' => 'details',
+    '#title' => t('Advanced options'),
+    '#open' => FALSE,
+    '#states' => [
+      'visible' => [
+        ':input[name="scheduler_publish_enable"]' => ['checked' => TRUE],
+      ],
+    ],
+  ];
+  $form['scheduler']['publish']['advanced']['scheduler_publish_past_date'] = [
+    '#type' => 'radios',
+    '#title' => t('Action to be taken for publication dates in the past'),
+    '#default_value' => $type->getThirdPartySetting('scheduler', 'publish_past_date', $config->get('default_publish_past_date')),
+    '#options' => [
+      'error' => t('Display an error message - do not allow dates in the past'),
+      'publish' => t('Publish the %type @singular immediately after saving', $params),
+      'schedule' => t('Schedule the %type @singular for publication on the next cron run', $params),
+    ],
+  ];
+  $form['scheduler']['publish']['advanced']['scheduler_publish_past_date_created'] = [
+    '#type' => 'checkbox',
+    '#title' => t('Change %type creation time to match the published time, for dates before the %type was created', $params),
+    '#description' => t("The created time will only be altered when the scheduled publishing time is earlier than the existing creation time"),
+    '#default_value' => $type->getThirdPartySetting('scheduler', 'publish_past_date_created', $config->get('default_publish_past_date_created')),
+    // This option is not relevant if the full 'change creation time' option is
+    // selected, or when past dates are not allowed. Hence only show it when
+    // the main option is not checked and the past dates option is not 'error'.
+    '#states' => [
+      'visible' => [
+        ':input[name="scheduler_publish_touch"]' => ['checked' => FALSE],
+        ':input[name="scheduler_publish_past_date"]' => ['!value' => 'error'],
+      ],
+    ],
+  ];
+
+  // Entity types that do not implement the 'getCreatedTime' method should have
+  // the option set to FALSE and the field disabled. It will be hidden due to
+  // the #states setting above, as scheduler_publish_touch has #access = FALSE.
+  if (!method_exists($contentEntity, 'getCreatedTime')) {
+    $form['scheduler']['publish']['advanced']['scheduler_publish_past_date_created']['#disabled'] = TRUE;
+    $form['scheduler']['publish']['advanced']['scheduler_publish_past_date_created']['#description'] = t('The entity type does not support the change of creation time.');
+    $form['scheduler']['publish']['advanced']['scheduler_publish_past_date_created']['#default_value'] = FALSE;
+  }
+
+  // Unpublishing options.
+  $form['scheduler']['unpublish'] = [
+    '#type' => 'details',
+    '#title' => t('Unpublishing'),
+    '#weight' => 2,
+    '#group' => 'scheduler',
+    '#open' => TRUE,
+  ];
+  $form['scheduler']['unpublish']['scheduler_unpublish_enable'] = [
+    '#type' => 'checkbox',
+    '#title' => t('Enable scheduled unpublishing for %type @plural', $params),
+    '#default_value' => $type->getThirdPartySetting('scheduler', 'unpublish_enable', $config->get('default_unpublish_enable')),
+  ];
+  $form['scheduler']['unpublish']['scheduler_unpublish_required'] = [
+    '#type' => 'checkbox',
+    '#title' => t('Require scheduled unpublishing'),
+    '#default_value' => $type->getThirdPartySetting('scheduler', 'unpublish_required', $config->get('default_unpublish_required')),
+    '#states' => [
+      'visible' => [
+        ':input[name="scheduler_unpublish_enable"]' => ['checked' => TRUE],
+      ],
+    ],
+  ];
+  if ($contentEntityType->isRevisionable()) {
+    $form['scheduler']['unpublish']['scheduler_unpublish_revision'] = [
+      '#type' => 'checkbox',
+      '#title' => t('Create a new revision on unpublishing'),
+      '#default_value' => $type->getThirdPartySetting('scheduler', 'unpublish_revision', $config->get('default_unpublish_revision')),
+      '#states' => [
+        'visible' => [
+          ':input[name="scheduler_unpublish_enable"]' => ['checked' => TRUE],
+        ],
+      ],
+    ];
+  }
+
+  // The 'entity_edit_layout' fieldset contains options to alter the layout of
+  // entity edit pages.
+  $form['scheduler']['entity_edit_layout'] = [
+    '#type' => 'details',
+    '#title' => t('@type edit page', $params),
+    '#weight' => 3,
+    '#group' => 'scheduler',
+    // The #states processing only caters for AND and does not do OR. So to set
+    // the state to visible if either of the boxes are ticked we use the fact
+    // that logical 'X = A or B' is equivalent to 'not X = not A and not B'.
+    '#states' => [
+      '!visible' => [
+        ':input[name="scheduler_publish_enable"]' => ['!checked' => TRUE],
+        ':input[name="scheduler_unpublish_enable"]' => ['!checked' => TRUE],
+      ],
+    ],
+  ];
+  $form['scheduler']['entity_edit_layout']['scheduler_fields_display_mode'] = [
+    '#type' => 'radios',
+    '#title' => t('Display scheduling date input fields in'),
+    '#default_value' => $type->getThirdPartySetting('scheduler', 'fields_display_mode', $config->get('default_fields_display_mode')),
+    '#options' => [
+      'vertical_tab' => t('Vertical tab'),
+      'fieldset' => t('Separate fieldset'),
+    ],
+    '#description' => t('Use this option to specify how the scheduler fields are displayed when editing %type @plural', $params),
+  ];
+  $form['scheduler']['entity_edit_layout']['scheduler_expand_fieldset'] = [
+    '#type' => 'radios',
+    '#title' => t('Expand fieldset or vertical tab'),
+    '#default_value' => $type->getThirdPartySetting('scheduler', 'expand_fieldset', $config->get('default_expand_fieldset')),
+    '#options' => [
+      'when_required' => t('Expand only when a scheduled date exists or when a date is required'),
+      'always' => t('Always open the fieldset or vertical tab'),
+    ],
+  ];
+  $form['scheduler']['entity_edit_layout']['scheduler_show_message_after_update'] = [
+    '#type' => 'checkbox',
+    '#prefix' => '<strong>' . t('Show message') . '</strong>',
+    '#title' => t('Show a confirmation message when a scheduled %type @singular is saved', $params),
+    '#default_value' => $type->getThirdPartySetting('scheduler', 'show_message_after_update', $config->get('default_show_message_after_update')),
+  ];
+
+  $form['#entity_builders'][] = '_scheduler_form_entity_type_form_builder';
+
+  // Add a custom submit handler to adjust the fields in the form displays.
+  $form['actions']['submit']['#submit'][] = '_scheduler_form_entity_type_submit';
+}
+
+/**
+ * Entity builder for the entity type form with scheduler options.
+ */
+function _scheduler_form_entity_type_form_builder($entity_type, $type, &$form, FormStateInterface $form_state) {
+  $type->setThirdPartySetting('scheduler', 'expand_fieldset', $form_state->getValue('scheduler_expand_fieldset'));
+  $type->setThirdPartySetting('scheduler', 'fields_display_mode', $form_state->getValue('scheduler_fields_display_mode'));
+  $type->setThirdPartySetting('scheduler', 'publish_enable', $form_state->getValue('scheduler_publish_enable'));
+  $type->setThirdPartySetting('scheduler', 'publish_past_date', $form_state->getValue('scheduler_publish_past_date'));
+  $type->setThirdPartySetting('scheduler', 'publish_past_date_created', $form_state->getValue('scheduler_publish_past_date_created'));
+  $type->setThirdPartySetting('scheduler', 'publish_required', $form_state->getValue('scheduler_publish_required'));
+  $type->setThirdPartySetting('scheduler', 'publish_revision', $form_state->getValue('scheduler_publish_revision'));
+  $type->setThirdPartySetting('scheduler', 'publish_touch', $form_state->getValue('scheduler_publish_touch'));
+  $type->setThirdPartySetting('scheduler', 'show_message_after_update', $form_state->getValue('scheduler_show_message_after_update'));
+  $type->setThirdPartySetting('scheduler', 'unpublish_enable', $form_state->getValue('scheduler_unpublish_enable'));
+  $type->setThirdPartySetting('scheduler', 'unpublish_required', $form_state->getValue('scheduler_unpublish_required'));
+  $type->setThirdPartySetting('scheduler', 'unpublish_revision', $form_state->getValue('scheduler_unpublish_revision'));
+}
+
+/**
+ * Entity type form submit handler.
+ */
+function _scheduler_form_entity_type_submit($form, FormStateInterface $form_state) {
+  // Get the entity type id (node, media, taxonomy_term, etc.)
+  $entity_type_id = $form_state->getFormObject()->getEntity()->getEntityType()->getBundleOf();
+  // Get the entity bundle id (page, article, image, etc.)
+  $bundle_id = $form_state->getFormObject()->getEntity()->id();
+
+  /** @var \Drupal\Core\Entity\EntityDisplayRepositoryInterface $display_repository */
+  $display_repository = \Drupal::service('entity_display.repository');
+
+  // Get all active display modes. getFormModes() returns the additional modes
+  // then add the default.
+  $all_display_modes = array_keys($display_repository->getFormModes($entity_type_id));
+  $all_display_modes[] = $display_repository::DEFAULT_DISPLAY_MODE;
+
+  $supported_display_modes = \Drupal::service('scheduler.manager')->getPlugin($entity_type_id)->entityFormDisplayModes();
+
+  // Each of the active form display modes may need to be adjusted to add or
+  // remove the scheduler fields depending on the 'enable' setting.
+  foreach ($all_display_modes as $display_mode) {
+    $form_display = $display_repository->getFormDisplay($entity_type_id, $bundle_id, $display_mode);
+
+    // If this bundle is not enabled for scheduled publishing or the form
+    // display mode is not supported then make sure the publish_on field is
+    // disabled in the form display.
+    if (!$form_state->getValue('scheduler_publish_enable') || !in_array($display_mode, $supported_display_modes)) {
+      $form_display->removeComponent('publish_on')->save();
     }
-    if (in_array($type, $unpublishing_enabled_types)) {
-      $items[] = t('Enabled for unpublishing');
+    // @todo Find a more robust way to detect that the checkbox was off before.
+    elseif (!$form['scheduler']['publish']['scheduler_publish_enable']['#default_value']) {
+      // If the entity bundle is now enabled for scheduled publishing but was
+      // not enabled before then set the publish_on field to be displayed and
+      // set the widget type to 'datetime_timestamp_no_default'. Only do this
+      // when the checkbox has been changed, because the widget could be altered
+      // or the field moved by a subsequent process or manually by admin.
+      $form_display->setComponent('scheduler_settings', ['weight' => 50])
+        ->setComponent('publish_on', ['type' => 'datetime_timestamp_no_default', 'weight' => 52])->save();
     }
-    if (empty($items)) {
-      $scheduler_settings = t('None');
+
+    // Do the same for the unpublish_on field.
+    if (!$form_state->getValue('scheduler_unpublish_enable') || !in_array($display_mode, $supported_display_modes)) {
+      $form_display->removeComponent('unpublish_on')->save();
     }
-    else {
-      $scheduler_settings = [
-        'data' => [
-          '#theme' => 'item_list',
-          '#items' => $items,
-        ],
-      ];
+    elseif (!$form['scheduler']['unpublish']['scheduler_unpublish_enable']['#default_value']) {
+      $form_display->setComponent('scheduler_settings', ['weight' => 50])
+        ->setComponent('unpublish_on', ['type' => 'datetime_timestamp_no_default', 'weight' => 54])->save();
+    }
+
+    // If the display mode is not supported then also remove the
+    // scheduler_settings group fieldset.
+    if (!in_array($display_mode, $supported_display_modes)) {
+      $form_display->removeComponent('scheduler_settings')->save();
+    }
+  }
+}
+
+/**
+ * Form alter handling for Devel Generate forms.
+ */
+function _scheduler_devel_generate_form_alter(array &$form, FormStateInterface $form_state, $form_id, $entityTypeId) {
+  // Show which types are enabled for scheduled publishing and unpublishing. If
+  // the form does not have a table but has a selection list instead, then
+  // nothing is added here.
+  $type_table = $entityTypeId . '_types';
+  if (isset($form[$type_table]['#header']) && isset($form[$type_table]['#options'])) {
+    // Add an extra column to the table to show which types are enabled for
+    // scheduled publishing and unpublishing.
+    $scheduler_manager = \Drupal::service('scheduler.manager');
+    $publishing_enabled_types = $scheduler_manager->getEnabledTypes($entityTypeId, 'publish');
+    $unpublishing_enabled_types = $scheduler_manager->getEnabledTypes($entityTypeId, 'unpublish');
+    $form[$type_table]['#header']['scheduler'] = t('Scheduler settings');
+
+    foreach (array_keys($form[$type_table]['#options']) as $type) {
+      $items = [];
+      if (in_array($type, $publishing_enabled_types)) {
+        $items[] = t('Enabled for publishing');
+      }
+      if (in_array($type, $unpublishing_enabled_types)) {
+        $items[] = t('Enabled for unpublishing');
+      }
+      if (empty($items)) {
+        $scheduler_settings = t('None');
+      }
+      else {
+        $scheduler_settings = [
+          'data' => [
+            '#theme' => 'item_list',
+            '#items' => $items,
+          ],
+        ];
+      }
+      $form[$type_table]['#options'][$type]['scheduler'] = $scheduler_settings;
     }
-    $form['node_types']['#options'][$type]['scheduler'] = $scheduler_settings;
   }
 
-  // Add form items to specify what proportion of generated nodes should have a
-  // publish-on and unpublish-on date assigned. See hook_node_presave() for the
-  // code which sets the node values.
+  if (!isset($form['time_range'])) {
+    // Add the time range field if it was not added by Devel Generate.
+    $options = [1 => t('Now')];
+    foreach ([3600, 86400, 604800, 2592000, 31536000] as $interval) {
+      $options[$interval] = \Drupal::service('date.formatter')->formatInterval($interval, 1);
+    }
+    $form['time_range'] = [
+      '#type' => 'select',
+      '#title' => t('How far into the future should the items be scheduled?'),
+      '#description' => t('Scheduled dates will be set randomly within the selected time span.'),
+      '#options' => $options,
+      '#default_value' => 86400,
+    ];
+  }
+
+  // Add form items to specify what proportion of generated entities should have
+  // a publish-on and/or unpublish-on date assigned. See hook_entity_presave()
+  // for the code that sets these values in the generated entity. Allow for any
+  // previously-executed form_alter to have already set the percentages.
   $form['scheduler_publishing'] = [
     '#type' => 'number',
     '#title' => t('Publishing date for Scheduler'),
-    '#description' => t('Enter the percentage of randomly selected Scheduler-enabled nodes to be given a publish-on date. Enter 0 for none, 100 for all. The date and time will be random within the range starting at node creation date, up to a time in the future matching the same span as selected above for node creation date.'),
-    '#default_value' => 50,
+    '#description' => t('Enter a percentage for randomly selecting Scheduler-enabled entities to be given a publish-on date. Enter 0 for none, 100 for all. The date and time will be random within the range starting at entity creation date, up to a time in the future matching the same span as selected above.'),
+    '#default_value' => $form['scheduler_publishing']['#default_value'] ?? 50,
     '#required' => TRUE,
     '#min' => 0,
     '#max' => 100,
@@ -283,8 +685,8 @@ function scheduler_form_devel_generate_form_content_alter(array &$form, FormStat
   $form['scheduler_unpublishing'] = [
     '#type' => 'number',
     '#title' => t('Unpublishing date for Scheduler'),
-    '#description' => t('Enter the percentage of randomly selected Scheduler-enabled nodes to be given an unpublish-on date. Enter 0 for none, 100 for all. The date and time will be random within the range starting at the later of node creation date and publish-on date, up to a time in the future matching the same span as selected above for node creation date.'),
-    '#default_value' => 50,
+    '#description' => t('Enter a percentage for randomly selecting Scheduler-enabled entities to be given an unpublish-on date. Enter 0 for none, 100 for all. The date and time will be random within the range starting at the later of entity creation date and publish-on date, up to a time in the future matching the same span as selected above.'),
+    '#default_value' => $form['scheduler_unpublishing']['#default_value'] ?? 50,
     '#required' => TRUE,
     '#min' => 0,
     '#max' => 100,
@@ -296,55 +698,69 @@ function scheduler_form_devel_generate_form_content_alter(array &$form, FormStat
  */
 function scheduler_form_language_content_settings_form_alter(array &$form, FormStateInterface $form_state) {
   // Add our validation function for the translation field settings form at
-  // admin/config/regional/content-language.
+  // admin/config/regional/content-language
+  // This hook function caters for all entity types, not just nodes.
   $form['#validate'][] = '_scheduler_translation_validate';
 }
 
 /**
  * Validation handler for language_content_settings_form.
  *
- * If the content type is translatable and the field is enabled for Scheduler
+ * For each entity type, if it is translatable and also enabled for Scheduler,
  * but the translation setting for the publish_on / unpublish_on field does not
  * match the 'published status' field setting then throw a validation error.
  *
  * @see https://www.drupal.org/project/scheduler/issues/2871164
  */
 function _scheduler_translation_validate($form, FormStateInterface $form_state) {
-  $content_types = $form_state->getValues()['settings']['node'];
-  $publishing_enabled_types = array_keys(_scheduler_get_scheduler_enabled_node_types('publish'));
-  $unpublishing_enabled_types = array_keys(_scheduler_get_scheduler_enabled_node_types('unpublish'));
-  $enabled = [];
-  foreach ($content_types as $name => $settings) {
-    $enabled['publish_on'] = in_array($name, $publishing_enabled_types);
-    $enabled['unpublish_on'] = in_array($name, $unpublishing_enabled_types);
-    if ($settings['translatable'] && ($enabled['publish_on'] || $enabled['unpublish_on'])) {
-      $params = [
-        '@type' => $form['settings']['node'][$name]['settings']['#label'],
-        '@status' => $form['settings']['node'][$name]['fields']['status']['#label'],
-      ];
-      foreach (['publish_on', 'unpublish_on'] as $var) {
-        $mismatch = $enabled[$var] && ($settings['fields'][$var] <> $settings['fields']['status']);
-        if ($mismatch) {
-          $params['@scheduler_field'] = $form['settings']['node'][$name]['fields'][$var]['#label'];
-          $message = t("Content type '@type' - Translatable settings for status field '@status' and Scheduler field '@scheduler_field' should match, either both on or both off", $params);
-          $form_state->setErrorByName("settings][node][$name][fields][status", $message);
-          $form_state->setErrorByName("settings][node][$name][fields][$var", $message);
+  $settings = $form_state->getValues()['settings'];
+  /** @var \Drupal\scheduler\SchedulerManager $scheduler_manager */
+  $scheduler_manager = \Drupal::service('scheduler.manager');
+  foreach ($settings as $entity_type => $content_types) {
+    $publishing_enabled_types = $scheduler_manager->getEnabledTypes($entity_type, 'publish');
+    $unpublishing_enabled_types = $scheduler_manager->getEnabledTypes($entity_type, 'unpublish');
+    if (empty($publishing_enabled_types) && empty($publishing_enabled_types)) {
+      continue;
+    }
+
+    $enabled = [];
+    foreach ($content_types as $name => $options) {
+      $enabled['publish_on'] = in_array($name, $publishing_enabled_types);
+      $enabled['unpublish_on'] = in_array($name, $unpublishing_enabled_types);
+      if ($options['translatable'] && ($enabled['publish_on'] || $enabled['unpublish_on'])) {
+        $params = [
+          '@entity' => $form['settings'][$entity_type]['#bundle_label'],
+          '@type' => $form['settings'][$entity_type][$name]['settings']['#label'],
+          '%status' => $form['settings'][$entity_type][$name]['fields']['status']['#label'],
+        ];
+        foreach (['publish_on', 'unpublish_on'] as $var) {
+          $mismatch = $enabled[$var] && ($options['fields'][$var] <> $options['fields']['status']);
+          if ($mismatch) {
+            $params['%scheduler_field'] = $form['settings'][$entity_type][$name]['fields'][$var]['#label'];
+            $message = t("There is a problem with @entity '@type' - The translatable settings for status field '%status' and Scheduler field '%scheduler_field' should match, either both on or both off", $params);
+            $form_state->setErrorByName("settings][$entity_type][$name][fields][status", $message);
+            $form_state->setErrorByName("settings][$entity_type][$name][fields][$var", $message);
+          }
         }
       }
     }
   }
+
 }
 
 /**
  * Implements hook_entity_base_field_info().
  */
 function scheduler_entity_base_field_info(EntityTypeInterface $entity_type) {
-  if ($entity_type->id() === 'node') {
+  $fields = [];
+  $entity_types = \Drupal::service('scheduler.manager')->getPluginEntityTypes();
+
+  if (in_array($entity_type->id(), $entity_types)) {
     $fields['publish_on'] = BaseFieldDefinition::create('timestamp')
       ->setLabel(t('Publish on'))
       ->setDisplayOptions('form', [
         'type' => 'datetime_timestamp_no_default',
-        'weight' => 30,
+        'region' => 'hidden',
       ])
       ->setDisplayConfigurable('form', TRUE)
       ->setTranslatable(TRUE)
@@ -355,178 +771,240 @@ function scheduler_entity_base_field_info(EntityTypeInterface $entity_type) {
       ->setLabel(t('Unpublish on'))
       ->setDisplayOptions('form', [
         'type' => 'datetime_timestamp_no_default',
-        'weight' => 30,
+        'region' => 'hidden',
       ])
       ->setDisplayConfigurable('form', TRUE)
       ->setTranslatable(TRUE)
       ->setRevisionable(TRUE)
       ->addConstraint('SchedulerUnpublishOn');
+  }
+
+  return $fields;
+}
 
-    return $fields;
+/**
+ * Implements hook_action_info_alter().
+ */
+function scheduler_action_info_alter(&$definitions) {
+
+  // Workbench Moderation has a bug where the wrong actions are assigned which
+  // causes scheduled publishing of non-moderated content to fail. This fix will
+  // work regardless of the relative weights of the two modules, and will
+  // continue to work even if WBM is fixed before this code is removed.
+  // See https://www.drupal.org/project/workbench_moderation/issues/3238576
+  if (\Drupal::moduleHandler()->moduleExists('workbench_moderation')) {
+    if (isset($definitions['entity:publish_action:node']['class']) && $definitions['entity:publish_action:node']['class'] == ModerationOptOutUnpublishNode::class) {
+      $definitions['entity:publish_action:node']['class'] = ModerationOptOutPublishNode::class;
+    }
+    if (isset($definitions['entity:unpublish_action:node']['class']) && $definitions['entity:unpublish_action:node']['class'] == UnpublishAction::class) {
+      $definitions['entity:unpublish_action:node']['class'] = ModerationOptOutUnpublishNode::class;
+    }
   }
 }
 
 /**
- * Implements hook_ENTITY_TYPE_view() for node entities.
+ * Implements hook_views_data_alter().
  */
-function scheduler_node_view(array &$build, EntityInterface $node, EntityViewDisplayInterface $display, $view_mode) {
-  // If the node is going to be unpublished, then add this information to the
-  // header for search engines. Only do this when the current page is the
-  // full-page view of the node.
+function scheduler_views_data_alter(array &$data) {
+  // By default the 'is null' and 'is not null' operators are only added to the
+  // list of filter options if the view contains a relationship. We want them to
+  // be always available for the scheduler date fields.
+  $entity_types = \Drupal::service('scheduler.manager')->getPluginEntityTypes();
+  foreach ($entity_types as $entityTypeId) {
+    // Not every entity that has a plugin will have these tables, so only set
+    // the allow_empty filter if the top-level key exists.
+    if (isset($data["{$entityTypeId}_field_data"])) {
+      $data["{$entityTypeId}_field_data"]['publish_on']['filter']['allow empty'] = TRUE;
+      $data["{$entityTypeId}_field_data"]['unpublish_on']['filter']['allow empty'] = TRUE;
+    }
+    if (isset($data["{$entityTypeId}_field_revision"])) {
+      $data["{$entityTypeId}_field_revision"]['publish_on']['filter']['allow empty'] = TRUE;
+      $data["{$entityTypeId}_field_revision"]['unpublish_on']['filter']['allow empty'] = TRUE;
+    }
+  }
+
+  // Add a relationship from Media Field Revision back to Media Field Data.
+  // @todo This can be removed when the relationship is added to core.
+  // @see https://www.drupal.org/project/drupal/issues/3036192
+  // Replace the existing 'argument' item.
+  $data['media_field_revision']['mid']['argument'] = [
+    'id' => 'media_mid',
+    'numeric' => TRUE,
+  ];
+  // Add a 'relationship' item.
+  $data['media_field_revision']['mid']['relationship'] = [
+    'id' => 'standard',
+    'base' => 'media_field_data',
+    'field' => 'mid',
+    'base field' => 'mid',
+    'title' => t('Media Field Data'),
+    'help' => t('Relationship to access the Media fields that are not on Media Revision.'),
+    'label' => t('Media Field'),
+    'extra' => [
+      [
+        'field' => 'langcode',
+        'left_field' => 'langcode',
+      ],
+    ],
+  ];
+}
+
+/**
+ * Implements hook_entity_view().
+ */
+function scheduler_entity_view(array &$build, EntityInterface $entity, EntityViewDisplayInterface $display, string $view_mode) {
+  // If the entity is going to be unpublished, then add this information to the
+  // http header for search engines. Only do this when the current page is the
+  // full-page view of the entity.
   // @see https://googleblog.blogspot.be/2007/07/robots-exclusion-protocol-now-with-even.html
-  if (!empty($node->unpublish_on->value) && node_is_page($node)) {
-    $unavailable_after = date(DATE_RFC850, $node->unpublish_on->value);
+  if ($view_mode == 'full' && isset($entity->unpublish_on->value)) {
+    $unavailable_after = date(DATE_RFC850, $entity->unpublish_on->value);
     $build['#attached']['http_header'][] = ['X-Robots-Tag', 'unavailable_after: ' . $unavailable_after];
+
+    // Also add the information as a meta tag in the html head section.
+    $unavailable_meta_tag = [
+      '#tag' => 'meta',
+      '#attributes' => [
+        'name' => 'robots',
+        'content' => 'unavailable_after: ' . $unavailable_after,
+      ],
+    ];
+    // Any value seems to be OK for the second item, but it must not be omitted.
+    $build['#attached']['html_head'][] = [$unavailable_meta_tag, 'robots_unavailable_date'];
   }
 }
 
 /**
- * Implements hook_ENTITY_TYPE_presave() for node entities.
+ * Implements hook_entity_presave().
  */
-function scheduler_node_presave(EntityInterface $node) {
+function scheduler_entity_presave(EntityInterface $entity) {
   $config = \Drupal::config('scheduler.settings');
-  $entity = $node->type->entity;
+  $scheduler_manager = \Drupal::service('scheduler.manager');
   $request_time = \Drupal::time()->getRequestTime();
-  $publish_message = FALSE;
-  $unpublish_message = FALSE;
 
-  // If there is no entity object or the class is incorrect then stop here. This
-  // should not really happen but it has been observed, so better to be safe.
-  // @see https://www.drupal.org/node/2902512
-  if (is_null($entity) || !get_class($entity) == 'Drupal\node\Entity\NodeType') {
+  $publishing_enabled_types = $scheduler_manager->getEnabledTypes($entity->getEntityTypeId(), 'publish');
+  $unpublishing_enabled_types = $scheduler_manager->getEnabledTypes($entity->getEntityTypeId(), 'unpublish');
+  $publishing_enabled = in_array($entity->bundle(), $publishing_enabled_types);
+  $unpublishing_enabled = in_array($entity->bundle(), $unpublishing_enabled_types);
+
+  if (!$publishing_enabled && !$unpublishing_enabled) {
+    // Neither scheduled publishing nor unpublishing are enabled for this
+    // specific bundle/type, so end here.
     return;
-  };
+  }
 
-  // If this node is being created via Devel Generate then set values for the
+  // If this entity is being created via Devel Generate then set values for the
   // publish_on and unpublish_on dates as specified in the devel_generate form.
-  if (isset($node->devel_generate)) {
-    static $publishing_enabled_types;
-    static $unpublishing_enabled_types;
+  if (isset($entity->devel_generate)) {
     static $publishing_percent;
     static $unpublishing_percent;
+    static $entity_created;
     static $time_range;
 
-    if (!isset($publishing_enabled_types)) {
-      $publishing_enabled_types = array_keys(_scheduler_get_scheduler_enabled_node_types('publish'));
-      $unpublishing_enabled_types = array_keys(_scheduler_get_scheduler_enabled_node_types('unpublish'));
+    if (!isset($publishing_percent)) {
       // The values may not be set if calling via drush, so default to zero.
-      $publishing_percent = @$node->devel_generate['scheduler_publishing'] ?: 0;
-      $unpublishing_percent = @$node->devel_generate['scheduler_unpublishing'] ?: 0;
-      // Reuse the selected 'node creation' time range for our future date span.
-      $time_range = $node->devel_generate['time_range'];
+      $publishing_percent = @$entity->devel_generate['scheduler_publishing'] ?: 0;
+      $unpublishing_percent = @$entity->devel_generate['scheduler_unpublishing'] ?: 0;
+      $entity_created = isset($entity->created) ? $entity->created->value : $request_time;
+      // Reuse the selected 'creation' time range for our future date span.
+      $time_range = $entity->devel_generate['time_range'];
     }
-    if ($publishing_percent && in_array($node->getType(), $publishing_enabled_types)) {
+    if ($publishing_percent && $publishing_enabled) {
       if (rand(1, 100) <= $publishing_percent) {
         // Randomly assign a publish_on value in the range starting with the
         // created date and up to the selected time range in the future.
-        $node->set('publish_on', rand($node->created->value + 1, $request_time + $time_range));
+        $entity->set('publish_on', rand($entity_created, $request_time + $time_range));
       }
     }
-    if ($unpublishing_percent && in_array($node->getType(), $unpublishing_enabled_types)) {
+    if ($unpublishing_percent && $unpublishing_enabled) {
       if (rand(1, 100) <= $unpublishing_percent) {
         // Randomly assign an unpublish_on value in the range from the later of
         // created date/publish_on date up to the time range in the future.
-        $node->set('unpublish_on', rand(max($node->created->value, $node->publish_on->value), $request_time + $time_range));
+        $entity->set('unpublish_on', rand(max($entity_created, $entity->publish_on->value), $request_time + $time_range));
       }
     }
   }
 
-  // If the node type is enabled for scheduled publishing and has a publish_on
+  $publish_message = FALSE;
+  $unpublish_message = FALSE;
+
+  // If the entity type is enabled for scheduled publishing and has a publish_on
   // date then check if publishing is allowed and if the content needs to be
   // published immediately.
-  if ($entity->getThirdPartySetting('scheduler', 'publish_enable', $config->get('default_publish_enable')) && !empty($node->publish_on->value)) {
-    // Check that other modules allow the action on this node.
-    $publication_allowed = \Drupal::service('scheduler.manager')->isAllowed($node, 'publish');
+  if ($publishing_enabled && !empty($entity->publish_on->value)) {
+    // Check that other modules allow the action on this entity.
+    $publication_allowed = $scheduler_manager->isAllowed($entity, 'publish');
 
-    // Publish the node immediately if the publication date is in the past.
-    $publish_immediately = $entity->getThirdPartySetting('scheduler', 'publish_past_date', $config->get('default_publish_past_date')) == 'publish';
+    // Publish the entity immediately if the publication date is in the past.
+    $publish_immediately = $scheduler_manager->getThirdPartySetting($entity, 'publish_past_date', $config->get('default_publish_past_date')) == 'publish';
 
-    if ($publication_allowed && $publish_immediately && $node->publish_on->value <= $request_time) {
-      // Trigger the PRE_PUBLISH_INMEDIATELY event so that modules can react
-      // before the node has been published.
-      $event = new SchedulerEvent($node);
-      \Drupal::service('event_dispatcher')->dispatch(SchedulerEvents::PRE_PUBLISH_IMMEDIATELY, $event);
-      $node = $event->getNode();
+    if ($publication_allowed && $publish_immediately && $entity->publish_on->value <= $request_time) {
+      // Trigger the PRE_PUBLISH_IMMEDIATELY event so that modules can react
+      // before the entity has been published.
+      $scheduler_manager->dispatchSchedulerEvent($entity, 'PRE_PUBLISH_IMMEDIATELY');
 
       // Set the 'changed' timestamp to match what would have been done had this
       // content been published via cron.
-      $node->setChangedTime($node->publish_on->value);
+      if ($entity instanceof EntityChangedInterface) {
+        $entity->setChangedTime($entity->publish_on->value);
+      }
+
       // If required, set the created date to match published date.
-      if ($entity->getThirdPartySetting('scheduler', 'publish_touch', $config->get('default_publish_touch')) ||
-      ($node->getCreatedTime() > $node->publish_on->value && $entity->getThirdPartySetting('scheduler', 'publish_past_date_created', $config->get('default_publish_past_date_created')))) {
-        $node->setCreatedTime($node->publish_on->value);
+      if ($scheduler_manager->getThirdPartySetting($entity, 'publish_touch', $config->get('default_publish_touch')) ||
+        ($scheduler_manager->getThirdPartySetting($entity, 'publish_past_date_created', $config->get('default_publish_past_date_created')) && $entity->getCreatedTime() > $entity->publish_on->value)) {
+        $entity->setCreatedTime($entity->publish_on->value);
       }
-      $node->publish_on->value = NULL;
-      $node->setPublished();
+      $entity->publish_on->value = NULL;
+      $entity->setPublished();
 
       // Trigger the PUBLISH_IMMEDIATELY event so that modules can react after
-      // the node has been published.
-      $event = new SchedulerEvent($node);
-      \Drupal::service('event_dispatcher')->dispatch(SchedulerEvents::PUBLISH_IMMEDIATELY, $event);
-      $node = $event->getNode();
+      // the entity has been published.
+      $scheduler_manager->dispatchSchedulerEvent($entity, 'PUBLISH_IMMEDIATELY');
     }
     else {
-      // Ensure the node is unpublished as it will be published by cron later.
-      $node->setUnpublished();
+      // Ensure the entity is unpublished as it will be published by cron later.
+      $entity->setUnpublished();
 
-      // Only inform the user that the node is scheduled if publication has not
-      // been prevented by other modules. Those modules have to display a
+      // Only inform the user that the entity is scheduled if publication has
+      // not been prevented by other modules. Those modules have to display a
       // message themselves explaining why publication is denied.
-      $publish_message = ($publication_allowed && $entity->getThirdPartySetting('scheduler', 'show_message_after_update', $config->get('default_show_message_after_update')));
+      $publish_message = ($publication_allowed && $scheduler_manager->getThirdPartySetting($entity, 'show_message_after_update', $config->get('default_show_message_after_update')));
     }
-  }
+  } // Entity has a publish_on date.
 
-  if ($entity->getThirdPartySetting('scheduler', 'unpublish_enable', $config->get('default_unpublish_enable')) && !empty($node->unpublish_on->value)) {
+  if ($unpublishing_enabled && !empty($entity->unpublish_on->value)) {
     // Scheduler does not do the same 'immediate' processing for unpublishing.
     // However, the api hook should still be called during presave as there may
     // be messages to be displayed if the unpublishing will be disallowed later.
-    $unpublication_allowed = \Drupal::service('scheduler.manager')->isAllowed($node, 'unpublish');
-    $unpublish_message = ($unpublication_allowed && $entity->getThirdPartySetting('scheduler', 'show_message_after_update', $config->get('default_show_message_after_update')));
+    $unpublication_allowed = $scheduler_manager->isAllowed($entity, 'unpublish');
+    $unpublish_message = ($unpublication_allowed && $scheduler_manager->getThirdPartySetting($entity, 'show_message_after_update', $config->get('default_show_message_after_update')));
   }
 
   // Give one message, which will include the publish_on date, the unpublish_on
-  // date or both dates. Cannot make the title into a link here when the node
-  // is being created. But the node module gives the link in the next message.
+  // date or both dates. Cannot make the title into a link here when the entity
+  // is being created. But core provides the link in the subsequent message.
   $date_formatter = \Drupal::service('date.formatter');
   if ($publish_message && $unpublish_message) {
     \Drupal::messenger()->addMessage(t('%title is scheduled to be published @publish_time and unpublished @unpublish_time.', [
-      '%title' => $node->getTitle(),
-      '@publish_time' => $date_formatter->format($node->publish_on->value, 'long'),
-      '@unpublish_time' => $date_formatter->format($node->unpublish_on->value, 'long'),
+      '%title' => $entity->label(),
+      '@publish_time' => $date_formatter->format($entity->publish_on->value, 'long'),
+      '@unpublish_time' => $date_formatter->format($entity->unpublish_on->value, 'long'),
     ]), 'status', FALSE);
   }
   elseif ($publish_message) {
     \Drupal::messenger()->addMessage(t('%title is scheduled to be published @publish_time.', [
-      '%title' => $node->getTitle(),
-      '@publish_time' => $date_formatter->format($node->publish_on->value, 'long'),
+      '%title' => $entity->label(),
+      '@publish_time' => $date_formatter->format($entity->publish_on->value, 'long'),
     ]), 'status', FALSE);
   }
   elseif ($unpublish_message) {
     \Drupal::messenger()->addMessage(t('%title is scheduled to be unpublished @unpublish_time.', [
-      '%title' => $node->getTitle(),
-      '@unpublish_time' => $date_formatter->format($node->unpublish_on->value, 'long'),
+      '%title' => $entity->label(),
+      '@unpublish_time' => $date_formatter->format($entity->unpublish_on->value, 'long'),
     ]), 'status', FALSE);
   }
 }
 
-/**
- * Implements hook_ENTITY_TYPE_insert() for node entities.
- */
-function scheduler_node_insert(EntityInterface $node) {
-  // Removed RULES code but keep the function. There may be code to add here.
-  // @TODO remove this comment when done. JSS Sep 2016.
-}
-
-/**
- * Implements hook_ENTITY_TYPE_update() for node entities.
- */
-function scheduler_node_update(EntityInterface $node) {
-  // Removed RULES code but keep the function. There may be code to add here.
-  // This function is currently called by actions SetPublishingDate and
-  // SetUnpublishingDate.
-  // @TODO remove this comment when done. JSS Sep 2016.
-}
-
 /**
  * Implements hook_cron().
  */
@@ -545,10 +1023,21 @@ function scheduler_cron() {
   // Scheduler 7.x provided hook_scheduler_api() which has been replaced by
   // event dispatching in 8.x. Display a warning in the log if any of these
   // hooks still exist, so that admins and developers are informed.
-  foreach (Drupal::moduleHandler()->getImplementations('scheduler_api') as $module) {
-    \Drupal::logger('scheduler')->warning('Function %function has not been executed. In Drupal 8, implementations of hook_scheduler_api() should be replaced by Scheduler event listeners.', [
-      '%function' => $module . '_scheduler_api',
-    ]);
+  if (version_compare(\Drupal::VERSION, '9.4', '>=')) {
+    // getImplementations() is deprecated in D9.4, use invokeAllWith().
+    \Drupal::moduleHandler()->invokeAllWith('scheduler_api', function (callable $hook, string $module) {
+      \Drupal::logger('scheduler')->warning('Function %function has not been executed. Implementations of hook_scheduler_api() should be replaced by Scheduler event listeners.', [
+        '%function' => $module . '_scheduler_api',
+      ]);
+    });
+  }
+  else {
+    // Use getImplementations() to maintain compatibility with Drupal 8.9.
+    foreach (Drupal::moduleHandler()->getImplementations('scheduler_api') as $module) {
+      \Drupal::logger('scheduler')->warning('Function %function has not been executed. Implementations of hook_scheduler_api() should be replaced by Scheduler event listeners.', [
+        '%function' => $module . '_scheduler_api',
+      ]);
+    }
   }
 
   // Reset the static scheduler_cron flag.
@@ -574,42 +1063,56 @@ function scheduler_cron_is_running() {
 function scheduler_entity_extra_field_info() {
   $config = \Drupal::config('scheduler.settings');
 
+  $plugins = \Drupal::service('scheduler.manager')->getPlugins();
+
   // Expose the Scheduler group on the 'Manage Form Display' tab when editing a
   // content type. This allows admins to adjust the weight of the group, and it
   // works for vertical tabs and separate fieldsets.
   $fields = [];
-  foreach (NodeType::loadMultiple() as $type) {
-    $publishing_enabled = $type->getThirdPartySetting('scheduler', 'publish_enable', $config->get('default_publish_enable'));
-    $unpublishing_enabled = $type->getThirdPartySetting('scheduler', 'unpublish_enable', $config->get('default_unpublish_enable'));
-
-    if ($publishing_enabled || $unpublishing_enabled) {
-      // Weight 20 puts this below the core fields by default.
-      $fields['node'][$type->get('type')]['form']['scheduler_settings'] = [
-        'label' => t('Scheduler Dates'),
-        'description' => t('Fieldset containing Scheduler Publish-on and Unpublish-on date input fields'),
-        'weight' => 20,
-      ];
+
+  foreach ($plugins as $entityTypeId => $plugin) {
+    $types = $plugin->getTypes();
+    foreach ($types as $type) {
+      $publishing_enabled = $type->getThirdPartySetting('scheduler', 'publish_enable', $config->get('default_publish_enable'));
+      $unpublishing_enabled = $type->getThirdPartySetting('scheduler', 'unpublish_enable', $config->get('default_unpublish_enable'));
+
+      if ($publishing_enabled || $unpublishing_enabled) {
+        // Weight 50 puts this below the core fields by default.
+        $fields[$entityTypeId][$type->id()]['form']['scheduler_settings'] = [
+          'label' => t('Scheduler Dates'),
+          'description' => t('Fieldset containing Scheduler Publish-on and Unpublish-on date input fields'),
+          'weight' => 50,
+        ];
+      }
     }
   }
+
   return $fields;
 }
 
 /**
- * Prepares variables for node templates.
- *
- * Makes the publish_on and unpublish_on data available as theme variables.
- *
- * @see template_preprocess_node()
+ * Implements hook_preprocess().
  */
-function scheduler_preprocess_node(&$variables) {
-  $date_formatter = \Drupal::service('date.formatter');
-  /* @var $node \Drupal\node\NodeInterface */
-  $node = $variables['node'];
-  if (!empty($node->publish_on->value) && $node->publish_on->value && is_numeric($node->publish_on->value)) {
-    $variables['publish_on'] = $date_formatter->format($node->publish_on->value, 'long');
+function scheduler_preprocess(&$variables, $hook) {
+  // For entity types that can be processed by Scheduler add the formatted
+  // publish_on and unpublish_on dates as variables for use in theme templates.
+  $plugins = &drupal_static(__FUNCTION__);
+  if (empty($plugins)) {
+    $plugins = \Drupal::service('scheduler.manager')->getPluginEntityTypes();
   }
-  if (!empty($node->unpublish_on->value) && $node->unpublish_on->value && is_numeric($node->unpublish_on->value)) {
-    $variables['unpublish_on'] = $date_formatter->format($node->unpublish_on->value, 'long');
+  // For $hook = 'node' and 'media' the entity is stored in $variables[$hook].
+  // This is not guaranteed, for example in commerce_product the entity is in
+  // $variables['product_entity']. This could be extracted if there is a need,
+  // but for now just skip if the entity is not immediately available.
+  if (in_array($hook, $plugins) && isset($variables[$hook])) {
+    $date_formatter = \Drupal::service('date.formatter');
+    $entity = $variables[$hook];
+    if (!empty($entity->publish_on->value) && $entity->publish_on->value && is_numeric($entity->publish_on->value)) {
+      $variables['publish_on'] = $date_formatter->format($entity->publish_on->value, 'long');
+    }
+    if (!empty($entity->unpublish_on->value) && $entity->unpublish_on->value && is_numeric($entity->unpublish_on->value)) {
+      $variables['unpublish_on'] = $date_formatter->format($entity->unpublish_on->value, 'long');
+    }
   }
 }
 
@@ -619,29 +1122,39 @@ function scheduler_preprocess_node(&$variables) {
  * This function exposes publish_on and unpublish_on as mappable targets to the
  * Feeds module.
  *
+ * @see https://www.drupal.org/project/feeds
+ *
  * @todo Port to Drupal 8.
  *
- * @see https://www.drupal.org/node/2651354
+ * @see https://www.drupal.org/project/scheduler/issues/2651354
  */
 function scheduler_feeds_processor_targets_alter(&$targets, $entity_type, $bundle_name) {
-  $config = \Drupal::config('scheduler.settings');
-
-  // Scheduler module only works on nodes.
-  if ($entity_type == 'node') {
+  // The plugins processing has not been tested, because the function has not
+  // been converted to Drupal 8. The processing below will need to be adjusted
+  // depending on whether $entity_type is a string or an object.
+  // See scheduler_entity_extra_field_info() for an example.
+  // May need to use $scheduler_manager->getThirdPartySetting.
+  $plugins = &drupal_static(__FUNCTION__);
+  if (empty($plugins)) {
+    $plugins = \Drupal::service('scheduler.manager')->getPluginEntityTypes();
+  }
+  if (in_array($entity_type, $plugins)) {
+    $config = \Drupal::config('scheduler.settings');
+    // @todo Get the entity object if $entity_type is a string.
     $publishing_enabled = $entity_type->getThirdPartySetting('scheduler', 'publish_enable', $config->get('default_publish_enable'));
     $unpublishing_enabled = $entity_type->getThirdPartySetting('scheduler', 'unpublish_enable', $config->get('default_unpublish_enable'));
 
     if ($publishing_enabled) {
       $targets['publish_on'] = [
         'name' => t('Scheduler: publish on'),
-        'description' => t('The date when the Scheduler module will publish the node.'),
+        'description' => t('The date when the Scheduler module will publish the content.'),
         'callback' => 'scheduler_feeds_set_target',
       ];
     }
     if ($unpublishing_enabled) {
       $targets['unpublish_on'] = [
         'name' => t('Scheduler: unpublish on'),
-        'description' => t('The date when the Scheduler module will unpublish the node.'),
+        'description' => t('The date when the Scheduler module will unpublish the content.'),
         'callback' => 'scheduler_feeds_set_target',
       ];
     }
@@ -653,7 +1166,7 @@ function scheduler_feeds_processor_targets_alter(&$targets, $entity_type, $bundl
  *
  * @todo Port to Drupal 8.
  *
- * @see https://www.drupal.org/node/2651354
+ * @see https://www.drupal.org/project/scheduler/issues/2651354
  */
 function scheduler_feeds_set_target($source, $entity, $target, $value, $mapping) {
   // We expect a string or integer, but can accomodate an array, by taking the
@@ -673,26 +1186,242 @@ function scheduler_feeds_set_target($source, $entity, $target, $value, $mapping)
     $timestamp = $value;
   }
 
-  // If the timestamp is valid then use it to set the target field in the node.
-  if (is_numeric($timestamp) && $timestamp > 0) {
+  // If the timestamp is valid, use it to set the target field in the entity.
+  if (is_numeric($timestamp)) {
     $entity->$target = $timestamp;
   }
 }
 
 /**
- * Returns all content types for which scheduler has been enabled.
- *
- * @param string $action
- *   The action that needs to be checked. Can be 'publish' or 'unpublish'.
- *
- * @return \Drupal\node\NodeTypeInterface[]
- *   Array of NodeTypeInterface objects
+ * Implements hook_modules_installed().
  */
-function _scheduler_get_scheduler_enabled_node_types($action) {
-  $config = \Drupal::config('scheduler.settings');
-  $node_types = NodeType::loadMultiple();
-  return array_filter($node_types, function ($bundle) use ($action, $config) {
-    /* @var \Drupal\node\NodeTypeInterface $bundle */
-    return $bundle->getThirdPartySetting('scheduler', $action . '_enable', $config->get('default_' . $action . '_enable'));
-  });
+function scheduler_modules_installed($modules) {
+  /** @var \Drupal\scheduler\SchedulerManager $scheduler_manager */
+  $scheduler_manager = \Drupal::service('scheduler.manager');
+  $scheduler_manager->invalidatePluginCache();
+
+  // If there is a Scheduler plugin for a newly installed module then update
+  // the base tables by adding publish_on and unpublish_on for that entity type,
+  // and load/refresh the scheduled view. Third-party modules can provide
+  // Scheduler plugins for entity types that are not defined by that module, or
+  // that do not have the same id as the module name. Similarly, core modules
+  // define entity types for which Scheduler provides the plugin. Hence we need
+  // to check both the plugin entity type and the provider and if either of
+  // these match a module that is being installed we run the update functions.
+  $matches = [];
+  $plugin_definitions = $scheduler_manager->getPluginDefinitions();
+  foreach ($plugin_definitions as $definition) {
+    // If the plugin entity type, provider or dependency match any of the
+    // modules being installed then add the entity type to the $matches list.
+    if (array_intersect([$definition['entityType'], $definition['provider'], $definition['dependency']], $modules)) {
+      $matches[] = $definition['entityType'];
+    }
+  }
+  if (!empty($matches)) {
+    // Add the database fields.
+    $scheduler_manager->entityUpdate();
+    // Load/refresh the scheduler view.
+    $scheduler_manager->viewsUpdate($matches);
+  }
+}
+
+/**
+ * Implements hook_cache_flush().
+ */
+function scheduler_cache_flush() {
+  \Drupal::service('scheduler.manager')->invalidatePluginCache();
+}
+
+/**
+ * Implements hook_migrate_prepare_row().
+ */
+function scheduler_migrate_prepare_row(Row $row, MigrateSourceInterface $source, MigrationInterface $migration) {
+  // getMigrationTags can return null for a custom migration, so skip these.
+  if (is_null($migration->getMigrationTags())) {
+    return;
+  }
+
+  // Process node type third-party-settings.
+  if (in_array('Scheduler Settings', $migration->getMigrationTags())) {
+    $scheduler_config_name = [
+      'scheduler_expand_fieldset_' . $row->getSourceProperty('type'),
+      'scheduler_publish_enable_' . $row->getSourceProperty('type'),
+      'scheduler_publish_past_date_' . $row->getSourceProperty('type'),
+      'scheduler_publish_required_' . $row->getSourceProperty('type'),
+      'scheduler_publish_revision_' . $row->getSourceProperty('type'),
+      'scheduler_publish_touch_' . $row->getSourceProperty('type'),
+      'scheduler_unpublish_enable_' . $row->getSourceProperty('type'),
+      'scheduler_unpublish_required_' . $row->getSourceProperty('type'),
+      'scheduler_unpublish_revision_' . $row->getSourceProperty('type'),
+      'scheduler_use_vertical_tabs_' . $row->getSourceProperty('type'),
+    ];
+    $query = $source->getDatabase()->select('variable', 'v')
+      ->fields('v', ['name', 'value'])
+      ->condition('name', $scheduler_config_name, 'IN');
+    $settings = $query->execute()->fetchAll();
+    $third_party_settings = [];
+    foreach ($settings as $setting) {
+      // Remove 'scheduler_' from the start and '_{type}' from the end to get
+      // the short config name from the full $setting->name.
+      $config_name = str_replace(
+        ['scheduler_', '_' . $row->getSourceProperty('type')],
+        ['', ''],
+        $setting->name
+      );
+      // Two third-party-settings have changed name or values, so convert these.
+      if ($config_name == 'use_vertical_tabs') {
+        if (unserialize($setting->value, ['allowed_classes' => FALSE]) == 1) {
+          $third_party_settings['fields_display_mode'] = 'vertical_tab';
+        }
+        else {
+          $third_party_settings['fields_display_mode'] = 'fieldset';
+        }
+      }
+      elseif ($config_name == 'expand_fieldset') {
+        if (unserialize($setting->value, ['allowed_classes' => FALSE]) == 1) {
+          $third_party_settings['expand_fieldset'] = 'always';
+        }
+        else {
+          $third_party_settings['expand_fieldset'] = 'when_required';
+        }
+      }
+      else {
+        // The remaining eight settings are exactly the same as in Drupal 7.
+        $third_party_settings[$config_name] = unserialize($setting->value, ['allowed_classes' => FALSE]);
+      }
+    }
+    $row->setSourceProperty('scheduler_third_party_settings', $third_party_settings);
+  }
+
+  // Process publish_on and unpublish_on dates.
+  if (in_array('Scheduler Data', $migration->getMigrationTags())) {
+    $database_connection = $source->getDatabase();
+    $result = $database_connection->select('scheduler', 's')
+      ->fields('s', ['publish_on', 'unpublish_on'])
+      ->condition('nid', $row->getSourceProperty('nid'))
+      ->execute()
+      ->fetch();
+    if ($result && $result->publish_on) {
+      $row->setSourceProperty('scheduler_publish_on', $result->publish_on);
+    }
+    if ($result && $result->unpublish_on) {
+      $row->setSourceProperty('scheduler_unpublish_on', $result->unpublish_on);
+    }
+  }
+}
+
+/**
+ * Implements hook_migration_plugins_alter().
+ */
+function scheduler_migration_plugins_alter(array &$migrations) {
+  $migration_plugin_manager = \Drupal::service('plugin.manager.migration');
+  // Create a stub migration with the variable source plugin.
+  // We cannot use MigrationDeriverTrait::getSourcePlugin() directly, because
+  // in PHP 8.1+ calling trait methods is deprecated.
+  // See \Drupal\migrate\Plugin\MigrationDeriverTrait::getSourcePlugin().
+  try {
+    $stub_migration = $migration_plugin_manager->createStubMigration([
+      'source' => [
+        'ignore_map' => TRUE,
+        'plugin' => 'variable',
+        'variables' => [],
+      ],
+      'idMap' => ['plugin' => 'null'],
+      'destination' => ['plugin' => 'null'],
+    ]);
+    // The 'variables' key in source is required (but can be empty) to avoid the
+    // following getSourcePlugin() failing with unknown index 'variables'.
+    $variable_source = $stub_migration->getSourcePlugin();
+    $variable_source->checkRequirements();
+  }
+  catch (RequirementsException $e) {
+    \Drupal::logger('scheduler')->notice('Scheduler settings cannot be migrated due to RequirementsException: %message, %requirements, line %line in %file', [
+      '%message' => $e->getMessage(),
+      '%requirements' => $e->getRequirementsString(),
+      '%line' => $e->getLine(),
+      '%file' => $e->getFile(),
+    ]);
+    return;
+  }
+  catch (PluginException $e) {
+    // The 'variable' source plugin isn't available because Migrate Drupal
+    // isn't enabled. There is nothing we can do.
+    return;
+  }
+  // Before migrating Scheduler, check if the module is enabled.
+  assert($variable_source instanceof DrupalSqlBase);
+  $scheduler_enabled = !empty($variable_source->getSystemData()['module']['scheduler']['status']);
+  if (!$scheduler_enabled) {
+    return;
+  }
+
+  $node_type_migrations = array_filter(
+    $migrations,
+    function ($definition) {
+      return $definition['id'] === 'd7_node_type';
+    }
+  );
+
+  foreach (array_keys($node_type_migrations) as $plugin_id) {
+    $migrations[$plugin_id]['process']['third_party_settings/scheduler'] = 'scheduler_third_party_settings';
+    $migrations[$plugin_id]['migration_tags'][] = 'Scheduler Settings';
+  }
+
+  $node_migrations = array_filter(
+    $migrations,
+    function ($definition) {
+      return $definition['id'] === 'd7_node_complete' || $definition['id'] === 'd7_node';
+    }
+  );
+
+  foreach (array_keys($node_migrations) as $plugin_id) {
+    $migrations[$plugin_id]['process']['publish_on'] = 'scheduler_publish_on';
+    $migrations[$plugin_id]['process']['unpublish_on'] = 'scheduler_unpublish_on';
+    $migrations[$plugin_id]['migration_tags'][] = 'Scheduler Data';
+  }
+
+}
+
+/**
+ * Implements hook_local_tasks_alter().
+ */
+function scheduler_local_tasks_alter(&$local_tasks) {
+  // If the default local tasks for the overviews are also provided by another
+  // module or by Core, then remove the ones added by Scheduler in
+  // src/Plugin/Derivative/DynamicLocalTasks. This is to avoid duplicate links
+  // if this core issue gets committed at some time.
+  // @see https://www.drupal.org/project/drupal/issues/3199682
+
+  // Get the list of routes to check.
+  $routes_to_check = \Drupal::service('scheduler.manager')->getCollectionRoutes();
+
+  // Find all the local tasks with the routes we are searching for and that have
+  // a parent_id. These will be the links that are potential duplicates.
+  $found = [];
+  foreach ($local_tasks as $key => $value) {
+    foreach ($routes_to_check as $route) {
+      if ($value['route_name'] == $route && !empty($value['parent_id'])) {
+        // Save the key of the $local_tasks array in a two level array, keyed on
+        // the route and the module that provided it.
+        $found[$route][$value['provider']] = $key;
+      }
+    }
+  }
+
+  // If there is more than one for any of the routes being checked then remove
+  // the route added by Scheduler.
+  foreach ($found as $route => $data) {
+    if (count($data) > 1) {
+      unset($local_tasks[$data['scheduler']]);
+      unset($data['scheduler']);
+    }
+    // We assume that the duplicates are only caused by Scheduler. Other modules
+    // could be causing more so log this and solve it later if it ever happens.
+    if (count($data) > 1) {
+      \Drupal::logger('scheduler')->warning('Local task route %route contains duplicates in addition to Scheduler. %data', [
+        '%route' => $route,
+        '%data' => print_r($data, TRUE),
+      ]);
+    }
+  }
 }
diff --git a/web/modules/scheduler/scheduler.permissions.yml b/web/modules/scheduler/scheduler.permissions.yml
index 5e36688fa57a312bcd99692fa463a92dcdce9dd2..fe7220ab427c37d58ae7122459f823a803c20133 100644
--- a/web/modules/scheduler/scheduler.permissions.yml
+++ b/web/modules/scheduler/scheduler.permissions.yml
@@ -1,9 +1,7 @@
 'administer scheduler':
   title: 'Administer scheduler'
-  description: 'Configure scheduler date formats, pop-up calendar, default times, lightweight cron'
-'schedule publishing of nodes':
-  title: 'Schedule content publication'
-  description: 'Allows users to set a start and end time for content publication'
-'view scheduled content':
-  title: 'View scheduled content list'
-  description: 'Allows users to see all content which is scheduled.'
+  description: 'Configure scheduler - set default times, lightweight cron.'
+# All other permissions are dynamically created for each supported entity type.
+# See src/SchedulerPermissions.php
+permission_callbacks:
+  - Drupal\scheduler\SchedulerPermissions::permissions
diff --git a/web/modules/scheduler/scheduler.services.yml b/web/modules/scheduler/scheduler.services.yml
index b849e4fd1d95403be1321aeb53277f6d92e01d4e..a889b50d9dfec97bc1ee11991fcc09a4f4a8fb3f 100644
--- a/web/modules/scheduler/scheduler.services.yml
+++ b/web/modules/scheduler/scheduler.services.yml
@@ -9,16 +9,22 @@ services:
       - '@config.factory'
       - '@event_dispatcher'
       - '@datetime.time'
+      - '@entity_field.manager'
+      - '@plugin.manager.scheduler'
   logger.channel.scheduler:
     class: Drupal\Core\Logger\LoggerChannel
     factory: logger.factory:get
     arguments: ['scheduler']
-  access_checker.scheduler_content:
-    class: Drupal\scheduler\Access\ScheduledListAccess
-    arguments: ['@current_route_match']
-    tags:
-      - { name: access_check }
   theme.negotiator.scheduler:
     class: Drupal\scheduler\Theme\SchedulerThemeNegotiator
     tags:
       - { name: theme_negotiator, priority: 10 }
+  plugin.manager.scheduler:
+    class: Drupal\scheduler\SchedulerPluginManager
+    parent: default_plugin_manager
+    arguments:
+      - '@entity_type.manager'
+  scheduler.route_subscriber:
+    class: Drupal\scheduler\Routing\SchedulerRouteSubscriber
+    tags:
+      - { name: event_subscriber }
diff --git a/web/modules/scheduler/scheduler.tokens.inc b/web/modules/scheduler/scheduler.tokens.inc
index 873e6a349fd4e4b4d07d0a0ba846b392940948fe..c6b19ba3cf4ce58d11d8b96c65ace3f81f2b7597 100644
--- a/web/modules/scheduler/scheduler.tokens.inc
+++ b/web/modules/scheduler/scheduler.tokens.inc
@@ -11,16 +11,22 @@
  * Implements hook_token_info().
  */
 function scheduler_token_info() {
-  $info['tokens']['node']['scheduler-publish'] = [
-    'name' => t('Publish on date'),
-    'description' => t("The date the node will be published."),
-    'type' => 'date',
-  ];
-  $info['tokens']['node']['scheduler-unpublish'] = [
-    'name' => t('Unpublish on date'),
-    'description' => t("The date the node will be unpublished."),
-    'type' => 'date',
-  ];
+
+  $plugin_types = \Drupal::service('scheduler.manager')->getPluginEntityTypes();
+  // Initialise the array to avoid 'variable is undefined' phpcs error.
+  $info = [];
+  foreach ($plugin_types as $type) {
+    $info['tokens'][$type]['scheduler-publish'] = [
+      'name' => t('Publish on date'),
+      'description' => t("The date the %type will be published.", ['%type' => $type]),
+      'type' => 'date',
+    ];
+    $info['tokens'][$type]['scheduler-unpublish'] = [
+      'name' => t('Unpublish on date'),
+      'description' => t("The date the %type will be unpublished.", ['%type' => $type]),
+      'type' => 'date',
+    ];
+  }
 
   return $info;
 }
@@ -31,34 +37,36 @@ function scheduler_token_info() {
 function scheduler_tokens($type, $tokens, array $data, array $options, BubbleableMetadata $bubbleable_metadata) {
   $token_service = \Drupal::token();
   $date_formatter = \Drupal::service('date.formatter');
-  $language_code = isset($options['langcode']) ? $options['langcode'] : NULL;
+  $language_code = $options['langcode'] ?? NULL;
   $replacements = [];
 
-  if ($type == 'node' && !empty($data['node'])) {
-    $node = $data['node'];
+  $plugin_types = \Drupal::service('scheduler.manager')->getPluginEntityTypes();
+
+  if (in_array($type, $plugin_types) && !empty($data[$type])) {
+    $entity = $data[$type];
 
     foreach ($tokens as $name => $original) {
       switch ($name) {
         case 'scheduler-publish':
-          if (isset($node->publish_on->value)) {
-            $replacements[$original] = $date_formatter->format($node->publish_on->value, 'medium', '', NULL, $language_code);
+          if (isset($entity->publish_on->value)) {
+            $replacements[$original] = $date_formatter->format($entity->publish_on->value, 'medium', '', NULL, $language_code);
           }
           break;
 
         case 'scheduler-unpublish':
-          if (isset($node->unpublish_on->value)) {
-            $replacements[$original] = $date_formatter->format($node->unpublish_on->value, 'medium', '', NULL, $language_code);
+          if (isset($entity->unpublish_on->value)) {
+            $replacements[$original] = $date_formatter->format($entity->unpublish_on->value, 'medium', '', NULL, $language_code);
           }
           break;
       }
     }
 
     // Chained token replacement.
-    if (isset($node->publish_on->value) && $publish_tokens = $token_service->findWithPrefix($tokens, 'scheduler-publish')) {
-      $replacements += $token_service->generate('date', $publish_tokens, ['date' => $node->publish_on->value], $options, $bubbleable_metadata);
+    if (isset($entity->publish_on->value) && $publish_tokens = $token_service->findWithPrefix($tokens, 'scheduler-publish')) {
+      $replacements += $token_service->generate('date', $publish_tokens, ['date' => $entity->publish_on->value], $options, $bubbleable_metadata);
     }
-    if (isset($node->unpublish_on->value) && $unpublish_tokens = $token_service->findWithPrefix($tokens, 'scheduler-unpublish')) {
-      $replacements += $token_service->generate('date', $unpublish_tokens, ['date' => $node->unpublish_on->value], $options, $bubbleable_metadata);
+    if (isset($entity->unpublish_on->value) && $unpublish_tokens = $token_service->findWithPrefix($tokens, 'scheduler-unpublish')) {
+      $replacements += $token_service->generate('date', $unpublish_tokens, ['date' => $entity->unpublish_on->value], $options, $bubbleable_metadata);
     }
   }
 
diff --git a/web/modules/scheduler/scheduler_rules_integration/composer.json b/web/modules/scheduler/scheduler_rules_integration/composer.json
index e938def725c7dd0cdbd166ff7ef82e95a2eadfb5..fc4b40235fb14f837052f523ebb68d50b7ed67f1 100644
--- a/web/modules/scheduler/scheduler_rules_integration/composer.json
+++ b/web/modules/scheduler/scheduler_rules_integration/composer.json
@@ -4,7 +4,7 @@
     "type": "drupal-module",
     "license": "GPL-2.0-or-later",
     "require": {
-        "drupal/rules": "^3.x",
-        "drupal/scheduler": "^1.0"
+        "drupal/rules": "^3",
+        "drupal/scheduler": ">=2"
     }
 }
diff --git a/web/modules/scheduler/scheduler_rules_integration/scheduler_rules_integration.info.yml b/web/modules/scheduler/scheduler_rules_integration/scheduler_rules_integration.info.yml
index 0bac854684a4639f6c7eace9e1299e2878af758a..c7511cc77e87e461ae1a798249cca74f239f4e39 100644
--- a/web/modules/scheduler/scheduler_rules_integration/scheduler_rules_integration.info.yml
+++ b/web/modules/scheduler/scheduler_rules_integration/scheduler_rules_integration.info.yml
@@ -1,13 +1,12 @@
 name: Scheduler Rules Integration
 type: module
 description: 'Scheduler sub-module providing conditions, actions and events for use with the Rules module.'
-core: 8.x
-core_version_requirement: ^8 || ^9
+core_version_requirement: ^8 || ^9 || ^10
 dependencies:
   - rules:rules
   - scheduler:scheduler
 
-# Information added by Drupal.org packaging script on 2020-06-06
-version: '8.x-1.3'
+# Information added by Drupal.org packaging script on 2022-11-20
+version: '2.0.0-rc8'
 project: 'scheduler'
-datestamp: 1591431340
+datestamp: 1668951020
diff --git a/web/modules/scheduler/scheduler_rules_integration/scheduler_rules_integration.module b/web/modules/scheduler/scheduler_rules_integration/scheduler_rules_integration.module
index beb061572e5d4852a39743021fb97c7538779140..1e8f38e86cff99241d3ab1c05a5a947c13759d9c 100644
--- a/web/modules/scheduler/scheduler_rules_integration/scheduler_rules_integration.module
+++ b/web/modules/scheduler/scheduler_rules_integration/scheduler_rules_integration.module
@@ -11,64 +11,78 @@
  */
 
 use Drupal\Core\Entity\EntityInterface;
-use Drupal\scheduler_rules_integration\Event\NewNodeIsScheduledForPublishingEvent;
-use Drupal\scheduler_rules_integration\Event\NewNodeIsScheduledForUnpublishingEvent;
-use Drupal\scheduler_rules_integration\Event\ExistingNodeIsScheduledForPublishingEvent;
-use Drupal\scheduler_rules_integration\Event\ExistingNodeIsScheduledForUnpublishingEvent;
-use Drupal\scheduler_rules_integration\Event\SchedulerHasPublishedThisNodeEvent;
-use Drupal\scheduler_rules_integration\Event\SchedulerHasUnpublishedThisNodeEvent;
 
 /**
- * Implements hook_ENTITY_TYPE_insert() for node entities.
+ * Dispatch a Rules Integration event for an entity.
+ *
+ * @param \Drupal\Core\Entity\EntityInterface $entity
+ *   The entity object being processed.
+ * @param string $event_id
+ *   The internal event id, for example NEW_FOR_PUBLISHING or CRON_PUBLISHED.
  */
-function scheduler_rules_integration_node_insert(EntityInterface $node) {
-  // Invoke the Rules events to indicate that a new node has been scheduled.
-  $event_dispatcher = \Drupal::service('event_dispatcher');
-  if (!empty($node->publish_on->value)) {
-    // @todo: In 7.x we had the dates as parameters. These are available in Rules via node.publish_on.value so maybe we do not need the parms?
-    $event = new NewNodeIsScheduledForPublishingEvent($node);
-    $event_dispatcher->dispatch(NewNodeIsScheduledForPublishingEvent::EVENT_NAME, $event);
-  }
-  if (!empty($node->unpublish_on->value)) {
-    $event = new NewNodeIsScheduledForUnpublishingEvent($node);
-    $event_dispatcher->dispatch(NewNodeIsScheduledForUnpublishingEvent::EVENT_NAME, $event);
-  }
+function _scheduler_rules_integration_event(EntityInterface $entity, $event_id) {
+  // Derive the fully namespaced event class for the given type of entity. The
+  // entity type id may contain underscores and these need to be converted to
+  // camelCase to match the event class. For example the class for 'node' is
+  // simply RulesNodeEvent, but the class for commerce_product is
+  // RulesCommerceProductEvent.
+  $camelCaseEntityType = str_replace(' ', '', ucwords(str_replace('_', ' ', $entity->getEntityTypeId())));
+  $event_class = "\Drupal\scheduler_rules_integration\Event\Rules{$camelCaseEntityType}Event";
+  $event = new $event_class($entity);
+  $event_name = constant(get_class($event) . "::$event_id");
+  \Drupal::service('scheduler.manager')->dispatch($event, $event_name);
+}
+
+/**
+ * Trigger Rules events during cron.
+ *
+ * This function is called from the main Scheduler module publish() and
+ * unpublish() functions in the SchedulerManager class.
+ *
+ * @param \Drupal\Core\Entity\EntityInterface $entity
+ *   The entity object being processed.
+ * @param string $action
+ *   The action being performed - 'publish' or 'unpublish'.
+ */
+function _scheduler_rules_integration_dispatch_cron_event(EntityInterface $entity, $action) {
+  $event_id = strtoupper("CRON_{$action}ED");
+  _scheduler_rules_integration_event($entity, $event_id);
 }
 
 /**
- * Implements hook_ENTITY_TYPE_update() for node entities.
+ * Implements hook_entity_insert().
  */
-function scheduler_rules_integration_node_update(EntityInterface $node) {
-  // Invoke Rules events to indicate that an existing node has been scheduled.
-  $event_dispatcher = \Drupal::service('event_dispatcher');
-  if (!empty($node->publish_on->value)) {
-    $event = new ExistingNodeIsScheduledForPublishingEvent($node, ['node' => $node]);
-    $event_dispatcher->dispatch(ExistingNodeIsScheduledForPublishingEvent::EVENT_NAME, $event);
+function scheduler_rules_integration_entity_insert(EntityInterface $entity) {
+  // Invoke the Rules events to indicate that a new entity has been scheduled.
+  $scheduler_manager = \Drupal::service('scheduler.manager');
+  // If this entity type is is not supported by Scheduler then go further.
+  if (!$scheduler_manager->getPlugin($entity->getEntityTypeId())) {
+    return;
+  }
+  if (!empty($entity->publish_on->value)) {
+    _scheduler_rules_integration_event($entity, 'NEW_FOR_PUBLISHING');
   }
-  if (!empty($node->unpublish_on->value)) {
-    $event = new ExistingNodeIsScheduledForUnpublishingEvent($node);
-    $event_dispatcher->dispatch(ExistingNodeIsScheduledForUnpublishingEvent::EVENT_NAME, $event);
+  if (!empty($entity->unpublish_on->value)) {
+    _scheduler_rules_integration_event($entity, 'NEW_FOR_UNPUBLISHING');
   }
 }
 
 /**
- * Trigger Rules events during cron.
- *
- * This function is called from the main Scheduler module publish() and
- * unpublish() functions in the SchedulerManager class.
+ * Implements hook_entity_update().
  */
-function _scheduler_rules_integration_dispatch_cron_event(EntityInterface $node, $event_type) {
-  $event_dispatcher = \Drupal::service('event_dispatcher');
-  if ($event_type == 'publish') {
-    // Invoke the event to tell Rules that Scheduler has published this node.
-    // @todo 2nd param $publish_on may be needed as the date will no longer be on the node
-    $event = new SchedulerHasPublishedThisNodeEvent($node);
-    $event_dispatcher->dispatch(SchedulerHasPublishedThisNodeEvent::EVENT_NAME, $event);
+function scheduler_rules_integration_entity_update(EntityInterface $entity) {
+  $scheduler_manager = \Drupal::service('scheduler.manager');
+  // If this entity type is is not supported by Scheduler then go further.
+  if (!$scheduler_manager->getPlugin($entity->getEntityTypeId())) {
+    return;
   }
-  elseif ($event_type == 'unpublish') {
-    // Invoke the event to tell Rules that Scheduler has unpublished this node.
-    // @todo 2nd param $publish_on may be needed as the date will no longer be on the node
-    $event = new SchedulerHasUnpublishedThisNodeEvent($node);
-    $event_dispatcher->dispatch(SchedulerHasUnpublishedThisNodeEvent::EVENT_NAME, $event);
+
+  // Invoke Rules events to indicate that an existing entity has been scheduled.
+  if (!empty($entity->publish_on->value)) {
+    _scheduler_rules_integration_event($entity, 'EXISTING_FOR_PUBLISHING');
+  }
+
+  if (!empty($entity->unpublish_on->value)) {
+    _scheduler_rules_integration_event($entity, 'EXISTING_FOR_UNPUBLISHING');
   }
 }
diff --git a/web/modules/scheduler/scheduler_rules_integration/scheduler_rules_integration.rules.events.yml b/web/modules/scheduler/scheduler_rules_integration/scheduler_rules_integration.rules.events.yml
index c49a780a21f1c7bc1109946fcc5556871a66ca1e..f60ded9ddabb57deb35126e6ff547c3b2c3156d4 100644
--- a/web/modules/scheduler/scheduler_rules_integration/scheduler_rules_integration.rules.events.yml
+++ b/web/modules/scheduler/scheduler_rules_integration/scheduler_rules_integration.rules.events.yml
@@ -1,64 +1,61 @@
+# Six events dispatched for node entity types.
 scheduler_new_node_is_scheduled_for_publishing_event:
-  label: 'After saving new content that is scheduled for publishing'
-  category: 'Scheduler'
-  context:
+  label: 'After saving a new content item that is scheduled for publishing'
+  category: 'Content (Scheduler)'
+  context_definitions:
     node:
       type: 'entity:node'
       label: 'Scheduled Content Node'
       description: 'The node object representing the scheduled content'
-# These parameters were in the 7.x version. May not be needed, but left here
-# for reference until decision is made.
-# @TODO Add the parameters or remove these commented lines. Sep 2016
-#    publish_on:
-#      type: 'integer'
-#      label: 'Scheduler Publish On date'
-#      description: 'Date and time that the node will be published by Scheduler'
-#    unpublish_on:
-#      type: 'integer'
-#      label: 'Scheduler Unpublish On date'
-#      description: 'Date and time that the node will be unpublished by Scheduler'
 
 scheduler_existing_node_is_scheduled_for_publishing_event:
-  label: 'After updating existing content that is scheduled for publishing'
-  category: 'Scheduler'
-  context:
+  label: 'After updating a content item that is scheduled for publishing'
+  category: 'Content (Scheduler)'
+  context_definitions:
     node:
       type: 'entity:node'
       label: 'Scheduled Content Node'
       description: 'The node object representing the scheduled content'
 
 scheduler_new_node_is_scheduled_for_unpublishing_event:
-  label: 'After saving new content that is scheduled for unpublishing'
-  category: 'Scheduler'
-  context:
+  label: 'After saving a new content item that is scheduled for unpublishing'
+  category: 'Content (Scheduler)'
+  context_definitions:
     node:
       type: 'entity:node'
       label: 'Scheduled Content Node'
       description: 'The node object representing the scheduled content'
 
 scheduler_existing_node_is_scheduled_for_unpublishing_event:
-  label: 'After updating existing content that is scheduled for unpublishing'
-  category: 'Scheduler'
-  context:
+  label: 'After updating a content item that is scheduled for unpublishing'
+  category: 'Content (Scheduler)'
+  context_definitions:
     node:
       type: 'entity:node'
       label: 'Scheduled Content Node'
       description: 'The node object representing the scheduled content'
 
 scheduler_has_published_this_node_event:
-  label: 'After a node has been published by Scheduler'
-  category: 'Scheduler'
-  context:
+  label: 'After Scheduler has published a content item'
+  category: 'Content (Scheduler)'
+  context_definitions:
     node:
       type: 'entity:node'
       label: 'Scheduled Content Node'
       description: 'The node object representing the scheduled content'
 
 scheduler_has_unpublished_this_node_event:
-  label: 'After a node has been unpublished by Scheduler'
-  category: 'Scheduler'
-  context:
+  label: 'After Scheduler has unpublished a content item'
+  category: 'Content (Scheduler)'
+  context_definitions:
     node:
       type: 'entity:node'
       label: 'Scheduled Content Node'
       description: 'The node object representing the scheduled content'
+
+# Use a deriver to build the corresponding six events for all other entity
+# types that are supported by Scheduler. This will not create any node events,
+# as they need to remain unchanged as above for backwards compatibilty.
+scheduler:
+  deriver: 'Drupal\scheduler_rules_integration\Event\EventDeriver'
+  class: '\Drupal\rules\EventHandler\ConfigurableEventHandlerEntityBundle'
diff --git a/web/modules/scheduler/scheduler_rules_integration/scheduler_rules_integration.rules_defaults.inc b/web/modules/scheduler/scheduler_rules_integration/scheduler_rules_integration.rules_defaults.inc
index 6ff1dfcd2b6eccd978ac373f74736d6613772a4b..970d9758dcd031fa7335807dc4d20554a5cabb6c 100644
--- a/web/modules/scheduler/scheduler_rules_integration/scheduler_rules_integration.rules_defaults.inc
+++ b/web/modules/scheduler/scheduler_rules_integration/scheduler_rules_integration.rules_defaults.inc
@@ -11,9 +11,12 @@
  * This functions returns an array of rules configurations with the
  * configuration names as keys.
  *
- * @TODO Convert to 8.x, this is stil 7.x code
+ * @todo Convert to 8.x, this is stil 7.x code
  */
 function scheduler_rules_integration_default_rules_configuration() {
+  // Initialise the array to avoid 'variable is undefined' phpcs error.
+  $configs = [];
+
   // Define two reaction rules which will be displayed on the 'Rules' tab. These
   // are initially inactive, but the user can enable them, and then modify the
   // values and/or add more conditions and actions.
diff --git a/web/modules/scheduler/scheduler_rules_integration/src/Event/EventBase.php b/web/modules/scheduler/scheduler_rules_integration/src/Event/EventBase.php
new file mode 100644
index 0000000000000000000000000000000000000000..681983098ec50aa37f7432a0bdf37f6cc951779c
--- /dev/null
+++ b/web/modules/scheduler/scheduler_rules_integration/src/Event/EventBase.php
@@ -0,0 +1,18 @@
+<?php
+
+namespace Drupal\scheduler_rules_integration\Event;
+
+// Drupal\Component\EventDispatcher\Event was introduced in Drupal core 9.1 to
+// assist with deprecations and the transition to Symfony 5.
+// @todo Remove this when core 9.1 is the lowest supported version.
+// @see https://www.drupal.org/project/scheduler/issues/3166688
+if (!class_exists('Drupal\Component\EventDispatcher\Event')) {
+  class_alias('Symfony\Component\EventDispatcher\Event', 'Drupal\Component\EventDispatcher\Event');
+}
+
+use Drupal\Component\EventDispatcher\Event;
+
+/**
+ * Base class on which all Scheduler Rules Integration events are extended.
+ */
+class EventBase extends Event {}
diff --git a/web/modules/scheduler/scheduler_rules_integration/src/Event/EventDeriver.php b/web/modules/scheduler/scheduler_rules_integration/src/Event/EventDeriver.php
new file mode 100644
index 0000000000000000000000000000000000000000..402c0cdacea44cace109987ea4236d15e22f16c3
--- /dev/null
+++ b/web/modules/scheduler/scheduler_rules_integration/src/Event/EventDeriver.php
@@ -0,0 +1,125 @@
+<?php
+
+namespace Drupal\scheduler_rules_integration\Event;
+
+use Drupal\Component\Plugin\Derivative\DeriverBase;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Plugin\Discovery\ContainerDeriverInterface;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\Core\StringTranslation\TranslationInterface;
+use Drupal\scheduler\SchedulerManager;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Derives Rules events for all non-node entities supported by Scheduler.
+ *
+ * This creates events with names starting with a prefix of "scheduler:" as
+ * defined by the property name in scheduler_rules_integration.rules.events.yml,
+ * followed by the text in keys of the array $this->derivatives.
+ *
+ * The processing below is based on code in the Rules module. For an example see
+ * src/Plugin/RulesEvent/EntityUpdateDeriver.php. For backwards compatibility
+ * the node event names must remain unchnaged, and this is not possible when
+ * using this deriver. Hence the node event names stay written out long-hand in
+ * scheduler_rules_integration.rules.events.yml.
+ */
+class EventDeriver extends DeriverBase implements ContainerDeriverInterface {
+  use StringTranslationTrait;
+
+  /**
+   * The entity type manager.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * The scheduler manager.
+   *
+   * @var \Drupal\scheduler\SchedulerManager
+   */
+  protected $schedulerManager;
+
+  /**
+   * Creates a new EventDeriver object.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+   *   The entity type manager.
+   * @param \Drupal\Core\StringTranslation\TranslationInterface $string_translation
+   *   The string translation service.
+   * @param \Drupal\scheduler\SchedulerManager $scheduler_manager
+   *   The scheduler manager.
+   */
+  public function __construct(EntityTypeManagerInterface $entity_type_manager, TranslationInterface $string_translation, SchedulerManager $scheduler_manager) {
+    $this->entityTypeManager = $entity_type_manager;
+    $this->stringTranslation = $string_translation;
+    $this->schedulerManager = $scheduler_manager;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container, $base_plugin_id) {
+    return new static(
+      $container->get('entity_type.manager'),
+      $container->get('string_translation'),
+      $container->get('scheduler.manager')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getDerivativeDefinitions($base_plugin_definition) {
+    // Get all entity types supported by Scheduler plugins.
+    foreach ($this->schedulerManager->getPluginEntityTypes() as $entity_type_id) {
+      // Node events are the originals, and for backwards-compatibility those
+      // event ids must remain unchanged, which cannot be done with the deriver.
+      // So they remain defined in scheduler_rules_integration.rules.events.yml.
+      if ($entity_type_id == 'node') {
+        continue;
+      }
+      $entity_type = $this->entityTypeManager->getDefinition($entity_type_id);
+
+      // Define the values that are the same for all events of this entity type.
+      $defaults = [
+        'entity_type_id' => $entity_type_id,
+        'category' => $entity_type->getLabel() . ' (' . $this->t('Scheduler') . ')',
+        'context_definitions' => [
+          $entity_type_id => [
+            'type' => "entity:$entity_type_id",
+            'label' => $this->t('The object representing the scheduled @entity_type', ['@entity_type' => $entity_type->getLabel()]),
+          ],
+        ],
+      ];
+
+      // Create six events for this entity type.
+      $this->derivatives["new_{$entity_type_id}_is_scheduled_for_publishing"] = [
+        'label' => $this->t('After saving a new @entity_type that is scheduled for publishing', ['@entity_type' => $entity_type->getSingularLabel()]),
+      ] + $defaults + $base_plugin_definition;
+
+      $this->derivatives["new_{$entity_type_id}_is_scheduled_for_unpublishing"] = [
+        'label' => $this->t('After saving a new @entity_type that is scheduled for unpublishing', ['@entity_type' => $entity_type->getSingularLabel()]),
+      ] + $defaults + $base_plugin_definition;
+
+      $this->derivatives["existing_{$entity_type_id}_is_scheduled_for_publishing"] = [
+        'label' => $this->t('After updating a @entity_type that is scheduled for publishing', ['@entity_type' => $entity_type->getSingularLabel()]),
+      ] + $defaults + $base_plugin_definition;
+
+      $this->derivatives["existing_{$entity_type_id}_is_scheduled_for_unpublishing"] = [
+        'label' => $this->t('After updating a @entity_type that is scheduled for unpublishing', ['@entity_type' => $entity_type->getSingularLabel()]),
+      ] + $defaults + $base_plugin_definition;
+
+      $this->derivatives["{$entity_type_id}_has_been_published_via_cron"] = [
+        'label' => $this->t('After Scheduler has published a @entity_type', ['@entity_type' => $entity_type->getSingularLabel()]),
+      ] + $defaults + $base_plugin_definition;
+
+      $this->derivatives["{$entity_type_id}_has_been_unpublished_via_cron"] = [
+        'label' => $this->t('After Scheduler has unpublished a @entity_type', ['@entity_type' => $entity_type->getSingularLabel()]),
+      ] + $defaults + $base_plugin_definition;
+
+    }
+    return $this->derivatives;
+  }
+
+}
diff --git a/web/modules/scheduler/scheduler_rules_integration/src/Event/ExistingNodeIsScheduledForPublishingEvent.php b/web/modules/scheduler/scheduler_rules_integration/src/Event/ExistingNodeIsScheduledForPublishingEvent.php
deleted file mode 100644
index 5706b208ea036bbfd95b193111664e1390071572..0000000000000000000000000000000000000000
--- a/web/modules/scheduler/scheduler_rules_integration/src/Event/ExistingNodeIsScheduledForPublishingEvent.php
+++ /dev/null
@@ -1,35 +0,0 @@
-<?php
-
-namespace Drupal\scheduler_rules_integration\Event;
-
-use Drupal\node\NodeInterface;
-use Symfony\Component\EventDispatcher\Event;
-
-/**
- * An existing node is scheduled for publishing.
- *
- * This event is fired when an existing node is updated/saved and it has a
- * scheduled publishing date.
- */
-class ExistingNodeIsScheduledForPublishingEvent extends Event {
-
-  const EVENT_NAME = 'scheduler_existing_node_is_scheduled_for_publishing_event';
-
-  /**
-   * The node which is being scheduled and saved.
-   *
-   * @var \Drupal\node\NodeInterface
-   */
-  public $node;
-
-  /**
-   * Constructs the object.
-   *
-   * @param \Drupal\node\NodeInterface $node
-   *   The node which is being scheduled and saved.
-   */
-  public function __construct(NodeInterface $node) {
-    $this->node = $node;
-  }
-
-}
diff --git a/web/modules/scheduler/scheduler_rules_integration/src/Event/ExistingNodeIsScheduledForUnpublishingEvent.php b/web/modules/scheduler/scheduler_rules_integration/src/Event/ExistingNodeIsScheduledForUnpublishingEvent.php
deleted file mode 100644
index 10a3163440ef8d04767b211210cc506de9b524e8..0000000000000000000000000000000000000000
--- a/web/modules/scheduler/scheduler_rules_integration/src/Event/ExistingNodeIsScheduledForUnpublishingEvent.php
+++ /dev/null
@@ -1,35 +0,0 @@
-<?php
-
-namespace Drupal\scheduler_rules_integration\Event;
-
-use Drupal\node\NodeInterface;
-use Symfony\Component\EventDispatcher\Event;
-
-/**
- * An existing node is scheduled for unpublishing.
- *
- * This event is fired when an existing node is updated/saved and it has a
- * scheduled unpublishing date.
- */
-class ExistingNodeIsScheduledForUnpublishingEvent extends Event {
-
-  const EVENT_NAME = 'scheduler_existing_node_is_scheduled_for_unpublishing_event';
-
-  /**
-   * The node which is being scheduled and saved.
-   *
-   * @var \Drupal\node\NodeInterface
-   */
-  public $node;
-
-  /**
-   * Constructs the object.
-   *
-   * @param \Drupal\node\NodeInterface $node
-   *   The node which is being scheduled and saved.
-   */
-  public function __construct(NodeInterface $node) {
-    $this->node = $node;
-  }
-
-}
diff --git a/web/modules/scheduler/scheduler_rules_integration/src/Event/NewNodeIsScheduledForPublishingEvent.php b/web/modules/scheduler/scheduler_rules_integration/src/Event/NewNodeIsScheduledForPublishingEvent.php
deleted file mode 100644
index 8010e1df5cccc238fb81f95452e139725063e6d0..0000000000000000000000000000000000000000
--- a/web/modules/scheduler/scheduler_rules_integration/src/Event/NewNodeIsScheduledForPublishingEvent.php
+++ /dev/null
@@ -1,35 +0,0 @@
-<?php
-
-namespace Drupal\scheduler_rules_integration\Event;
-
-use Drupal\node\NodeInterface;
-use Symfony\Component\EventDispatcher\Event;
-
-/**
- * A new node is scheduled for publishing.
- *
- * This event is fired when a newly created node is saved for the first time
- * and it has a scheduled publishing date.
- */
-class NewNodeIsScheduledForPublishingEvent extends Event {
-
-  const EVENT_NAME = 'scheduler_new_node_is_scheduled_for_publishing_event';
-
-  /**
-   * The node which is being scheduled and saved.
-   *
-   * @var \Drupal\node\NodeInterface
-   */
-  public $node;
-
-  /**
-   * Constructs the object.
-   *
-   * @param \Drupal\node\NodeInterface $node
-   *   The node which is being scheduled and saved.
-   */
-  public function __construct(NodeInterface $node) {
-    $this->node = $node;
-  }
-
-}
diff --git a/web/modules/scheduler/scheduler_rules_integration/src/Event/NewNodeIsScheduledForUnpublishingEvent.php b/web/modules/scheduler/scheduler_rules_integration/src/Event/NewNodeIsScheduledForUnpublishingEvent.php
deleted file mode 100644
index 2e638595b60faaa8c1679323f2e73cfc9d57d055..0000000000000000000000000000000000000000
--- a/web/modules/scheduler/scheduler_rules_integration/src/Event/NewNodeIsScheduledForUnpublishingEvent.php
+++ /dev/null
@@ -1,35 +0,0 @@
-<?php
-
-namespace Drupal\scheduler_rules_integration\Event;
-
-use Drupal\node\NodeInterface;
-use Symfony\Component\EventDispatcher\Event;
-
-/**
- * A new node is scheduled for unpublishing.
- *
- * This event is fired when a newly created node is saved for the first time
- * and it has a scheduled unpublishing date.
- */
-class NewNodeIsScheduledForUnpublishingEvent extends Event {
-
-  const EVENT_NAME = 'scheduler_new_node_is_scheduled_for_unpublishing_event';
-
-  /**
-   * The node which is being scheduled and saved.
-   *
-   * @var \Drupal\node\NodeInterface
-   */
-  public $node;
-
-  /**
-   * Constructs the object.
-   *
-   * @param \Drupal\node\NodeInterface $node
-   *   The node which is being scheduled and saved.
-   */
-  public function __construct(NodeInterface $node) {
-    $this->node = $node;
-  }
-
-}
diff --git a/web/modules/scheduler/scheduler_rules_integration/src/Event/RulesCommerceProductEvent.php b/web/modules/scheduler/scheduler_rules_integration/src/Event/RulesCommerceProductEvent.php
new file mode 100644
index 0000000000000000000000000000000000000000..64ce4de0b2967749050855827a7b8a48301c43e8
--- /dev/null
+++ b/web/modules/scheduler/scheduler_rules_integration/src/Event/RulesCommerceProductEvent.php
@@ -0,0 +1,67 @@
+<?php
+
+namespace Drupal\scheduler_rules_integration\Event;
+
+use Drupal\commerce_product\Entity\ProductInterface;
+
+/**
+ * Class for all Commerce Product events for use in Rules module.
+ */
+class RulesCommerceProductEvent extends EventBase {
+
+  /**
+   * Define constants to convert the event identifier into the full event name.
+   *
+   * The final event names here are defined in the event deriver and are
+   * different in format from the event names for node events, as originally
+   * coded long-hand in scheduler_rules_integration.rules.events.yml.
+   * However, the identifiers (CRON_PUBLISHED, NEW_FOR_PUBLISHING, etc) are the
+   * same for all types and this is how the actual event names are retrieved.
+   */
+  const CRON_PUBLISHED = 'scheduler:commerce_product_has_been_published_via_cron';
+  const CRON_UNPUBLISHED = 'scheduler:commerce_product_has_been_unpublished_via_cron';
+  const NEW_FOR_PUBLISHING = 'scheduler:new_commerce_product_is_scheduled_for_publishing';
+  const NEW_FOR_UNPUBLISHING = 'scheduler:new_commerce_product_is_scheduled_for_unpublishing';
+  const EXISTING_FOR_PUBLISHING = 'scheduler:existing_commerce_product_is_scheduled_for_publishing';
+  const EXISTING_FOR_UNPUBLISHING = 'scheduler:existing_commerce_product_is_scheduled_for_unpublishing';
+
+  /**
+   * The commerce product which is being processed.
+   *
+   * This property name could be changed to lowerCamelCase but that would also
+   * require the context_definitions key to be changed to match. This could also
+   * be done, but when editing a rule we get commerceproduct in the drop-downs,
+   * whereas all other usages in the Rules forms have commerce_product. This is
+   * confusing for the admin/developer who has to select from this list when
+   * editing a rule. Therefore keep the property name matching the entity type
+   * id and prevent Coder from reporting the invalid name by disabling this
+   * specific sniff for this file only.
+   *
+   * phpcs:disable Drupal.NamingConventions.ValidVariableName.LowerCamelName
+   *
+   * @var Drupal\commerce_product\Entity\ProductInterface
+   */
+  public $commerce_product;
+
+  /**
+   * Constructs the object.
+   *
+   * @param Drupal\commerce_product\Entity\ProductInterface $commerce_product
+   *   The commerce_product item which is being processed.
+   */
+  public function __construct(ProductInterface $commerce_product) {
+    $this->commerce_product = $commerce_product;
+  }
+
+  /**
+   * Returns the entity which is being processed.
+   */
+  public function getEntity() {
+    // The Rules module requires the entity to be stored in a specifically named
+    // property which will obviously vary according to the entity type being
+    // processed. This generic getEntity() method is not strictly required by
+    // Rules but is added for convenience when manipulating the event entity.
+    return $this->commerce_product;
+  }
+
+}
diff --git a/web/modules/scheduler/scheduler_rules_integration/src/Event/RulesMediaEvent.php b/web/modules/scheduler/scheduler_rules_integration/src/Event/RulesMediaEvent.php
new file mode 100644
index 0000000000000000000000000000000000000000..a2e4f974bf548840e813880cb023b73e5cac2732
--- /dev/null
+++ b/web/modules/scheduler/scheduler_rules_integration/src/Event/RulesMediaEvent.php
@@ -0,0 +1,56 @@
+<?php
+
+namespace Drupal\scheduler_rules_integration\Event;
+
+use Drupal\media\MediaInterface;
+
+/**
+ * Class for all Rules media events.
+ */
+class RulesMediaEvent extends EventBase {
+
+  /**
+   * Define constants to convert the event identifier into the full event name.
+   *
+   * The final event names here are defined in the event deriver and are
+   * different in format from the event names for node events, as originally
+   * coded long-hand in scheduler_rules_integration.rules.events.yml.
+   * However, the identifiers (CRON_PUBLISHED, NEW_FOR_PUBLISHING, etc) are the
+   * same for all types and this is how the actual event names are retrieved.
+   */
+  const CRON_PUBLISHED = 'scheduler:media_has_been_published_via_cron';
+  const CRON_UNPUBLISHED = 'scheduler:media_has_been_unpublished_via_cron';
+  const NEW_FOR_PUBLISHING = 'scheduler:new_media_is_scheduled_for_publishing';
+  const NEW_FOR_UNPUBLISHING = 'scheduler:new_media_is_scheduled_for_unpublishing';
+  const EXISTING_FOR_PUBLISHING = 'scheduler:existing_media_is_scheduled_for_publishing';
+  const EXISTING_FOR_UNPUBLISHING = 'scheduler:existing_media_is_scheduled_for_unpublishing';
+
+  /**
+   * The media item which is being processed.
+   *
+   * @var \Drupal\media\MediaInterface
+   */
+  public $media;
+
+  /**
+   * Constructs the object.
+   *
+   * @param \Drupal\media\MediaInterface $media
+   *   The media item which is being processed.
+   */
+  public function __construct(MediaInterface $media) {
+    $this->media = $media;
+  }
+
+  /**
+   * Returns the entity which is being processed.
+   */
+  public function getEntity() {
+    // The Rules module requires the entity to be stored in a specifically named
+    // property which will obviously vary according to the entity type being
+    // processed. This generic getEntity() method is not strictly required by
+    // Rules but is added for convenience when manipulating the event entity.
+    return $this->media;
+  }
+
+}
diff --git a/web/modules/scheduler/scheduler_rules_integration/src/Event/RulesNodeEvent.php b/web/modules/scheduler/scheduler_rules_integration/src/Event/RulesNodeEvent.php
new file mode 100644
index 0000000000000000000000000000000000000000..dfabe82a55406056a9db4292fea57d83bdeb9bd4
--- /dev/null
+++ b/web/modules/scheduler/scheduler_rules_integration/src/Event/RulesNodeEvent.php
@@ -0,0 +1,56 @@
+<?php
+
+namespace Drupal\scheduler_rules_integration\Event;
+
+use Drupal\node\NodeInterface;
+
+/**
+ * Class for all Rules node events.
+ */
+class RulesNodeEvent extends EventBase {
+
+  /**
+   * Define constants to convert the event identifier into the full event name.
+   *
+   * To retain backwards compatibility the event names for node events remain as
+   * originally specified in scheduler_rules_integration.rules.events.yml. The
+   * format is different from the new events derived for other entity types.
+   * However, the identifiers (CRON_PUBLISHED, NEW_FOR_PUBLISHING, etc) are the
+   * same for all types and this is how the actual event names are retrieved.
+   */
+  const CRON_PUBLISHED = 'scheduler_has_published_this_node_event';
+  const CRON_UNPUBLISHED = 'scheduler_has_unpublished_this_node_event';
+  const NEW_FOR_PUBLISHING = 'scheduler_new_node_is_scheduled_for_publishing_event';
+  const NEW_FOR_UNPUBLISHING = 'scheduler_new_node_is_scheduled_for_unpublishing_event';
+  const EXISTING_FOR_PUBLISHING = 'scheduler_existing_node_is_scheduled_for_publishing_event';
+  const EXISTING_FOR_UNPUBLISHING = 'scheduler_existing_node_is_scheduled_for_unpublishing_event';
+
+  /**
+   * The node which is being processed.
+   *
+   * @var \Drupal\node\NodeInterface
+   */
+  public $node;
+
+  /**
+   * Constructs the object.
+   *
+   * @param \Drupal\node\NodeInterface $node
+   *   The node which is being processed.
+   */
+  public function __construct(NodeInterface $node) {
+    $this->node = $node;
+  }
+
+  /**
+   * Returns the entity which is being processed.
+   */
+  public function getEntity() {
+    // The Rules module requires the entity to be stored in a specifically named
+    // property which will obviously vary according to the entity type being
+    // processed. This generic getEntity() method is not strictly required by
+    // Rules but is added for convenience when manipulating the event entity.
+    return $this->node;
+  }
+
+}
diff --git a/web/modules/scheduler/scheduler_rules_integration/src/Event/RulesTaxonomyTermEvent.php b/web/modules/scheduler/scheduler_rules_integration/src/Event/RulesTaxonomyTermEvent.php
new file mode 100644
index 0000000000000000000000000000000000000000..932b3bafc974c32142a2558310bdd5e50a5656b4
--- /dev/null
+++ b/web/modules/scheduler/scheduler_rules_integration/src/Event/RulesTaxonomyTermEvent.php
@@ -0,0 +1,67 @@
+<?php
+
+namespace Drupal\scheduler_rules_integration\Event;
+
+use Drupal\taxonomy\TermInterface;
+
+/**
+ * Class for all Rules taxonomy term events.
+ */
+class RulesTaxonomyTermEvent extends EventBase {
+
+  /**
+   * Define constants to convert the event identifier into the full event name.
+   *
+   * The final event names here are defined in the event deriver and are
+   * different in format from the event names for node events, as originally
+   * coded long-hand in scheduler_rules_integration.rules.events.yml.
+   * However, the identifiers (CRON_PUBLISHED, NEW_FOR_PUBLISHING, etc) are the
+   * same for all types and this is how the actual event names are retrieved.
+   */
+  const CRON_PUBLISHED = 'scheduler:taxonomy_term_has_been_published_via_cron';
+  const CRON_UNPUBLISHED = 'scheduler:taxonomy_term_has_been_unpublished_via_cron';
+  const NEW_FOR_PUBLISHING = 'scheduler:new_taxonomy_term_is_scheduled_for_publishing';
+  const NEW_FOR_UNPUBLISHING = 'scheduler:new_taxonomy_term_is_scheduled_for_unpublishing';
+  const EXISTING_FOR_PUBLISHING = 'scheduler:existing_taxonomy_term_is_scheduled_for_publishing';
+  const EXISTING_FOR_UNPUBLISHING = 'scheduler:existing_taxonomy_term_is_scheduled_for_unpublishing';
+
+  /**
+   * The taxonomy term which is being processed.
+   *
+   * This property name could be changed to lowerCamelCase but that would also
+   * require the context_definitions key to be changed to match. This could also
+   * be done, but when editing a rule we get taxonomyterm in the drop-downs,
+   * whereas all other usages in the Rules forms have taxonomy_term. This is
+   * confusing for the admin/developer who has to select from this list when
+   * editing a rule. Therefore keep the property name matching the entity type
+   * id and prevent Coder from reporting the invalid name by disabling this
+   * specific sniff for this file only.
+   *
+   * phpcs:disable Drupal.NamingConventions.ValidVariableName.LowerCamelName
+   *
+   * @var \Drupal\taxonomy\TermInterface
+   */
+  public $taxonomy_term;
+
+  /**
+   * Constructs the object.
+   *
+   * @param \Drupal\taxonomy\TermInterface $taxonomy_term
+   *   The taxonomy term is being processed.
+   */
+  public function __construct(TermInterface $taxonomy_term) {
+    $this->taxonomy_term = $taxonomy_term;
+  }
+
+  /**
+   * Returns the entity which is being processed.
+   */
+  public function getEntity() {
+    // The Rules module requires the entity to be stored in a specifically named
+    // property which will obviously vary according to the entity type being
+    // processed. This generic getEntity() method is not strictly required by
+    // Rules but is added for convenience when manipulating the event entity.
+    return $this->taxonomy_term;
+  }
+
+}
diff --git a/web/modules/scheduler/scheduler_rules_integration/src/Event/SchedulerHasPublishedThisNodeEvent.php b/web/modules/scheduler/scheduler_rules_integration/src/Event/SchedulerHasPublishedThisNodeEvent.php
deleted file mode 100644
index da4afa62df8a5a9e6fc5a854c7eb6beb6fd8a7db..0000000000000000000000000000000000000000
--- a/web/modules/scheduler/scheduler_rules_integration/src/Event/SchedulerHasPublishedThisNodeEvent.php
+++ /dev/null
@@ -1,34 +0,0 @@
-<?php
-
-namespace Drupal\scheduler_rules_integration\Event;
-
-use Drupal\node\NodeInterface;
-use Symfony\Component\EventDispatcher\Event;
-
-/**
- * A node is published by Scheduler.
- *
- * This event is fired when Scheduler publishes a node via cron.
- */
-class SchedulerHasPublishedThisNodeEvent extends Event {
-
-  const EVENT_NAME = 'scheduler_has_published_this_node_event';
-
-  /**
-   * The node which has been processed.
-   *
-   * @var \Drupal\node\NodeInterface
-   */
-  public $node;
-
-  /**
-   * Constructs the object.
-   *
-   * @param \Drupal\node\NodeInterface $node
-   *   The node which has been published by Scheduler.
-   */
-  public function __construct(NodeInterface $node) {
-    $this->node = $node;
-  }
-
-}
diff --git a/web/modules/scheduler/scheduler_rules_integration/src/Event/SchedulerHasUnpublishedThisNodeEvent.php b/web/modules/scheduler/scheduler_rules_integration/src/Event/SchedulerHasUnpublishedThisNodeEvent.php
deleted file mode 100644
index 08b6937117a05c104a86dc86f2e3b7d275db7108..0000000000000000000000000000000000000000
--- a/web/modules/scheduler/scheduler_rules_integration/src/Event/SchedulerHasUnpublishedThisNodeEvent.php
+++ /dev/null
@@ -1,34 +0,0 @@
-<?php
-
-namespace Drupal\scheduler_rules_integration\Event;
-
-use Drupal\node\NodeInterface;
-use Symfony\Component\EventDispatcher\Event;
-
-/**
- * A node is unpublished by Scheduler.
- *
- * This event is fired when Scheduler unpublishes a node via cron.
- */
-class SchedulerHasUnpublishedThisNodeEvent extends Event {
-
-  const EVENT_NAME = 'scheduler_has_unpublished_this_node_event';
-
-  /**
-   * The node which has been processed..
-   *
-   * @var \Drupal\node\NodeInterface
-   */
-  public $node;
-
-  /**
-   * Constructs the object.
-   *
-   * @param \Drupal\node\NodeInterface $node
-   *   The node which has been unpublished by Scheduler.
-   */
-  public function __construct(NodeInterface $node) {
-    $this->node = $node;
-  }
-
-}
diff --git a/web/modules/scheduler/scheduler_rules_integration/src/Plugin/Condition/ConditionDeriver.php b/web/modules/scheduler/scheduler_rules_integration/src/Plugin/Condition/ConditionDeriver.php
new file mode 100644
index 0000000000000000000000000000000000000000..e99dfd5d52a420b99e6c3dc692a6a0133017e22d
--- /dev/null
+++ b/web/modules/scheduler/scheduler_rules_integration/src/Plugin/Condition/ConditionDeriver.php
@@ -0,0 +1,143 @@
+<?php
+
+namespace Drupal\scheduler_rules_integration\Plugin\Condition;
+
+use Drupal\Component\Plugin\Derivative\DeriverBase;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Plugin\Discovery\ContainerDeriverInterface;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\Core\StringTranslation\TranslationInterface;
+use Drupal\rules\Context\ContextDefinition;
+use Drupal\scheduler\SchedulerManager;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Derives conditions for each supported entity type (except nodes).
+ */
+class ConditionDeriver extends DeriverBase implements ContainerDeriverInterface {
+
+  use StringTranslationTrait;
+
+  /**
+   * The entity type manager.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * The scheduler manager.
+   *
+   * @var \Drupal\scheduler\SchedulerManager
+   */
+  protected $schedulerManager;
+
+  /**
+   * Creates a new deriver object.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+   *   The entity type manager.
+   * @param \Drupal\Core\StringTranslation\TranslationInterface $string_translation
+   *   The string translation service.
+   * @param \Drupal\scheduler\SchedulerManager $scheduler_manager
+   *   The scheduler manager.
+   */
+  public function __construct(EntityTypeManagerInterface $entity_type_manager, TranslationInterface $string_translation, SchedulerManager $scheduler_manager) {
+    $this->entityTypeManager = $entity_type_manager;
+    $this->stringTranslation = $string_translation;
+    $this->schedulerManager = $scheduler_manager;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container, $base_plugin_id) {
+    return new static(
+      $container->get('entity_type.manager'),
+      $container->get('string_translation'),
+      $container->get('scheduler.manager')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getDerivativeDefinitions($base_plugin_definition) {
+    // Get all entity types supported by Scheduler plugins.
+    $base_plugin_id = $base_plugin_definition['id'];
+    foreach ($this->schedulerManager->getPluginEntityTypes() as $entity_type_id) {
+      // Node actions are the originals, and for backwards-compatibility those
+      // action ids must remain the same, which can not be done using this
+      // deriver. Hence the node actions are defined in the 'Legacy' classes.
+      if ($entity_type_id == 'node') {
+        continue;
+      }
+      $entity_type = $this->entityTypeManager->getDefinition($entity_type_id);
+
+      // Create a context definition object for the 'entity'. This is common
+      // to all the derivatives.
+      $entity_context_definition = ContextDefinition::create("entity:$entity_type_id")
+        ->setAssignmentRestriction(ContextDefinition::ASSIGNMENT_RESTRICTION_SELECTOR)
+        ->setRequired(TRUE);
+
+      $t_args = [
+        '@entity_type_label' => $entity_type->getLabel(),
+        '@entity_type_singular' => $entity_type->getSingularLabel(),
+      ];
+      // Define the action label, context label and description, depending on
+      // which derivative we are building.
+      switch ($base_plugin_id) {
+        case 'scheduler_publishing_is_enabled':
+          $label = $this->t('@entity_type_label type is enabled for scheduled publishing', $t_args);
+          $entity_context_definition
+            ->setLabel($this->t('@entity_type_label', $t_args))
+            ->setDescription($this->t('The @entity_type_singular to check for the type being enabled for scheduled publishing.', $t_args));
+          break;
+
+        case 'scheduler_unpublishing_is_enabled':
+          $label = $this->t('@entity_type_label type is enabled for scheduled unpublishing', $t_args);
+          $entity_context_definition
+            ->setLabel($this->t('@entity_type_label', $t_args))
+            ->setDescription($this->t('The @entity_type_singular to check for the type being enabled for scheduled unpublishing.', $t_args));
+          break;
+
+        case 'scheduler_entity_is_scheduled_for_publishing':
+          $label = $this->t('@entity_type_label is scheduled for publishing', $t_args);
+          $entity_context_definition
+            ->setLabel($this->t('@entity_type_label', $t_args))
+            ->setDescription($this->t('The @entity_type_singular to check for having a scheduled publishing date.', $t_args));
+          break;
+
+        case 'scheduler_entity_is_scheduled_for_unpublishing':
+          $label = $this->t('@entity_type_label is scheduled for unpublishing', $t_args);
+          $entity_context_definition
+            ->setLabel($this->t('@entity_type_label', $t_args))
+            ->setDescription($this->t('The @entity_type_singular to check for having a scheduled unpublishing date.', $t_args));
+          break;
+
+        default:
+          $label = 'NOT SET for ' . $base_plugin_id;
+          $entity_context_definition->setLabel($label);
+          break;
+      }
+
+      // Build the basic condition definition with the entity context.
+      $condition_definition = [
+        'label' => $label,
+        'entity_type_id' => $entity_type_id,
+        'category' => $entity_type->getLabel() . ' (' . $this->t('Scheduler') . ')',
+        // The context parameter names have to be consistent across all entity
+        // types (we cannot use $entity_type_id). This avoids PHP8 failing with
+        // 'unknown named parameter' in call_user_func_array()
+        // @see https://www.drupal.org/project/scheduler/issues/3276637
+        'context_definitions' => ['entity' => $entity_context_definition],
+      ];
+
+      // Add the full definition to the derivatives array.
+      $this->derivatives[$entity_type_id] = $condition_definition + $base_plugin_definition;
+    }
+
+    return $this->derivatives;
+  }
+
+}
diff --git a/web/modules/scheduler/scheduler_rules_integration/src/Plugin/Condition/Legacy/LegacyPublishingIsEnabled.php b/web/modules/scheduler/scheduler_rules_integration/src/Plugin/Condition/Legacy/LegacyPublishingIsEnabled.php
new file mode 100644
index 0000000000000000000000000000000000000000..7d77d9b6fa17d09e858349e73834941471587c35
--- /dev/null
+++ b/web/modules/scheduler/scheduler_rules_integration/src/Plugin/Condition/Legacy/LegacyPublishingIsEnabled.php
@@ -0,0 +1,23 @@
+<?php
+
+namespace Drupal\scheduler_rules_integration\Plugin\Condition\Legacy;
+
+use Drupal\scheduler_rules_integration\Plugin\Condition\PublishingIsEnabled;
+
+/**
+ * Provides a 'Publishing is enabled' condition for nodes only.
+ *
+ * @Condition(
+ *   id = "scheduler_condition_publishing_is_enabled",
+ *   label = @Translation("Node type is enabled for scheduled publishing"),
+ *   category = @Translation("Content (Scheduler)"),
+ *   context_definitions = {
+ *     "entity" = @ContextDefinition("entity:node",
+ *       label = @Translation("Node"),
+ *       description = @Translation("The node to check for the type being enabled for scheduled publishing."),
+ *       assignment_restriction = "selector",
+ *     )
+ *   }
+ * )
+ */
+class LegacyPublishingIsEnabled extends PublishingIsEnabled {}
diff --git a/web/modules/scheduler/scheduler_rules_integration/src/Plugin/Condition/Legacy/LegacyScheduledForPublishing.php b/web/modules/scheduler/scheduler_rules_integration/src/Plugin/Condition/Legacy/LegacyScheduledForPublishing.php
new file mode 100644
index 0000000000000000000000000000000000000000..d59dd9a96e7601681a4c82af3439956fdbc7b65c
--- /dev/null
+++ b/web/modules/scheduler/scheduler_rules_integration/src/Plugin/Condition/Legacy/LegacyScheduledForPublishing.php
@@ -0,0 +1,23 @@
+<?php
+
+namespace Drupal\scheduler_rules_integration\Plugin\Condition\Legacy;
+
+use Drupal\scheduler_rules_integration\Plugin\Condition\ScheduledForPublishing;
+
+/**
+ * Provides 'Node is scheduled for publishing' condition.
+ *
+ * @Condition(
+ *   id = "scheduler_condition_node_scheduled_for_publishing",
+ *   label = @Translation("Node is scheduled for publishing"),
+ *   category = @Translation("Content (Scheduler)"),
+ *   context_definitions = {
+ *     "entity" = @ContextDefinition("entity:node",
+ *       label = @Translation("Node"),
+ *       description = @Translation("The node to check for having a scheduled publishing date."),
+ *       assignment_restriction = "selector",
+ *     )
+ *   }
+ * )
+ */
+class LegacyScheduledForPublishing extends ScheduledForPublishing {}
diff --git a/web/modules/scheduler/scheduler_rules_integration/src/Plugin/Condition/Legacy/LegacyScheduledForUnpublishing.php b/web/modules/scheduler/scheduler_rules_integration/src/Plugin/Condition/Legacy/LegacyScheduledForUnpublishing.php
new file mode 100644
index 0000000000000000000000000000000000000000..7cac63db5930835636f5c46750bd27e52ad78077
--- /dev/null
+++ b/web/modules/scheduler/scheduler_rules_integration/src/Plugin/Condition/Legacy/LegacyScheduledForUnpublishing.php
@@ -0,0 +1,23 @@
+<?php
+
+namespace Drupal\scheduler_rules_integration\Plugin\Condition\Legacy;
+
+use Drupal\scheduler_rules_integration\Plugin\Condition\ScheduledForUnpublishing;
+
+/**
+ * Provides 'Node is scheduled for unpublishing' condition.
+ *
+ * @Condition(
+ *   id = "scheduler_condition_node_scheduled_for_unpublishing",
+ *   label = @Translation("Node is scheduled for unpublishing"),
+ *   category = @Translation("Content (Scheduler)"),
+ *   context_definitions = {
+ *     "entity" = @ContextDefinition("entity:node",
+ *       label = @Translation("Node"),
+ *       description = @Translation("The node to check for having a scheduled unpublishing date."),
+ *       assignment_restriction = "selector",
+ *     )
+ *   }
+ * )
+ */
+class LegacyScheduledForUnpublishing extends ScheduledForUnpublishing {}
diff --git a/web/modules/scheduler/scheduler_rules_integration/src/Plugin/Condition/Legacy/LegacyUnpublishingIsEnabled.php b/web/modules/scheduler/scheduler_rules_integration/src/Plugin/Condition/Legacy/LegacyUnpublishingIsEnabled.php
new file mode 100644
index 0000000000000000000000000000000000000000..afb5daab3f7eb33feedf68f1fd994e8cb7b1b546
--- /dev/null
+++ b/web/modules/scheduler/scheduler_rules_integration/src/Plugin/Condition/Legacy/LegacyUnpublishingIsEnabled.php
@@ -0,0 +1,23 @@
+<?php
+
+namespace Drupal\scheduler_rules_integration\Plugin\Condition\Legacy;
+
+use Drupal\scheduler_rules_integration\Plugin\Condition\UnpublishingIsEnabled;
+
+/**
+ * Provides 'Unpublishing is enabled' condition for nodes only.
+ *
+ * @Condition(
+ *   id = "scheduler_condition_unpublishing_is_enabled",
+ *   label = @Translation("Node type is enabled for scheduled unpublishing"),
+ *   category = @Translation("Content (Scheduler)"),
+ *   context_definitions = {
+ *     "entity" = @ContextDefinition("entity:node",
+ *       label = @Translation("Node"),
+ *       description = @Translation("The node to check for the type being enabled for scheduled unpublishing."),
+ *       assignment_restriction = "selector",
+ *     )
+ *   }
+ * )
+ */
+class LegacyUnpublishingIsEnabled extends UnpublishingIsEnabled {}
diff --git a/web/modules/scheduler/scheduler_rules_integration/src/Plugin/Condition/NodeIsScheduledForPublishing.php b/web/modules/scheduler/scheduler_rules_integration/src/Plugin/Condition/NodeIsScheduledForPublishing.php
deleted file mode 100644
index 7ceae962534f8f762d103e9043e587aef7512402..0000000000000000000000000000000000000000
--- a/web/modules/scheduler/scheduler_rules_integration/src/Plugin/Condition/NodeIsScheduledForPublishing.php
+++ /dev/null
@@ -1,35 +0,0 @@
-<?php
-
-namespace Drupal\scheduler_rules_integration\Plugin\Condition;
-
-use Drupal\rules\Core\RulesConditionBase;
-
-/**
- * Provides 'Node is scheduled for publishing' condition.
- *
- * @Condition(
- *   id = "scheduler_condition_node_scheduled_for_publishing",
- *   label = @Translation("Node is scheduled for publishing"),
- *   category = @Translation("Scheduler"),
- *   context = {
- *     "node" = @ContextDefinition("entity:node",
- *       label = @Translation("Scheduled Node"),
- *       description = @Translation("The node to test for having a scheduled publishing date. Enter 'node' or use data selection.")
- *     )
- *   }
- * )
- */
-class NodeIsScheduledForPublishing extends RulesConditionBase {
-
-  /**
-   * Determines whether a node is scheduled for publishing.
-   *
-   * @return bool
-   *   TRUE if the node is scheduled for publishing, FALSE if not.
-   */
-  protected function doEvaluate() {
-    $node = $this->getContextValue('node');
-    return !empty($node->publish_on->value);
-  }
-
-}
diff --git a/web/modules/scheduler/scheduler_rules_integration/src/Plugin/Condition/NodeIsScheduledForUnpublishing.php b/web/modules/scheduler/scheduler_rules_integration/src/Plugin/Condition/NodeIsScheduledForUnpublishing.php
deleted file mode 100644
index 5d125d7866e8f9f850526ebd689a62ddd720e3d1..0000000000000000000000000000000000000000
--- a/web/modules/scheduler/scheduler_rules_integration/src/Plugin/Condition/NodeIsScheduledForUnpublishing.php
+++ /dev/null
@@ -1,35 +0,0 @@
-<?php
-
-namespace Drupal\scheduler_rules_integration\Plugin\Condition;
-
-use Drupal\rules\Core\RulesConditionBase;
-
-/**
- * Provides 'Node is scheduled for unpublishing' condition.
- *
- * @Condition(
- *   id = "scheduler_condition_node_scheduled_for_unpublishing",
- *   label = @Translation("Node is scheduled for unpublishing"),
- *   category = @Translation("Scheduler"),
- *   context = {
- *     "node" = @ContextDefinition("entity:node",
- *       label = @Translation("Scheduled Node"),
- *       description = @Translation("The node to test for having a scheduled unpublishing date. Enter 'node' or use data selection.")
- *     )
- *   }
- * )
- */
-class NodeIsScheduledForUnpublishing extends RulesConditionBase {
-
-  /**
-   * Determines whether a node is scheduled for unpublishing.
-   *
-   * @return bool
-   *   TRUE if the node is scheduled for unpublishing, FALSE if not.
-   */
-  protected function doEvaluate() {
-    $node = $this->getContextValue('node');
-    return !empty($node->unpublish_on->value);
-  }
-
-}
diff --git a/web/modules/scheduler/scheduler_rules_integration/src/Plugin/Condition/PublishingIsEnabled.php b/web/modules/scheduler/scheduler_rules_integration/src/Plugin/Condition/PublishingIsEnabled.php
index 309444a508457db8776ea3903d39640a284b241f..8f8a9de79d2681db7bedb81f564678f16d8071d3 100644
--- a/web/modules/scheduler/scheduler_rules_integration/src/Plugin/Condition/PublishingIsEnabled.php
+++ b/web/modules/scheduler/scheduler_rules_integration/src/Plugin/Condition/PublishingIsEnabled.php
@@ -2,36 +2,33 @@
 
 namespace Drupal\scheduler_rules_integration\Plugin\Condition;
 
+use Drupal\Core\Entity\EntityInterface;
 use Drupal\rules\Core\RulesConditionBase;
 
 /**
- * Provides a 'Publishing is enabled' condition.
+ * Provides 'Publishing is enabled for the type of this entity' condition.
  *
  * @Condition(
- *   id = "scheduler_condition_publishing_is_enabled",
- *   label = @Translation("Node type is enabled for scheduled publishing"),
- *   category = @Translation("Scheduler"),
- *   context = {
- *     "node" = @ContextDefinition("entity:node",
- *       label = @Translation("Scheduled Node"),
- *       description = @Translation("The node to check for scheduled publishing enabled. Enter 'node' or use data selection.")
- *     )
- *   }
+ *   id = "scheduler_publishing_is_enabled",
+ *   deriver = "Drupal\scheduler_rules_integration\Plugin\Condition\ConditionDeriver"
  * )
  */
 class PublishingIsEnabled extends RulesConditionBase {
 
   /**
-   * Determines whether scheduled publishing is enabled for this node type.
+   * Determines whether scheduled publishing is enabled for this entity type.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity to be checked.
    *
    * @return bool
-   *   TRUE if scheduled publishing is enabled for the content type of this
-   *   node.
+   *   TRUE if scheduled publishing is enabled for the bundle of this entity
+   *   type.
    */
-  public function evaluate() {
-    $node = $this->getContextValue('node');
+  public function doEvaluate(EntityInterface $entity) {
     $config = \Drupal::config('scheduler.settings');
-    return ($node->type->entity->getThirdPartySetting('scheduler', 'publish_enable', $config->get('default_publish_enable')));
+    $bundle_field = $entity->getEntityType()->get('entity_keys')['bundle'];
+    return ($entity->$bundle_field->entity->getThirdPartySetting('scheduler', 'publish_enable', $config->get('default_publish_enable')));
   }
 
 }
diff --git a/web/modules/scheduler/scheduler_rules_integration/src/Plugin/Condition/ScheduledForPublishing.php b/web/modules/scheduler/scheduler_rules_integration/src/Plugin/Condition/ScheduledForPublishing.php
new file mode 100644
index 0000000000000000000000000000000000000000..5ae791c8c151dd76a7f61866b93504c4266fc8b1
--- /dev/null
+++ b/web/modules/scheduler/scheduler_rules_integration/src/Plugin/Condition/ScheduledForPublishing.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace Drupal\scheduler_rules_integration\Plugin\Condition;
+
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\rules\Core\RulesConditionBase;
+
+/**
+ * Provides 'Entity is scheduled for publishing' condition.
+ *
+ * @Condition(
+ *   id = "scheduler_entity_is_scheduled_for_publishing",
+ *   deriver = "Drupal\scheduler_rules_integration\Plugin\Condition\ConditionDeriver"
+ * )
+ */
+class ScheduledForPublishing extends RulesConditionBase {
+
+  /**
+   * Determines whether an entity is scheduled for publishing.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity to be checked.
+   *
+   * @return bool
+   *   TRUE if the entity is scheduled for publishing, FALSE if not.
+   */
+  public function doEvaluate(EntityInterface $entity) {
+    return isset($entity->publish_on->value);
+  }
+
+}
diff --git a/web/modules/scheduler/scheduler_rules_integration/src/Plugin/Condition/ScheduledForUnpublishing.php b/web/modules/scheduler/scheduler_rules_integration/src/Plugin/Condition/ScheduledForUnpublishing.php
new file mode 100644
index 0000000000000000000000000000000000000000..e047440ca16d6fed10b774f23f4fcfa8b278a70b
--- /dev/null
+++ b/web/modules/scheduler/scheduler_rules_integration/src/Plugin/Condition/ScheduledForUnpublishing.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace Drupal\scheduler_rules_integration\Plugin\Condition;
+
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\rules\Core\RulesConditionBase;
+
+/**
+ * Provides 'Entity is scheduled for publishing' condition.
+ *
+ * @Condition(
+ *   id = "scheduler_entity_is_scheduled_for_unpublishing",
+ *   deriver = "Drupal\scheduler_rules_integration\Plugin\Condition\ConditionDeriver"
+ * )
+ */
+class ScheduledForUnpublishing extends RulesConditionBase {
+
+  /**
+   * Determines whether an entity is scheduled for unpublishing.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity to be checked.
+   *
+   * @return bool
+   *   TRUE if the entity is scheduled for unpublishing, FALSE if not.
+   */
+  public function doEvaluate(EntityInterface $entity) {
+    return isset($entity->unpublish_on->value);
+  }
+
+}
diff --git a/web/modules/scheduler/scheduler_rules_integration/src/Plugin/Condition/UnpublishingIsEnabled.php b/web/modules/scheduler/scheduler_rules_integration/src/Plugin/Condition/UnpublishingIsEnabled.php
index b5b218bc0cfaf0c017bcc73c9d9861885d5a087c..01f62ffa48abf590f1618b8ed62e75756d1de224 100644
--- a/web/modules/scheduler/scheduler_rules_integration/src/Plugin/Condition/UnpublishingIsEnabled.php
+++ b/web/modules/scheduler/scheduler_rules_integration/src/Plugin/Condition/UnpublishingIsEnabled.php
@@ -2,36 +2,33 @@
 
 namespace Drupal\scheduler_rules_integration\Plugin\Condition;
 
+use Drupal\Core\Entity\EntityInterface;
 use Drupal\rules\Core\RulesConditionBase;
 
 /**
- * Provides 'Unpublishing is enabled' condition.
+ * Provides 'Unpublishing is enabled for the type of this entity' condition.
  *
  * @Condition(
- *   id = "scheduler_condition_unpublishing_is_enabled",
- *   label = @Translation("Node type is enabled for scheduled unpublishing"),
- *   category = @Translation("Scheduler"),
- *   context = {
- *     "node" = @ContextDefinition("entity:node",
- *       label = @Translation("Scheduled Node"),
- *       description = @Translation("The node to check for scheduled unpublishing enabled. Enter 'node' or use data selection.")
- *     )
- *   }
+ *   id = "scheduler_unpublishing_is_enabled",
+ *   deriver = "Drupal\scheduler_rules_integration\Plugin\Condition\ConditionDeriver"
  * )
  */
 class UnpublishingIsEnabled extends RulesConditionBase {
 
   /**
-   * Determines whether scheduled unpublishing is enabled for this node type.
+   * Determines whether scheduled unpublishing is enabled for this entity type.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity to be checked.
    *
    * @return bool
-   *   TRUE if scheduled unpublishing is enabled for the content type of this
-   *   node.
+   *   TRUE if scheduled unpublishing is enabled for the bundle of this entity
+   *   type.
    */
-  public function evaluate() {
-    $node = $this->getContextValue('node');
+  public function doEvaluate(EntityInterface $entity) {
     $config = \Drupal::config('scheduler.settings');
-    return ($node->type->entity->getThirdPartySetting('scheduler', 'unpublish_enable', $config->get('default_unpublish_enable')));
+    $bundle_field = $entity->getEntityType()->get('entity_keys')['bundle'];
+    return ($entity->$bundle_field->entity->getThirdPartySetting('scheduler', 'unpublish_enable', $config->get('default_unpublish_enable')));
   }
 
 }
diff --git a/web/modules/scheduler/scheduler_rules_integration/src/Plugin/RulesAction/Legacy/LegacyPublishNow.php b/web/modules/scheduler/scheduler_rules_integration/src/Plugin/RulesAction/Legacy/LegacyPublishNow.php
new file mode 100644
index 0000000000000000000000000000000000000000..12314d6e05ef6a810e3dc7dc94756e08ebb7c6d2
--- /dev/null
+++ b/web/modules/scheduler/scheduler_rules_integration/src/Plugin/RulesAction/Legacy/LegacyPublishNow.php
@@ -0,0 +1,24 @@
+<?php
+
+namespace Drupal\scheduler_rules_integration\Plugin\RulesAction\Legacy;
+
+use Drupal\scheduler_rules_integration\Plugin\RulesAction\PublishNow;
+
+/**
+ * Provides a 'Publish the node immediately' action.
+ *
+ * @RulesAction(
+ *   id = "scheduler_publish_now_action",
+ *   entity_type_id = "node",
+ *   label = @Translation("Publish a content item immediately"),
+ *   category = @Translation("Content (Scheduler)"),
+ *   context_definitions = {
+ *     "entity" = @ContextDefinition("entity:node",
+ *       label = @Translation("Node"),
+ *       description = @Translation("The node to be published now"),
+ *       assignment_restriction = "selector",
+ *     ),
+ *   }
+ * )
+ */
+class LegacyPublishNow extends PublishNow {}
diff --git a/web/modules/scheduler/scheduler_rules_integration/src/Plugin/RulesAction/Legacy/LegacyRemovePublishingDate.php b/web/modules/scheduler/scheduler_rules_integration/src/Plugin/RulesAction/Legacy/LegacyRemovePublishingDate.php
new file mode 100644
index 0000000000000000000000000000000000000000..63d614b40d55452536b5097fc31e2791981a0cb4
--- /dev/null
+++ b/web/modules/scheduler/scheduler_rules_integration/src/Plugin/RulesAction/Legacy/LegacyRemovePublishingDate.php
@@ -0,0 +1,24 @@
+<?php
+
+namespace Drupal\scheduler_rules_integration\Plugin\RulesAction\Legacy;
+
+use Drupal\scheduler_rules_integration\Plugin\RulesAction\RemovePublishingDate;
+
+/**
+ * Provides a 'Remove date for scheduled publishing' action, for nodes only.
+ *
+ * @RulesAction(
+ *   id = "scheduler_remove_publishing_date_action",
+ *   entity_type_id = "node",
+ *   label = @Translation("Remove date for publishing a content item"),
+ *   category = @Translation("Content (Scheduler)"),
+ *   context_definitions = {
+ *     "entity" = @ContextDefinition("entity:node",
+ *       label = @Translation("Node"),
+ *       description = @Translation("The node from which to remove the scheduled publishing date"),
+ *       assignment_restriction = "selector",
+ *     ),
+ *   }
+ * )
+ */
+class LegacyRemovePublishingDate extends RemovePublishingDate {}
diff --git a/web/modules/scheduler/scheduler_rules_integration/src/Plugin/RulesAction/Legacy/LegacyRemoveUnpublishingDate.php b/web/modules/scheduler/scheduler_rules_integration/src/Plugin/RulesAction/Legacy/LegacyRemoveUnpublishingDate.php
new file mode 100644
index 0000000000000000000000000000000000000000..fb417dac17b086930f3a355c1929907fd9b5b5dd
--- /dev/null
+++ b/web/modules/scheduler/scheduler_rules_integration/src/Plugin/RulesAction/Legacy/LegacyRemoveUnpublishingDate.php
@@ -0,0 +1,24 @@
+<?php
+
+namespace Drupal\scheduler_rules_integration\Plugin\RulesAction\Legacy;
+
+use Drupal\scheduler_rules_integration\Plugin\RulesAction\RemoveUnpublishingDate;
+
+/**
+ * Provides a 'Remove date for scheduled unpublishing' action for nodes only.
+ *
+ * @RulesAction(
+ *   id = "scheduler_remove_unpublishing_date_action",
+ *   entity_type_id = "node",
+ *   label = @Translation("Remove date for unpublishing a content item"),
+ *   category = @Translation("Content (Scheduler)"),
+ *   context_definitions = {
+ *     "entity" = @ContextDefinition("entity:node",
+ *       label = @Translation("Node"),
+ *       description = @Translation("The node from which to remove the scheduled unpublishing date"),
+ *       assignment_restriction = "selector",
+ *     ),
+ *   }
+ * )
+ */
+class LegacyRemoveUnpublishingDate extends RemoveUnpublishingDate {}
diff --git a/web/modules/scheduler/scheduler_rules_integration/src/Plugin/RulesAction/Legacy/LegacySetPublishingDate.php b/web/modules/scheduler/scheduler_rules_integration/src/Plugin/RulesAction/Legacy/LegacySetPublishingDate.php
new file mode 100644
index 0000000000000000000000000000000000000000..707041c194a0f915cbb62a07d60601f4137c3cc2
--- /dev/null
+++ b/web/modules/scheduler/scheduler_rules_integration/src/Plugin/RulesAction/Legacy/LegacySetPublishingDate.php
@@ -0,0 +1,28 @@
+<?php
+
+namespace Drupal\scheduler_rules_integration\Plugin\RulesAction\Legacy;
+
+use Drupal\scheduler_rules_integration\Plugin\RulesAction\SetPublishingDate;
+
+/**
+ * Provides the 'Set date for scheduled unpublishing' action just for nodes.
+ *
+ * @RulesAction(
+ *   id = "scheduler_set_publishing_date_action",
+ *   entity_type_id = "node",
+ *   label = @Translation("Set date for publishing a content item"),
+ *   category = @Translation("Content (Scheduler)"),
+ *   context_definitions = {
+ *     "entity" = @ContextDefinition("entity:node",
+ *       label = @Translation("Node for scheduling"),
+ *       description = @Translation("The node which is to have a scheduled publishing date set"),
+ *       assignment_restriction = "selector",
+ *     ),
+ *     "date" = @ContextDefinition("timestamp",
+ *       label = @Translation("The date for publishing"),
+ *       description = @Translation("The date when Scheduler will publish the node"),
+ *     )
+ *   }
+ * )
+ */
+class LegacySetPublishingDate extends SetPublishingDate {}
diff --git a/web/modules/scheduler/scheduler_rules_integration/src/Plugin/RulesAction/Legacy/LegacySetUnpublishingDate.php b/web/modules/scheduler/scheduler_rules_integration/src/Plugin/RulesAction/Legacy/LegacySetUnpublishingDate.php
new file mode 100644
index 0000000000000000000000000000000000000000..9878e1a8402a2a967f813ac0ce1656888458a6e5
--- /dev/null
+++ b/web/modules/scheduler/scheduler_rules_integration/src/Plugin/RulesAction/Legacy/LegacySetUnpublishingDate.php
@@ -0,0 +1,28 @@
+<?php
+
+namespace Drupal\scheduler_rules_integration\Plugin\RulesAction\Legacy;
+
+use Drupal\scheduler_rules_integration\Plugin\RulesAction\SetUnpublishingDate;
+
+/**
+ * Provides a 'Set date for scheduled unpublishing' action just for nodes.
+ *
+ * @RulesAction(
+ *   id = "scheduler_set_unpublishing_date_action",
+ *   entity_type_id = "node",
+ *   label = @Translation("Set date for unpublishing a content item"),
+ *   category = @Translation("Content (Scheduler)"),
+ *   context_definitions = {
+ *     "entity" = @ContextDefinition("entity:node",
+ *       label = @Translation("Node for scheduling"),
+ *       description = @Translation("The node which is to have a scheduled unpublishing date set"),
+ *       assignment_restriction = "selector",
+ *     ),
+ *     "date" = @ContextDefinition("timestamp",
+ *       label = @Translation("The date for unpublishing"),
+ *       description = @Translation("The date when Scheduler will unpublish the node"),
+ *     )
+ *   }
+ * )
+ */
+class LegacySetUnpublishingDate extends SetUnpublishingDate {}
diff --git a/web/modules/scheduler/scheduler_rules_integration/src/Plugin/RulesAction/Legacy/LegacyUnpublishNow.php b/web/modules/scheduler/scheduler_rules_integration/src/Plugin/RulesAction/Legacy/LegacyUnpublishNow.php
new file mode 100644
index 0000000000000000000000000000000000000000..dfd4e6486410f8f6a25365905f204edbd68d8732
--- /dev/null
+++ b/web/modules/scheduler/scheduler_rules_integration/src/Plugin/RulesAction/Legacy/LegacyUnpublishNow.php
@@ -0,0 +1,24 @@
+<?php
+
+namespace Drupal\scheduler_rules_integration\Plugin\RulesAction\Legacy;
+
+use Drupal\scheduler_rules_integration\Plugin\RulesAction\UnpublishNow;
+
+/**
+ * Provides an 'Unpublish the node immediately' action.
+ *
+ * @RulesAction(
+ *   id = "scheduler_unpublish_now_action",
+ *   entity_type_id = "node",
+ *   label = @Translation("Unpublish a content item immediately"),
+ *   category = @Translation("Content (Scheduler)"),
+ *   context_definitions = {
+ *     "entity" = @ContextDefinition("entity:node",
+ *       label = @Translation("Node"),
+ *       description = @Translation("The node to be unpublished now"),
+ *       assignment_restriction = "selector",
+ *     ),
+ *   }
+ * )
+ */
+class LegacyUnpublishNow extends UnpublishNow {}
diff --git a/web/modules/scheduler/scheduler_rules_integration/src/Plugin/RulesAction/PublishNow.php b/web/modules/scheduler/scheduler_rules_integration/src/Plugin/RulesAction/PublishNow.php
index b15e96237144d0dee32a35207b0f5caaec6fe3b2..a929988913a83391bb83ddf5cb16561a3653b550 100644
--- a/web/modules/scheduler/scheduler_rules_integration/src/Plugin/RulesAction/PublishNow.php
+++ b/web/modules/scheduler/scheduler_rules_integration/src/Plugin/RulesAction/PublishNow.php
@@ -2,35 +2,30 @@
 
 namespace Drupal\scheduler_rules_integration\Plugin\RulesAction;
 
-use Drupal\rules\Core\RulesActionBase;
+use Drupal\Core\Entity\EntityInterface;
 
 /**
- * Provides a 'Publish the node immediately' action.
+ * Provides a 'Publish immediately' action.
  *
  * @RulesAction(
- *   id = "scheduler_publish_now_action",
- *   label = @Translation("Publish the content immediately"),
- *   category = @Translation("Scheduler"),
- *   context = {
- *     "node" = @ContextDefinition("entity:node",
- *       label = @Translation("Node"),
- *       description = @Translation("The node to be published now"),
- *     ),
- *   }
+ *   id = "scheduler_publish_now",
+ *   deriver = "Drupal\scheduler_rules_integration\Plugin\RulesAction\SchedulerRulesActionDeriver"
  * )
  */
-class PublishNow extends RulesActionBase {
+class PublishNow extends SchedulerRulesActionBase {
 
   /**
-   * Set the node status to Published.
+   * Set the entity status to Published.
    *
-   * This action should really be provided by Rules or by Core, but it is not
-   * yet done (as of Aug 2016). Scheduler users need this action so we provide
-   * it here. It could be removed later when Rules or Core includes it.
+   * This action is provided by the Rules Module but only for node content, not
+   * Media. There is also a problem with recursion in the Rules action due to
+   * autoSaveContext(). Hence better for Scheduler to provide this action.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity to be published.
    */
-  public function doExecute() {
-    $node = $this->getContextValue('node');
-    $node->setPublished();
+  public function doExecute(EntityInterface $entity) {
+    $entity->setPublished();
   }
 
 }
diff --git a/web/modules/scheduler/scheduler_rules_integration/src/Plugin/RulesAction/RemovePublishingDate.php b/web/modules/scheduler/scheduler_rules_integration/src/Plugin/RulesAction/RemovePublishingDate.php
index 2204fdbdbf55d702405a3578dcbeff32014dc09c..f6c7fed0d3979c1dbe1da341c538fee5ce05086e 100644
--- a/web/modules/scheduler/scheduler_rules_integration/src/Plugin/RulesAction/RemovePublishingDate.php
+++ b/web/modules/scheduler/scheduler_rules_integration/src/Plugin/RulesAction/RemovePublishingDate.php
@@ -2,60 +2,35 @@
 
 namespace Drupal\scheduler_rules_integration\Plugin\RulesAction;
 
-use Drupal\Core\Link;
-use Drupal\Core\Url;
-use Drupal\rules\Core\RulesActionBase;
+use Drupal\Core\Entity\EntityInterface;
 
 /**
  * Provides a 'Remove date for scheduled publishing' action.
  *
  * @RulesAction(
- *   id = "scheduler_remove_publishing_date_action",
- *   label = @Translation("Remove date for scheduled publishing"),
- *   category = @Translation("Scheduler"),
- *   context = {
- *     "node" = @ContextDefinition("entity:node",
- *       label = @Translation("Node"),
- *       description = @Translation("The node from which to remove the scheduled publishing date"),
- *     ),
- *   }
+ *   id = "scheduler_remove_publishing_date",
+ *   deriver = "Drupal\scheduler_rules_integration\Plugin\RulesAction\SchedulerRulesActionDeriver"
  * )
  */
-class RemovePublishingDate extends RulesActionBase {
+class RemovePublishingDate extends SchedulerRulesActionBase {
 
   /**
-   * Remove the publish_on date from the node.
+   * Remove the publish_on date from the entity.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity from which to remove the scheduled date.
    */
-  public function doExecute() {
-    $node = $this->getContextValue('node');
+  public function doExecute(EntityInterface $entity) {
     $config = \Drupal::config('scheduler.settings');
-    if ($node->type->entity->getThirdPartySetting('scheduler', 'publish_enable', $config->get('default_publish_enable'))) {
-      $node->set('publish_on', NULL);
-      scheduler_node_presave($node);
-      scheduler_node_update($node);
+    $bundle_field = $entity->getEntityType()->get('entity_keys')['bundle'];
+    if ($entity->$bundle_field->entity->getThirdPartySetting('scheduler', 'publish_enable', $config->get('default_publish_enable'))) {
+      $entity->set('publish_on', NULL);
+      scheduler_entity_presave($entity);
     }
     else {
       // The action cannot be executed because the content type is not enabled
       // for scheduled publishing.
-      $action_label = $this->summary();
-      // @todo Can we get the condition description from the actual condition
-      // object instead of hard-coding it here?
-      $condition = $this->t('Node type is enabled for scheduled publishing');
-      $type_name = node_get_type_label($node);
-      $url = new Url('entity.node_type.edit_form', ['node_type' => $node->getType()]);
-      $arguments = [
-        '%type' => $type_name,
-        '%action_label' => $action_label,
-        '%condition' => $condition,
-        '@url' => $url->toString(),
-        'link' => Link::fromTextAndUrl($this->t('@type settings', ['@type' => $type_name]), $url)->toString(),
-      ];
-
-      \Drupal::logger('scheduler')->warning('Action "%action_label" is not valid because scheduled publishing is not enabled for %type content. Add the condition "%condition" to your Reaction Rule, or enable scheduled publishing via the %type settings.',
-        $arguments);
-
-      \Drupal::messenger()->addMessage($this->t('Action "%action_label" is not valid because scheduled publishing is not enabled for %type content. Add the condition "%condition" to your Reaction Rule, or enable scheduled publishing via the <a href="@url">%type</a> settings.',
-        $arguments), 'warning', FALSE);
+      $this->notEnabledWarning($entity, 'publish');
     }
   }
 
diff --git a/web/modules/scheduler/scheduler_rules_integration/src/Plugin/RulesAction/RemoveUnpublishingDate.php b/web/modules/scheduler/scheduler_rules_integration/src/Plugin/RulesAction/RemoveUnpublishingDate.php
index d76aec275703e4b0906ebc903a3714f7e54caa9c..dce7fad5ca896e9472dcc7c5b58a3810d0fe1a30 100644
--- a/web/modules/scheduler/scheduler_rules_integration/src/Plugin/RulesAction/RemoveUnpublishingDate.php
+++ b/web/modules/scheduler/scheduler_rules_integration/src/Plugin/RulesAction/RemoveUnpublishingDate.php
@@ -2,60 +2,35 @@
 
 namespace Drupal\scheduler_rules_integration\Plugin\RulesAction;
 
-use Drupal\Core\Link;
-use Drupal\Core\Url;
-use Drupal\rules\Core\RulesActionBase;
+use Drupal\Core\Entity\EntityInterface;
 
 /**
  * Provides a 'Remove date for scheduled unpublishing' action.
  *
  * @RulesAction(
- *   id = "scheduler_remove_unpublishing_date_action",
- *   label = @Translation("Remove date for scheduled unpublishing"),
- *   category = @Translation("Scheduler"),
- *   context = {
- *     "node" = @ContextDefinition("entity:node",
- *       label = @Translation("Node"),
- *       description = @Translation("The node from which to remove the scheduled unpublishing date"),
- *     ),
- *   }
+ *   id = "scheduler_remove_unpublishing_date",
+ *   deriver = "Drupal\scheduler_rules_integration\Plugin\RulesAction\SchedulerRulesActionDeriver"
  * )
  */
-class RemoveUnpublishingDate extends RulesActionBase {
+class RemoveUnpublishingDate extends SchedulerRulesActionBase {
 
   /**
-   * Remove the unpublish_on date from the node.
+   * Remove the unpublish_on date from the entity.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity from which to remove the scheduled date.
    */
-  public function doExecute() {
-    $node = $this->getContextValue('node');
+  public function doExecute(EntityInterface $entity) {
     $config = \Drupal::config('scheduler.settings');
-    if ($node->type->entity->getThirdPartySetting('scheduler', 'unpublish_enable', $config->get('default_unpublish_enable'))) {
-      $node->set('unpublish_on', NULL);
-      scheduler_node_presave($node);
-      scheduler_node_update($node);
+    $bundle_field = $entity->getEntityType()->get('entity_keys')['bundle'];
+    if ($entity->$bundle_field->entity->getThirdPartySetting('scheduler', 'unpublish_enable', $config->get('default_unpublish_enable'))) {
+      $entity->set('unpublish_on', NULL);
+      scheduler_entity_presave($entity);
     }
     else {
       // The action cannot be executed because the content type is not enabled
       // for scheduled unpublishing.
-      $action_label = $this->summary();
-      // @todo Can we get the condition description from the actual condition
-      // object instead of hard-coding it here?
-      $condition = $this->t('Node type is enabled for scheduled unpublishing');
-      $type_name = node_get_type_label($node);
-      $url = new Url('entity.node_type.edit_form', ['node_type' => $node->getType()]);
-      $arguments = [
-        '%type' => $type_name,
-        '%action_label' => $action_label,
-        '%condition' => $condition,
-        '@url' => $url->toString(),
-        'link' => Link::fromTextAndUrl($this->t('@type settings', ['@type' => $type_name]), $url)->toString(),
-      ];
-
-      \Drupal::logger('scheduler')->warning('Action "%action_label" is not valid because scheduled unpublishing is not enabled for %type content. Add the condition "%condition" to your Reaction Rule, or enable scheduled unpublishing via the %type settings.',
-        $arguments);
-
-      \Drupal::messenger()->addMessage($this->t('Action "%action_label" is not valid because scheduled unpublishing is not enabled for %type content. Add the condition "%condition" to your Reaction Rule, or enable scheduled unpublishing via the <a href="@url">%type</a> settings.',
-        $arguments), 'warning', FALSE);
+      $this->notEnabledWarning($entity, 'unpublish');
     }
   }
 
diff --git a/web/modules/scheduler/scheduler_rules_integration/src/Plugin/RulesAction/SchedulerRulesActionBase.php b/web/modules/scheduler/scheduler_rules_integration/src/Plugin/RulesAction/SchedulerRulesActionBase.php
new file mode 100644
index 0000000000000000000000000000000000000000..ef7eebcbc040c8575dc0753f57c1b617235a3bde
--- /dev/null
+++ b/web/modules/scheduler/scheduler_rules_integration/src/Plugin/RulesAction/SchedulerRulesActionBase.php
@@ -0,0 +1,87 @@
+<?php
+
+namespace Drupal\scheduler_rules_integration\Plugin\RulesAction;
+
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Link;
+use Drupal\Core\Url;
+use Drupal\rules\Core\RulesActionBase;
+
+/**
+ * Provides base class on which all Scheduler Rules actions are built.
+ */
+class SchedulerRulesActionBase extends RulesActionBase {
+
+  /**
+   * The entity type id.
+   *
+   * @var string
+   */
+  protected $entityTypeId;
+
+  /**
+   * Constructs a SchedulerRulesActionBase object.
+   *
+   * @param array $configuration
+   *   A configuration array containing information about the plugin instance.
+   * @param string $plugin_id
+   *   The plugin ID for the plugin instance.
+   * @param mixed $plugin_definition
+   *   The plugin implementation definition.
+   */
+  public function __construct(array $configuration, $plugin_id, $plugin_definition) {
+    parent::__construct($configuration, $plugin_id, $plugin_definition);
+    $this->entityTypeId = $plugin_definition['entity_type_id'];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
+    return new static(
+      $configuration,
+      $plugin_id,
+      $plugin_definition
+    );
+  }
+
+  /**
+   * Gives a warning when an entity is not enabled for Scheduler.
+   *
+   * This is called from actions that attempt to set or remove a Scheduler date
+   * value when the entity type is not enabled for that process.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity object being processed by the action.
+   * @param string $process
+   *   The process that is not enabled, either 'publish' or 'unpublish'.
+   */
+  public function notEnabledWarning(EntityInterface $entity, string $process) {
+    $action = $this->summary();
+    $activity = ($process == 'publish') ? $this->t('scheduled publishing') : $this->t('scheduled unpublishing');
+    $condition = $this->t('@bundle_label is enabled for @activity', [
+      '@bundle_label' => $entity->getEntityType()->getBundleLabel(),
+      '@activity' => $activity,
+    ]);
+
+    $bundle_field = $entity->getEntityType()->get('entity_keys')['bundle'];
+    $type_name = $entity->$bundle_field->entity->label();
+    $type_id = $entity->$bundle_field->entity->bundle();
+    $url = new Url("entity.$type_id.edit_form", [$type_id => $entity->bundle()]);
+    $arguments = [
+      '%action' => "'$action'",
+      '@activity' => $activity,
+      '%type' => $type_name,
+      '@group' => $entity->getEntityType()->getPluralLabel(),
+      '%condition' => "'$condition'",
+      '@url' => $url->toString(),
+    ];
+    $link = Link::fromTextAndUrl($this->t('@type settings', ['@type' => $type_name]), $url)->toString();
+    \Drupal::logger('scheduler')->warning('Action %action is not valid because @activity is not enabled for %type @group. Add the condition %condition to your Reaction Rule, or enable @activity via the %type settings.',
+      $arguments + ['link' => $link]);
+
+    \Drupal::messenger()->addMessage($this->t('Action %action is not valid because @activity is not enabled for %type @group. Add the condition %condition to your Reaction Rule, or enable @activity via the <a href="@url">%type</a> settings.',
+      $arguments), 'warning', FALSE);
+  }
+
+}
diff --git a/web/modules/scheduler/scheduler_rules_integration/src/Plugin/RulesAction/SchedulerRulesActionDeriver.php b/web/modules/scheduler/scheduler_rules_integration/src/Plugin/RulesAction/SchedulerRulesActionDeriver.php
new file mode 100644
index 0000000000000000000000000000000000000000..eb6f43cfbecbf6eabe3e78c0a8205ab42824caa6
--- /dev/null
+++ b/web/modules/scheduler/scheduler_rules_integration/src/Plugin/RulesAction/SchedulerRulesActionDeriver.php
@@ -0,0 +1,175 @@
+<?php
+
+namespace Drupal\scheduler_rules_integration\Plugin\RulesAction;
+
+use Drupal\Component\Plugin\Derivative\DeriverBase;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Plugin\Discovery\ContainerDeriverInterface;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\Core\StringTranslation\TranslationInterface;
+use Drupal\rules\Context\ContextDefinition;
+use Drupal\scheduler\SchedulerManager;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Derives actions for each supported entity type.
+ *
+ * Based on code from Rules module EntityCreateDeriver.
+ */
+class SchedulerRulesActionDeriver extends DeriverBase implements ContainerDeriverInterface {
+
+  use StringTranslationTrait;
+
+  /**
+   * The entity type manager.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * The scheduler manager.
+   *
+   * @var \Drupal\scheduler\SchedulerManager
+   */
+  protected $schedulerManager;
+
+  /**
+   * Creates a new deriver object.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+   *   The entity type manager.
+   * @param \Drupal\Core\StringTranslation\TranslationInterface $string_translation
+   *   The string translation service.
+   * @param \Drupal\scheduler\SchedulerManager $scheduler_manager
+   *   The scheduler manager.
+   */
+  public function __construct(EntityTypeManagerInterface $entity_type_manager, TranslationInterface $string_translation, SchedulerManager $scheduler_manager) {
+    $this->entityTypeManager = $entity_type_manager;
+    $this->stringTranslation = $string_translation;
+    $this->schedulerManager = $scheduler_manager;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container, $base_plugin_id) {
+    return new static(
+      $container->get('entity_type.manager'),
+      $container->get('string_translation'),
+      $container->get('scheduler.manager')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getDerivativeDefinitions($base_plugin_definition) {
+    // Get all entity types supported by Scheduler plugins.
+    $base_plugin_id = $base_plugin_definition['id'];
+    foreach ($this->schedulerManager->getPluginEntityTypes() as $entity_type_id) {
+      // Node actions are the originals, and for backwards-compatibility those
+      // action ids must remain the same, which can not be done using this
+      // deriver. Hence the node actions are defined in the 'Legacy' classes.
+      if ($entity_type_id == 'node') {
+        continue;
+      }
+      $entity_type = $this->entityTypeManager->getDefinition($entity_type_id);
+
+      // Create a context definition object for the 'entity'. This is common
+      // to all the derivatives.
+      $entity_context_definition = ContextDefinition::create("entity:$entity_type_id")
+        ->setAssignmentRestriction(ContextDefinition::ASSIGNMENT_RESTRICTION_SELECTOR)
+        ->setRequired(TRUE);
+
+      $t_args = [
+        '@entity_type_label' => $entity_type->getLabel(),
+        '@entity_type_singular' => $entity_type->getSingularLabel(),
+      ];
+      // Define the action label, context label and description, depending on
+      // which derivative we are building.
+      switch ($base_plugin_id) {
+        case 'scheduler_set_publishing_date':
+          $action_label = $this->t('Set date for publishing a @entity_type_singular', $t_args);
+          $entity_context_definition
+            ->setLabel($this->t('@entity_type_label for scheduling', $t_args))
+            ->setDescription($this->t('The @entity_type_singular which is to have a scheduled publishing date set', $t_args));
+          // Define a label and description for the date context definition.
+          $date_label = $this->t('Date for publishing');
+          $date_description = $this->t('The date when Scheduler will publish the @entity_type_singular', $t_args);
+          break;
+
+        case 'scheduler_set_unpublishing_date':
+          $action_label = $this->t('Set date for unpublishing a @entity_type_singular', $t_args);
+          $entity_context_definition
+            ->setLabel($this->t('@entity_type_label for scheduling', $t_args))
+            ->setDescription($this->t('The @entity_type_singular which is to have a scheduled unpublishing date set', $t_args));
+          $date_label = $this->t('Date for unpublishing');
+          $date_description = $this->t('The date when Scheduler will unpublish the @entity_type_singular', $t_args);
+          break;
+
+        case 'scheduler_remove_publishing_date':
+          $action_label = $this->t('Remove date for publishing a @entity_type_singular', $t_args);
+          $entity_context_definition
+            ->setLabel($this->t('@entity_type_label', $t_args))
+            ->setDescription($this->t('The @entity_type_singular from which to remove the scheduled publishing date', $t_args));
+          break;
+
+        case 'scheduler_remove_unpublishing_date':
+          $action_label = $this->t('Remove date for unpublishing a @entity_type_singular', $t_args);
+          $entity_context_definition
+            ->setLabel($this->t('@entity_type_label', $t_args))
+            ->setDescription($this->t('The @entity_type_singular from which to remove the scheduled unpublishing date', $t_args));
+          break;
+
+        case 'scheduler_publish_now':
+          $action_label = $this->t('Publish a @entity_type_singular immediately', $t_args);
+          $entity_context_definition
+            ->setLabel($this->t('@entity_type_label for publishing', $t_args))
+            ->setDescription($this->t('The @entity_type_singular to be published now', $t_args));
+          break;
+
+        case 'scheduler_unpublish_now':
+          $action_label = $this->t('Unpublish a @entity_type_singular immediately', $t_args);
+          $entity_context_definition
+            ->setLabel($this->t('@entity_type_label for unpublishing', $t_args))
+            ->setDescription($this->t('The @entity_type_singular to be unpublished now', $t_args));
+          break;
+
+        default:
+          $action_label = 'NOT SET for ' . $base_plugin_id;
+          $entity_context_definition->setLabel($action_label);
+          break;
+      }
+
+      // Build the basic action definition, with the entity context, which is
+      // common to all six actions.
+      $action_definition = [
+        'label' => $action_label,
+        'entity_type_id' => $entity_type_id,
+        'category' => $entity_type->getLabel() . ' (' . $this->t('Scheduler') . ')',
+        // The context parameter names have to be consistent across all entity
+        // types (we cannot use $entity_type_id). This avoids PHP8 failing with
+        // 'unknown named parameter' in call_user_func_array()
+        // @see https://www.drupal.org/project/scheduler/issues/3276637
+        'context_definitions' => ['entity' => $entity_context_definition],
+      ];
+
+      // For the actions that set a scheduler date add the date as a second
+      // context variable.
+      if ($base_plugin_id == 'scheduler_set_publishing_date' || $base_plugin_id == 'scheduler_set_unpublishing_date') {
+        $date_context_definition = ContextDefinition::create('timestamp')
+          ->setLabel($date_label)
+          ->setDescription($date_description)
+          ->setRequired(TRUE);
+        $action_definition['context_definitions']['date'] = $date_context_definition;
+      }
+
+      // Finally add the full action definition to the derivatives array.
+      $this->derivatives[$entity_type_id] = $action_definition + $base_plugin_definition;
+    }
+
+    return $this->derivatives;
+  }
+
+}
diff --git a/web/modules/scheduler/scheduler_rules_integration/src/Plugin/RulesAction/SetPublishingDate.php b/web/modules/scheduler/scheduler_rules_integration/src/Plugin/RulesAction/SetPublishingDate.php
index 2fb9a391e8c427f7cde7ec781126fdba31860f48..8a4b9f665477a088e36eb2bed5e6392b0d82f394 100644
--- a/web/modules/scheduler/scheduler_rules_integration/src/Plugin/RulesAction/SetPublishingDate.php
+++ b/web/modules/scheduler/scheduler_rules_integration/src/Plugin/RulesAction/SetPublishingDate.php
@@ -2,70 +2,41 @@
 
 namespace Drupal\scheduler_rules_integration\Plugin\RulesAction;
 
-use Drupal\Core\Link;
-use Drupal\Core\Url;
-use Drupal\rules\Core\RulesActionBase;
+use Drupal\Core\Entity\EntityInterface;
 
 /**
  * Provides a 'Set date for scheduled publishing' action.
  *
  * @RulesAction(
- *   id = "scheduler_set_publishing_date_action",
- *   label = @Translation("Set date for scheduled publishing"),
- *   category = @Translation("Scheduler"),
- *   context = {
- *     "node" = @ContextDefinition("entity:node",
- *       label = @Translation("Node for scheduling"),
- *       description = @Translation("The node which is to have a scheduled publishing date set"),
- *     ),
- *     "date" = @ContextDefinition("timestamp",
- *       label = @Translation("The date for publishing"),
- *       description = @Translation("The date when Scheduler will publish the node"),
- *     )
- *   }
+ *   id = "scheduler_set_publishing_date",
+ *   deriver = "Drupal\scheduler_rules_integration\Plugin\RulesAction\SchedulerRulesActionDeriver"
  * )
  */
-class SetPublishingDate extends RulesActionBase {
+class SetPublishingDate extends SchedulerRulesActionBase {
 
   /**
-   * Set the publish_on date for the node.
+   * Set the publish_on date on the entity.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity to be scheduled for publishing.
+   * @param int $date
+   *   The date for publishing.
    */
-  public function doExecute() {
-    $node = $this->getContextValue('node');
-    $date = $this->getContextValue('date');
+  public function doExecute(EntityInterface $entity, $date) {
     $config = \Drupal::config('scheduler.settings');
-    if ($node->type->entity->getThirdPartySetting('scheduler', 'publish_enable', $config->get('default_publish_enable'))) {
-      $node->set('publish_on', $date);
-      // When this action is invoked and it operates on the node being editted
-      // then hook_node_presave() and hook_node_update() will be executed
-      // automatically. But if this action is being used to schedule a different
-      // node then we need to call the functions directly here.
-      scheduler_node_presave($node);
-      scheduler_node_update($node);
+    $bundle_field = $entity->getEntityType()->get('entity_keys')['bundle'];
+    if ($entity->$bundle_field->entity->getThirdPartySetting('scheduler', 'publish_enable', $config->get('default_publish_enable'))) {
+      $entity->set('publish_on', $date);
+      // When this action is invoked and it operates on the entity being edited
+      // then hook_entity_presave() will be executed automatically. But if this
+      // action is being used to schedule a different entity then we need to
+      // call the functions directly here.
+      scheduler_entity_presave($entity);
     }
     else {
       // The action cannot be executed because the content type is not enabled
       // for scheduled publishing.
-      $action_label = $this->summary();
-      // @todo Can we get the condition description from the actual condition
-      // object instead of hard-coding it here?
-      $condition = $this->t('Node type is enabled for scheduled publishing');
-      $type_name = node_get_type_label($node);
-      $url = new Url('entity.node_type.edit_form', ['node_type' => $node->getType()]);
-      $arguments = [
-        '%type' => $type_name,
-        '%action_label' => $action_label,
-        '%condition' => $condition,
-        '@url' => $url->toString(),
-        'link' => Link::fromTextAndUrl($this->t('@type settings', ['@type' => $type_name]), $url)->toString(),
-      ];
-
-      \Drupal::logger('scheduler')->warning('Action "%action_label" is not valid because scheduled publishing is not enabled for %type content. Add the condition "%condition" to your Reaction Rule, or enable scheduled publishing via the %type settings.',
-        $arguments);
-
-      \Drupal::messenger()->addMessage($this->t('Action "%action_label" is not valid because scheduled publishing is not enabled for %type content. Add the condition "%condition" to your Reaction Rule, or enable scheduled publishing via the <a href="@url">%type</a> settings.',
-        $arguments), 'warning', FALSE);
-
+      $this->notEnabledWarning($entity, 'publish');
     }
   }
 
diff --git a/web/modules/scheduler/scheduler_rules_integration/src/Plugin/RulesAction/SetUnpublishingDate.php b/web/modules/scheduler/scheduler_rules_integration/src/Plugin/RulesAction/SetUnpublishingDate.php
index 4240bda836e4cac361216d6059e167073d7890a2..e0006d25fad0054196d8fff8bdaf8258b857487e 100644
--- a/web/modules/scheduler/scheduler_rules_integration/src/Plugin/RulesAction/SetUnpublishingDate.php
+++ b/web/modules/scheduler/scheduler_rules_integration/src/Plugin/RulesAction/SetUnpublishingDate.php
@@ -2,69 +2,41 @@
 
 namespace Drupal\scheduler_rules_integration\Plugin\RulesAction;
 
-use Drupal\Core\Link;
-use Drupal\Core\Url;
-use Drupal\rules\Core\RulesActionBase;
+use Drupal\Core\Entity\EntityInterface;
 
 /**
  * Provides a 'Set date for scheduled unpublishing' action.
  *
  * @RulesAction(
- *   id = "scheduler_set_unpublishing_date_action",
- *   label = @Translation("Set date for scheduled unpublishing"),
- *   category = @Translation("Scheduler"),
- *   context = {
- *     "node" = @ContextDefinition("entity:node",
- *       label = @Translation("Node for scheduling"),
- *       description = @Translation("The node which is to have a scheduled unpublishing date set"),
- *     ),
- *     "date" = @ContextDefinition("timestamp",
- *       label = @Translation("The date for unpublishing"),
- *       description = @Translation("The date when Scheduler will unpublish the node"),
- *     )
- *   }
+ *   id = "scheduler_set_unpublishing_date",
+ *   deriver = "Drupal\scheduler_rules_integration\Plugin\RulesAction\SchedulerRulesActionDeriver"
  * )
  */
-class SetUnpublishingDate extends RulesActionBase {
+class SetUnpublishingDate extends SchedulerRulesActionBase {
 
   /**
-   * Set the unpublish_on date for the node.
+   * Set the unpublish_on date on the entity.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity to be scheduled for unpublishing.
+   * @param int $date
+   *   The date for unpublishing.
    */
-  public function doExecute() {
-    $node = $this->getContextValue('node');
-    $date = $this->getContextValue('date');
+  public function doExecute(EntityInterface $entity, $date) {
     $config = \Drupal::config('scheduler.settings');
-    if ($node->type->entity->getThirdPartySetting('scheduler', 'unpublish_enable', $config->get('default_unpublish_enable'))) {
-      $node->set('unpublish_on', $date);
-      // When this action is invoked and it operates on the node being editted
-      // then hook_node_presave() and hook_node_update() will be executed
-      // automatically. But if this action is being used to schedule a different
-      // node then we need to call the functions directly here.
-      scheduler_node_presave($node);
-      scheduler_node_update($node);
+    $bundle_field = $entity->getEntityType()->get('entity_keys')['bundle'];
+    if ($entity->$bundle_field->entity->getThirdPartySetting('scheduler', 'unpublish_enable', $config->get('default_unpublish_enable'))) {
+      $entity->set('unpublish_on', $date);
+      // When this action is invoked and it operates on the entity being edited
+      // then hook_entity_presave() will be executed automatically. But if this
+      // action is being used to schedule a different entity then we need to
+      // call the functions directly here.
+      scheduler_entity_presave($entity);
     }
     else {
       // The action cannot be executed because the content type is not enabled
       // for scheduled unpublishing.
-      $action_label = $this->summary();
-      // @todo Can we get the condition description from the actual condition
-      // object instead of hard-coding it here?
-      $condition = $this->t('Node type is enabled for scheduled unpublishing');
-      $type_name = node_get_type_label($node);
-      $url = new Url('entity.node_type.edit_form', ['node_type' => $node->getType()]);
-      $arguments = [
-        '%type' => $type_name,
-        '%action_label' => $action_label,
-        '%condition' => $condition,
-        '@url' => $url->toString(),
-        'link' => Link::fromTextAndUrl($this->t('@type settings', ['@type' => $type_name]), $url)->toString(),
-      ];
-
-      \Drupal::logger('scheduler')->warning('Action "%action_label" is not valid because scheduled unpublishing is not enabled for %type content. Add the condition "%condition" to your Reaction Rule, or enable scheduled unpublishing via the %type settings.',
-        $arguments);
-
-      \Drupal::messenger()->addMessage($this->t('Action "%action_label" is not valid because scheduled unpublishing is not enabled for %type content. Add the condition "%condition" to your Reaction Rule, or enable scheduled unpublishing via the <a href="@url">%type</a> settings.',
-        $arguments), 'warning', FALSE);
+      $this->notEnabledWarning($entity, 'unpublish');
     }
   }
 
diff --git a/web/modules/scheduler/scheduler_rules_integration/src/Plugin/RulesAction/UnpublishNow.php b/web/modules/scheduler/scheduler_rules_integration/src/Plugin/RulesAction/UnpublishNow.php
index 498e9d66eb274c692cc47f9cfe1a45ee765e30de..cba53e26183c35b41b8ac5566db5d74a1bed4a5a 100644
--- a/web/modules/scheduler/scheduler_rules_integration/src/Plugin/RulesAction/UnpublishNow.php
+++ b/web/modules/scheduler/scheduler_rules_integration/src/Plugin/RulesAction/UnpublishNow.php
@@ -2,35 +2,30 @@
 
 namespace Drupal\scheduler_rules_integration\Plugin\RulesAction;
 
-use Drupal\rules\Core\RulesActionBase;
+use Drupal\Core\Entity\EntityInterface;
 
 /**
- * Provides an 'Unpublish the node immediately' action.
+ * Provides an 'Unpublish immediately' action.
  *
  * @RulesAction(
- *   id = "scheduler_unpublish_now_action",
- *   label = @Translation("Unpublish the content immediately"),
- *   category = @Translation("Scheduler"),
- *   context = {
- *     "node" = @ContextDefinition("entity:node",
- *       label = @Translation("Node"),
- *       description = @Translation("The node to be unpublished now"),
- *     ),
- *   }
+ *   id = "scheduler_unpublish_now",
+ *   deriver = "Drupal\scheduler_rules_integration\Plugin\RulesAction\SchedulerRulesActionDeriver"
  * )
  */
-class UnpublishNow extends RulesActionBase {
+class UnpublishNow extends SchedulerRulesActionBase {
 
   /**
-   * Set the node status to Unpublished.
+   * Set the entity status to Unpublished.
    *
-   * This action should really be provided by Rules or by Core, but it is not
-   * yet done (as of Aug 2016). Scheduler users need this action so we provide
-   * it here. It could be removed later when Rules or Core includes it.
+   * This action is provided by the Rules Module but only for node content, not
+   * Media. There is also a problem with recursion in the Rules action due to
+   * autoSaveContext(). Hence better for Scheduler to provide this action.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity to be unpublished.
    */
-  public function doExecute() {
-    $node = $this->getContextValue('node');
-    $node->setUnpublished();
+  public function doExecute(EntityInterface $entity) {
+    $entity->setUnpublished();
   }
 
 }
diff --git a/web/modules/scheduler/scheduler_rules_integration/tests/src/Functional/SchedulerRulesActionsTest.php b/web/modules/scheduler/scheduler_rules_integration/tests/src/Functional/SchedulerRulesActionsTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..a1d26b3bf05c5761d73cbb72d83c2978851abfba
--- /dev/null
+++ b/web/modules/scheduler/scheduler_rules_integration/tests/src/Functional/SchedulerRulesActionsTest.php
@@ -0,0 +1,425 @@
+<?php
+
+namespace Drupal\Tests\scheduler_rules_integration\Functional;
+
+use Drupal\Core\Logger\RfcLogLevel;
+use Drupal\rules\Context\ContextConfig;
+use Drupal\Tests\scheduler\Functional\SchedulerBrowserTestBase;
+
+/**
+ * Tests the six actions that Scheduler provides for use in Rules module.
+ *
+ * @group scheduler_rules_integration
+ */
+class SchedulerRulesActionsTest extends SchedulerBrowserTestBase {
+
+  /**
+   * Additional modules required.
+   *
+   * @var array
+   */
+  protected static $modules = ['scheduler_rules_integration'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+
+    $this->rulesStorage = $this->container->get('entity_type.manager')->getStorage('rules_reaction_rule');
+    $this->expressionManager = $this->container->get('plugin.manager.rules_expression');
+    $this->drupalLogin($this->adminUser);
+
+  }
+
+  /**
+   * Tests the actions which set and remove the 'Publish On' date.
+   *
+   * @dataProvider dataStandardEntityTypes()
+   */
+  public function testPublishOnActions($entityTypeId, $enabledBundle) {
+    $nonEnabledBundle = $this->entityTypeObject($entityTypeId, 'non-enabled')->id();
+    $titleField = $this->titleField($entityTypeId);
+    $publish_on = $this->requestTime + 1800;
+    $publish_on_formatted = $this->dateFormatter->format($publish_on, 'long');
+
+    // The legacy rules action ids for nodes remain as:
+    // -  scheduler_set_publishing_date_action
+    // -  scheduler_publish_now_action
+    // For all other entity types the new derived action ids are of the form:
+    // -  scheduler_set_publishing_date:{type}
+    // -  scheduler_publish_now:{type}
+    // .
+    $action_suffix = ($entityTypeId == 'node') ? '_action' : ":$entityTypeId";
+    $storage = $this->entityStorageObject($entityTypeId);
+
+    // Create rule 1 to set the publishing date.
+    $rule1 = $this->expressionManager->createRule();
+    $rule1->addCondition('rules_data_comparison',
+        ContextConfig::create()
+          ->map('data', "$entityTypeId.$titleField.value")
+          ->setValue('operation', 'contains')
+          ->setValue('value', 'Trigger Rule 1')
+    );
+    $message1 = 'RULES message 1. Action to set Publish-on date.';
+    $rule1->addAction("scheduler_set_publishing_date$action_suffix",
+      ContextConfig::create()
+        ->map('entity', "$entityTypeId")
+        ->setValue('date', $publish_on)
+      )
+      ->addAction('rules_system_message',
+        ContextConfig::create()
+          ->setValue('message', $message1)
+          ->setValue('type', 'status')
+    );
+    // The event needs to be rules_entity_presave:{type} 'before saving' because
+    // rules_entity_update:{type} 'after save' is too late to set the date.
+    $config_entity = $this->rulesStorage->create([
+      'id' => 'rule1',
+      'events' => [['event_name' => "rules_entity_presave:$entityTypeId"]],
+      'expression' => $rule1->getConfiguration(),
+    ]);
+    $config_entity->save();
+
+    // Create rule 2 to remove the publishing date and publish the entity.
+    $rule2 = $this->expressionManager->createRule();
+    $rule2->addCondition('rules_data_comparison',
+        ContextConfig::create()
+          ->map('data', "$entityTypeId.$titleField.value")
+          ->setValue('operation', 'contains')
+          ->setValue('value', 'Trigger Rule 2')
+    );
+    $message2 = 'RULES message 2. Action to remove Publish-on date and publish immediately.';
+    $rule2->addAction("scheduler_remove_publishing_date$action_suffix",
+      ContextConfig::create()
+        ->map('entity', "$entityTypeId")
+      )
+      ->addAction("scheduler_publish_now$action_suffix",
+        ContextConfig::create()
+          ->map('entity', "$entityTypeId")
+      )
+      ->addAction('rules_system_message',
+        ContextConfig::create()
+          ->setValue('message', $message2)
+          ->setValue('type', 'status')
+      );
+    $config_entity = $this->rulesStorage->create([
+      'id' => 'rule2',
+      'events' => [['event_name' => "rules_entity_presave:$entityTypeId"]],
+      'expression' => $rule2->getConfiguration(),
+    ]);
+    $config_entity->save();
+
+    $assert = $this->assertSession();
+
+    // First, create a new scheduler-enabled entity, triggering rule 1.
+    $title = "First - new enabled $enabledBundle - Trigger Rule 1";
+    $this->drupalGet($this->entityAddUrl($entityTypeId, $enabledBundle));
+    $this->submitForm(["{$titleField}[0][value]" => $title], 'Save');
+    $entity = $this->getEntityByTitle($entityTypeId, $title);
+    $this->assertSession()->pageTextContains(sprintf('%s is scheduled to be published %s', $title, $publish_on_formatted));
+
+    // Check that rule 1 is triggered and rule 2 is not. Check that a publishing
+    // date has been set and the status is now unpublished.
+    $assert->pageTextContains($message1);
+    $assert->pageTextNotContains($message2);
+    $this->assertEquals($entity->publish_on->value, $publish_on, 'Entity should be scheduled for publishing at the correct time');
+    $this->assertEmpty($entity->unpublish_on->value, 'Entity should not be scheduled for unpublishing.');
+    $this->assertFalse($entity->isPublished(), 'Entity should be unpublished');
+
+    // Second, edit a pre-existing Scheduler-enabled entity, without triggering
+    // either of the rules.
+    $entity = $this->createEntity($entityTypeId, $enabledBundle, [
+      "$titleField" => "Second - existing enabled $enabledBundle",
+    ]);
+    $this->drupalGet($entity->toUrl('edit-form'));
+    $this->submitForm(["{$titleField}[0][value]" => "Edit enabled $enabledBundle - but no rules will be triggered"], 'Save');
+    $storage->resetCache([$entity->id()]);
+    $entity = $storage->load($entity->id());
+    // Check that neither of the rules are triggered, no publish and unpublish
+    // dates are set and the status is still published.
+    $assert->pageTextNotContains($message1);
+    $assert->pageTextNotContains($message2);
+    $this->assertEmpty($entity->publish_on->value, 'Entity should not be scheduled for publishing');
+    $this->assertEmpty($entity->unpublish_on->value, 'Entity should not be scheduled for unpublishing');
+    $this->assertTrue($entity->isPublished(), 'Entity should remain published');
+
+    // Edit the entity, triggering rule 1.
+    $this->drupalGet($entity->toUrl('edit-form'));
+    $this->submitForm(["{$titleField}[0][value]" => "Edit enabled $enabledBundle - Trigger Rule 1"], 'Save');
+    $storage->resetCache([$entity->id()]);
+    $entity = $storage->load($entity->id());
+    // Check that rule 1 is triggered and rule 2 is not. Check that a publishing
+    // date has been set and the status is now unpublished.
+    $assert->pageTextContains($message1);
+    $assert->pageTextNotContains($message2);
+    $this->assertEquals($entity->publish_on->value, $publish_on, 'Entity should be scheduled for publishing at the correct time');
+    $this->assertEmpty($entity->unpublish_on->value, 'Entity should not be scheduled for unpublishing');
+    $this->assertFalse($entity->isPublished(), 'Entity should be unpublished');
+
+    // Edit the entity, triggering rule 2.
+    $this->drupalGet($entity->toUrl('edit-form'));
+    $this->submitForm(["{$titleField}[0][value]" => "Edit enabled $enabledBundle - Trigger Rule 2"], 'Save');
+    $storage->resetCache([$entity->id()]);
+    $entity = $storage->load($entity->id());
+    // Check that rule 2 is triggered and rule 1 is not. Check that the
+    // publishing date has been removed and the status is now published.
+    $assert->pageTextNotContains($message1);
+    $assert->pageTextContains($message2);
+    $this->assertEmpty($entity->publish_on->value, 'Entity should not be scheduled for publishing.');
+    $this->assertEmpty($entity->unpublish_on->value, 'Entity should not be scheduled for unpublishing.');
+    $this->assertTrue($entity->isPublished(), 'Entity should be published.');
+
+    // Third, create a new entity which is not scheduler-enabled.
+    $title = "Third - new non-enabled $nonEnabledBundle - Trigger Rule 1";
+    $this->drupalGet($this->entityAddUrl($entityTypeId, $nonEnabledBundle));
+    $this->submitForm(["{$titleField}[0][value]" => $title], 'Save');
+    $entity = $this->getEntityByTitle($entityTypeId, $title);
+    // Check that rule 1 issued a warning message.
+    $assert->pageTextContains('warning message');
+    $assert->elementExists('xpath', '//div[@aria-label="Warning message" and contains(string(), "Action")]');
+    // Check that no publishing date is set.
+    $this->assertEmpty($entity->publish_on->value, 'Entity should not be scheduled for publishing');
+    // Check that a log message has been recorded.
+    $log = \Drupal::database()->select('watchdog', 'w')
+      ->condition('type', 'scheduler')
+      ->condition('severity', RfcLogLevel::WARNING)
+      ->countQuery()
+      ->execute()
+      ->fetchField();
+    $this->assertEquals(1, $log, 'There is 1 watchdog warning message from Scheduler');
+
+    // Fourthly, edit a pre-existing entity which is not enabled for Scheduler,
+    // triggering rule 1.
+    $entity = $this->createEntity($entityTypeId, $nonEnabledBundle, [
+      "$titleField" => "Fourth - existing non-enabled $nonEnabledBundle",
+    ]);
+    $this->drupalGet($entity->toUrl('edit-form'));
+    $this->submitForm(["{$titleField}[0][value]" => "Edit non-enabled $nonEnabledBundle - Trigger Rule 1"], 'Save');
+    // Check that rule 1 issued a warning message.
+    $assert->pageTextContains('warning message');
+    $assert->elementExists('xpath', '//div[@aria-label="Warning message" and contains(string(), "Action")]');
+    // Check that no publishing date is set.
+    $this->assertEmpty($entity->publish_on->value, 'Entity should not be scheduled for publishing.');
+    // Check that a log message has been recorded.
+    $log = \Drupal::database()->select('watchdog', 'w')
+      ->condition('type', 'scheduler')
+      ->condition('severity', RfcLogLevel::WARNING)
+      ->countQuery()
+      ->execute()
+      ->fetchField();
+    $this->assertEquals(2, $log, 'There are now 2 watchdog warning messages from Scheduler');
+
+    // Edit the entity again, triggering rule 2.
+    $this->drupalGet($entity->toUrl('edit-form'));
+    $this->submitForm(["{$titleField}[0][value]" => "Edit non-enabled $nonEnabledBundle - Trigger Rule 2"], 'Save');
+    // Check that rule 2 issued a warning message.
+    $assert->pageTextContains('warning message');
+    $assert->elementExists('xpath', '//div[@aria-label="Warning message" and contains(string(), "Action")]');
+    // Check that a second log message has been recorded.
+    $log = \Drupal::database()->select('watchdog', 'w')
+      ->condition('type', 'scheduler')
+      ->condition('severity', RfcLogLevel::WARNING)
+      ->countQuery()
+      ->execute()
+      ->fetchField();
+    $this->assertEquals(3, $log, 'There are now 3 watchdog warning messages from Scheduler');
+    $this->drupalGet('admin/reports/dblog');
+  }
+
+  /**
+   * Tests the actions which set and remove the 'Unpublish On' date.
+   *
+   * @dataProvider dataStandardEntityTypes()
+   */
+  public function testUnpublishOnActions($entityTypeId, $enabledBundle) {
+    $nonEnabledBundle = $this->entityTypeObject($entityTypeId, 'non-enabled')->id();
+    $titleField = $this->titleField($entityTypeId);
+    $unpublish_on = $this->requestTime + 2400;
+    $unpublish_on_formatted = $this->dateFormatter->format($unpublish_on, 'long');
+
+    // The legacy rules action ids for nodes remain as:
+    // -  scheduler_set_unpublishing_date_action
+    // -  scheduler_unpublish_now_action
+    // For all other entity types the new derived action ids are of the form:
+    // -  scheduler_set_unpublishing_date:{type}
+    // -  scheduler_unpublish_now:{type}
+    // .
+    $action_suffix = ($entityTypeId == 'node') ? '_action' : ":$entityTypeId";
+    $storage = $this->entityStorageObject($entityTypeId);
+
+    // Create rule 3 to set the unpublishing date.
+    $rule3 = $this->expressionManager->createRule();
+    $rule3->addCondition('rules_data_comparison',
+        ContextConfig::create()
+          ->map('data', "$entityTypeId.$titleField.value")
+          ->setValue('operation', 'contains')
+          ->setValue('value', 'Trigger Rule 3')
+    );
+    $message3 = 'RULES message 3. Action to set Unpublish-on date.';
+    $rule3->addAction("scheduler_set_unpublishing_date$action_suffix",
+      ContextConfig::create()
+        ->map('entity', "$entityTypeId")
+        ->setValue('date', $unpublish_on)
+      )
+      ->addAction('rules_system_message',
+        ContextConfig::create()
+          ->setValue('message', $message3)
+          ->setValue('type', 'status')
+    );
+    // The event needs to be rules_entity_presave:{type} 'before saving' because
+    // rules_entity_update:{type} 'after save' is too late to set the date.
+    $config_entity = $this->rulesStorage->create([
+      'id' => 'rule3',
+      'events' => [['event_name' => "rules_entity_presave:$entityTypeId"]],
+      'expression' => $rule3->getConfiguration(),
+    ]);
+    $config_entity->save();
+
+    // Create rule 4 to remove the unpublishing date and unpublish the entity.
+    $rule4 = $this->expressionManager->createRule();
+    $rule4->addCondition('rules_data_comparison',
+        ContextConfig::create()
+          ->map('data', "$entityTypeId.$titleField.value")
+          ->setValue('operation', 'contains')
+          ->setValue('value', 'Trigger Rule 4')
+    );
+    $message4 = "RULES message 4. Action to remove Unpublish-on date and unpublish the $entityTypeId immediately.";
+    $rule4->addAction("scheduler_remove_unpublishing_date$action_suffix",
+      ContextConfig::create()
+        ->map('entity', "$entityTypeId")
+      )
+      ->addAction("scheduler_unpublish_now$action_suffix",
+        ContextConfig::create()
+          ->map('entity', "$entityTypeId")
+      )
+      ->addAction('rules_system_message',
+        ContextConfig::create()
+          ->setValue('message', $message4)
+          ->setValue('type', 'status')
+      );
+    $config_entity = $this->rulesStorage->create([
+      'id' => 'rule4',
+      'events' => [['event_name' => "rules_entity_presave:$entityTypeId"]],
+      'expression' => $rule4->getConfiguration(),
+    ]);
+    $config_entity->save();
+
+    $assert = $this->assertSession();
+
+    // First, create a new scheduler-enabled entity, triggering rule 3.
+    $title = "First - new enabled $enabledBundle - Trigger Rule 3";
+    $this->drupalGet($this->entityAddUrl($entityTypeId, $enabledBundle));
+    $this->submitForm(["{$titleField}[0][value]" => $title], 'Save');
+    $entity = $this->getEntityByTitle($entityTypeId, $title);
+    $this->assertSession()->pageTextContains(sprintf('%s is scheduled to be unpublished %s', $title, $unpublish_on_formatted));
+
+    // Check that rule 3 is triggered and rule 4 is not. Check that a publishing
+    // date has been set and the status is now unpublished.
+    $assert->pageTextContains($message3);
+    $assert->pageTextNotContains($message4);
+    $this->assertEquals($entity->unpublish_on->value, $unpublish_on, 'Entity should be scheduled for unpublishing at the correct time');
+    $this->assertEmpty($entity->publish_on->value, 'Entity should not be scheduled for publishing.');
+    $this->assertTrue($entity->isPublished(), 'Entity should be published');
+
+    // Second, edit a pre-existing Scheduler-enabled entity, without triggering
+    // either of the rules.
+    $entity = $this->createEntity($entityTypeId, $enabledBundle, [
+      "$titleField" => "Second - existing enabled $enabledBundle",
+    ]);
+    $this->drupalGet($entity->toUrl('edit-form'));
+    $this->submitForm(["{$titleField}[0][value]" => "Edit enabled $enabledBundle - but no rules will be triggered"], 'Save');
+    $storage->resetCache([$entity->id()]);
+    $entity = $storage->load($entity->id());
+    // Check that neither of the rules are triggered, no publish and unpublish
+    // dates are set and the status is still published.
+    $assert->pageTextNotContains($message3);
+    $assert->pageTextNotContains($message4);
+    $this->assertEmpty($entity->publish_on->value, 'Entity should not be scheduled for publishing');
+    $this->assertEmpty($entity->unpublish_on->value, 'Entity should not be scheduled for unpublishing');
+    $this->assertTrue($entity->isPublished(), 'Entity should remain published');
+
+    // Edit the entity, triggering rule 3.
+    $this->drupalGet($entity->toUrl('edit-form'));
+    $this->submitForm(["{$titleField}[0][value]" => "Edit enabled $enabledBundle - Trigger Rule 3"], 'Save');
+    $storage->resetCache([$entity->id()]);
+    $entity = $storage->load($entity->id());
+    // Check that rule 3 is triggered and rule 4 is not. Check that an
+    // unpublishing date has been set and the status is still published.
+    $assert->pageTextContains($message3);
+    $assert->pageTextNotContains($message4);
+    $this->assertEmpty($entity->publish_on->value, 'Entity should not be scheduled for publishing');
+    $this->assertEquals($entity->unpublish_on->value, $unpublish_on, 'Entity should be scheduled for unpublishing at the correct time');
+    $this->assertTrue($entity->isPublished(), 'Entity is still published');
+
+    // Edit the entity, triggering rule 4.
+    $this->drupalGet($entity->toUrl('edit-form'));
+    $this->submitForm(["{$titleField}[0][value]" => "Edit enabled $enabledBundle - Trigger Rule 4"], 'Save');
+    $storage->resetCache([$entity->id()]);
+    $entity = $storage->load($entity->id());
+    // Check that rule 4 is triggered and rule 3 is not. Check that the
+    // unpublishing date has been removed and the status is now unpublished.
+    $assert->pageTextNotContains($message3);
+    $assert->pageTextContains($message4);
+    $this->assertEmpty($entity->publish_on->value, 'Entity should not be scheduled for publishing.');
+    $this->assertEmpty($entity->unpublish_on->value, 'Entity should not be scheduled for unpublishing.');
+    $this->assertFalse($entity->isPublished(), 'Entity should be unpublished.');
+
+    // Third, create a new entity which is not scheduler-enabled.
+    $title = "Third - new non-enabled $nonEnabledBundle - Trigger Rule 3";
+    $this->drupalGet($this->entityAddUrl($entityTypeId, $nonEnabledBundle));
+    $this->submitForm(["{$titleField}[0][value]" => $title], 'Save');
+    $entity = $this->getEntityByTitle($entityTypeId, $title);
+    // Check that rule 3 issued a warning message.
+    $assert->pageTextContains('warning message');
+    $assert->elementExists('xpath', '//div[@aria-label="Warning message" and contains(string(), "Action")]');
+    // Check that no publishing date is set.
+    $this->assertEmpty($entity->unpublish_on->value, 'Entity should not be scheduled for unpublishing');
+    // Check that a log message has been recorded.
+    $log = \Drupal::database()->select('watchdog', 'w')
+      ->condition('type', 'scheduler')
+      ->condition('severity', RfcLogLevel::WARNING)
+      ->countQuery()
+      ->execute()
+      ->fetchField();
+    $this->assertEquals(1, $log, 'There is 1 watchdog warning message from Scheduler');
+
+    // Fourthly, edit a pre-existing entity which is not enabled for Scheduler,
+    // triggering rule 3.
+    $entity = $this->createEntity($entityTypeId, $nonEnabledBundle, [
+      "$titleField" => "Fourth - existing non-enabled $nonEnabledBundle",
+    ]);
+    $this->drupalGet($entity->toUrl('edit-form'));
+    $this->submitForm(["{$titleField}[0][value]" => "Edit non-enabled $nonEnabledBundle - Trigger Rule 3"], 'Save');
+    // Check that rule 3 issued a warning message.
+    $assert->pageTextContains('warning message');
+    $assert->elementExists('xpath', '//div[@aria-label="Warning message" and contains(string(), "Action")]');
+    // Check that no unpublishing date is set.
+    $this->assertEmpty($entity->unpublish_on->value, 'Entity should not be scheduled for unpublishing.');
+    // Check that a log message has been recorded.
+    $log = \Drupal::database()->select('watchdog', 'w')
+      ->condition('type', 'scheduler')
+      ->condition('severity', RfcLogLevel::WARNING)
+      ->countQuery()
+      ->execute()
+      ->fetchField();
+    $this->assertEquals(2, $log, 'There are now 2 watchdog warning messages from Scheduler');
+
+    // Edit the entity again, triggering rule 4.
+    $this->drupalGet($entity->toUrl('edit-form'));
+    $this->submitForm(["{$titleField}[0][value]" => "Edit non-enabled $nonEnabledBundle - Trigger Rule 4"], 'Save');
+    // Check that rule 4 issued a warning message.
+    $assert->pageTextContains('warning message');
+    $assert->elementExists('xpath', '//div[@aria-label="Warning message" and contains(string(), "Action")]');
+    // Check that a second log message has been recorded.
+    $log = \Drupal::database()->select('watchdog', 'w')
+      ->condition('type', 'scheduler')
+      ->condition('severity', RfcLogLevel::WARNING)
+      ->countQuery()
+      ->execute()
+      ->fetchField();
+    $this->assertEquals(3, $log, 'There are now 3 watchdog warning messages from Scheduler');
+    $this->drupalGet('admin/reports/dblog');
+  }
+
+}
diff --git a/web/modules/scheduler/scheduler_rules_integration/tests/src/Functional/SchedulerRulesConditionsTest.php b/web/modules/scheduler/scheduler_rules_integration/tests/src/Functional/SchedulerRulesConditionsTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..f2141bfcfca298dca5bded3173da7b5409c485e7
--- /dev/null
+++ b/web/modules/scheduler/scheduler_rules_integration/tests/src/Functional/SchedulerRulesConditionsTest.php
@@ -0,0 +1,292 @@
+<?php
+
+namespace Drupal\Tests\scheduler_rules_integration\Functional;
+
+use Drupal\rules\Context\ContextConfig;
+use Drupal\Tests\scheduler\Functional\SchedulerBrowserTestBase;
+
+/**
+ * Tests the four conditions that Scheduler provides for use in Rules module.
+ *
+ * @group scheduler_rules_integration
+ */
+class SchedulerRulesConditionsTest extends SchedulerBrowserTestBase {
+
+  /**
+   * Additional modules required.
+   *
+   * @var array
+   */
+  protected static $modules = ['scheduler_rules_integration'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+    $this->rulesStorage = $this->container->get('entity_type.manager')->getStorage('rules_reaction_rule');
+    $this->expressionManager = $this->container->get('plugin.manager.rules_expression');
+  }
+
+  /**
+   * Tests the conditions for whether an entity type is enabled for Scheduler.
+   *
+   * @dataProvider dataStandardEntityTypes()
+   */
+  public function testEntityTypeEnabledConditions($entityTypeId, $bundle) {
+
+    // The legacy rules condition ids for nodes remain as:
+    // -  scheduler_condition_publishing_is_enabled
+    // -  scheduler_condition_unpublishing_is_enabled
+    // For all other entity types the new derived condition ids are of the form:
+    // -  scheduler_publishing_is_enabled:{type}
+    // -  scheduler_unpublishing_is_enabled:{type}
+    // .
+    $condition_prefix = ($entityTypeId == 'node') ? 'scheduler_condition_' : 'scheduler_';
+    $condition_suffix = ($entityTypeId == 'node') ? '' : ":$entityTypeId";
+    $entityType = $this->entityTypeObject($entityTypeId, $bundle);
+    $assert = $this->assertSession();
+
+    // Create a reaction rule to display a message when viewing an entity of a
+    // type that is enabled for scheduled publishing.
+    // "viewing content" actually means "viewing PUBLISHED content".
+    $rule1 = $this->expressionManager->createRule();
+    $rule1->addCondition("{$condition_prefix}publishing_is_enabled{$condition_suffix}",
+      ContextConfig::create()->map('entity', "$entityTypeId")
+    );
+    $message1 = 'RULES message 1. This entity type is enabled for scheduled publishing.';
+    $rule1->addAction('rules_system_message', ContextConfig::create()
+      ->setValue('message', $message1)
+      ->setValue('type', 'status')
+      );
+    $config_entity = $this->rulesStorage->create([
+      'id' => 'rule1',
+      'events' => [['event_name' => "rules_entity_view:$entityTypeId"]],
+      'expression' => $rule1->getConfiguration(),
+    ]);
+    $config_entity->save();
+
+    // Create a reaction rule to display a message when viewing an entity of a
+    // type that is enabled for scheduled unpublishing.
+    $rule2 = $this->expressionManager->createRule();
+    $rule2->addCondition("{$condition_prefix}unpublishing_is_enabled{$condition_suffix}",
+      ContextConfig::create()->map('entity', "$entityTypeId")
+    );
+    $message2 = 'RULES message 2. This entity type is enabled for scheduled unpublishing.';
+    $rule2->addAction('rules_system_message', ContextConfig::create()
+      ->setValue('message', $message2)
+      ->setValue('type', 'status')
+      );
+    $config_entity = $this->rulesStorage->create([
+      'id' => 'rule2',
+      'events' => [['event_name' => "rules_entity_view:$entityTypeId"]],
+      'expression' => $rule2->getConfiguration(),
+    ]);
+    $config_entity->save();
+
+    // Create a reaction rule to display a message when viewing an entity of a
+    // type that is NOT enabled for scheduled publishing.
+    $rule3 = $this->expressionManager->createRule();
+    $rule3->addCondition("{$condition_prefix}publishing_is_enabled{$condition_suffix}",
+      ContextConfig::create()->map('entity', "$entityTypeId")->negateResult()
+    );
+    $message3 = 'RULES message 3. This entity type is not enabled for scheduled publishing.';
+    $rule3->addAction('rules_system_message', ContextConfig::create()
+      ->setValue('message', $message3)
+      ->setValue('type', 'status')
+      );
+    $config_entity = $this->rulesStorage->create([
+      'id' => 'rule3',
+      'events' => [['event_name' => "rules_entity_view:$entityTypeId"]],
+      'expression' => $rule3->getConfiguration(),
+    ]);
+    $config_entity->save();
+
+    // Create a reaction rule to display a message when viewing an entity of a
+    // type that is NOT enabled for scheduled unpublishing.
+    $rule4 = $this->expressionManager->createRule();
+    $rule4->addCondition("{$condition_prefix}unpublishing_is_enabled{$condition_suffix}",
+      ContextConfig::create()->map('entity', "$entityTypeId")->negateResult()
+    );
+    $message4 = 'RULES message 4. This entity type is not enabled for scheduled unpublishing.';
+    $rule4->addAction('rules_system_message', ContextConfig::create()
+      ->setValue('message', $message4)
+      ->setValue('type', 'status')
+      );
+    $config_entity = $this->rulesStorage->create([
+      'id' => 'rule4',
+      'events' => [['event_name' => "rules_entity_view:$entityTypeId"]],
+      'expression' => $rule4->getConfiguration(),
+    ]);
+    $config_entity->save();
+
+    // Create a published entity.
+    $entity = $this->createEntity($entityTypeId, $bundle, [
+      'title' => "Enabled Conditions - $entityTypeId $bundle",
+      'status' => TRUE,
+    ]);
+
+    // View the entity and check the default position - that the entity type is
+    // enabled for both publishing and unpublishing.
+    $this->drupalGet($entity->toUrl());
+    $assert->pageTextContains($message1);
+    $assert->pageTextContains($message2);
+    $assert->pageTextNotContains($message3);
+    $assert->pageTextNotContains($message4);
+
+    // Turn off scheduled publishing for the entity type and check the rules.
+    $entityType->setThirdPartySetting('scheduler', 'publish_enable', FALSE)->save();
+    drupal_flush_all_caches();
+    $this->drupalGet($entity->toUrl());
+    $assert->pageTextNotContains($message1);
+    $assert->pageTextContains($message2);
+    $assert->pageTextContains($message3);
+    $assert->pageTextNotContains($message4);
+
+    // Turn off scheduled unpublishing for the entity type and the check again.
+    $entityType->setThirdPartySetting('scheduler', 'unpublish_enable', FALSE)->save();
+    drupal_flush_all_caches();
+    $this->drupalGet($entity->toUrl());
+    $assert->pageTextNotContains($message1);
+    $assert->pageTextNotContains($message2);
+    $assert->pageTextContains($message3);
+    $assert->pageTextContains($message4);
+
+  }
+
+  /**
+   * Tests the conditions for whether an entity is scheduled.
+   *
+   * @dataProvider dataStandardEntityTypes()
+   */
+  public function testEntityIsScheduledConditions($entityTypeId, $bundle) {
+    // The legacy rules condition ids for nodes remain as:
+    // -  scheduler_condition_node_scheduled_for_publishing
+    // -  scheduler_condition_node_scheduled_for_unpublishing
+    // For all other entity types the new derived condition ids are of the form:
+    // -  scheduler_entity_is_scheduled_for_publishing:{type}
+    // -  scheduler_entity_is_scheduled_for_unpublishing:{type}
+    // .
+    $condition_prefix = ($entityTypeId == 'node') ? 'scheduler_condition_node_' : 'scheduler_entity_is_';
+    $condition_suffix = ($entityTypeId == 'node') ? '' : ":$entityTypeId";
+    $assert = $this->assertSession();
+
+    // Create a reaction rule to display a message when an entity is updated and
+    // is not scheduled for publishing.
+    $rule5 = $this->expressionManager->createRule();
+    $rule5->addCondition("{$condition_prefix}scheduled_for_publishing{$condition_suffix}",
+      ContextConfig::create()->map('entity', "$entityTypeId")->negateResult()
+    );
+    $message5 = "RULES message 5. This $entityTypeId is not scheduled for publishing.";
+    $rule5->addAction('rules_system_message', ContextConfig::create()
+      ->setValue('message', $message5)
+      ->setValue('type', 'status')
+      );
+    $config_entity = $this->rulesStorage->create([
+      'id' => 'rule5',
+      'events' => [['event_name' => "rules_entity_update:$entityTypeId"]],
+      'expression' => $rule5->getConfiguration(),
+    ]);
+    $config_entity->save();
+
+    // Create a reaction rule to display a message when an entity is updated and
+    // is not scheduled for unpublishing.
+    $rule6 = $this->expressionManager->createRule();
+    $rule6->addCondition("{$condition_prefix}scheduled_for_unpublishing{$condition_suffix}",
+      ContextConfig::create()->map('entity', "$entityTypeId")->negateResult()
+    );
+    $message6 = "RULES message 6. This $entityTypeId is not scheduled for unpublishing.";
+    $rule6->addAction('rules_system_message', ContextConfig::create()
+      ->setValue('message', $message6)
+      ->setValue('type', 'status')
+      );
+    $config_entity = $this->rulesStorage->create([
+      'id' => 'rule6',
+      'events' => [['event_name' => "rules_entity_update:$entityTypeId"]],
+      'expression' => $rule6->getConfiguration(),
+    ]);
+    $config_entity->save();
+
+    // Create a reaction rule to display a message when an entity is updated and
+    // is scheduled for publishing.
+    $rule7 = $this->expressionManager->createRule();
+    $rule7->addCondition("{$condition_prefix}scheduled_for_publishing{$condition_suffix}",
+      ContextConfig::create()->map('entity', "$entityTypeId")
+    );
+    $message7 = "RULES message 7. This $entityTypeId is scheduled for publishing.";
+    $rule7->addAction('rules_system_message', ContextConfig::create()
+      ->setValue('message', $message7)
+      ->setValue('type', 'status')
+      );
+    $config_entity = $this->rulesStorage->create([
+      'id' => 'rule7',
+      'events' => [['event_name' => "rules_entity_update:$entityTypeId"]],
+      'expression' => $rule7->getConfiguration(),
+    ]);
+    $config_entity->save();
+
+    // Create a reaction rule to display a message when an entity is updated and
+    // is scheduled for unpublishing.
+    $rule8 = $this->expressionManager->createRule();
+    $rule8->addCondition("{$condition_prefix}scheduled_for_unpublishing{$condition_suffix}",
+      ContextConfig::create()->map('entity', "$entityTypeId")
+    );
+    $message8 = "RULES message 8. This $entityTypeId is scheduled for unpublishing.";
+    $rule8->addAction('rules_system_message', ContextConfig::create()
+      ->setValue('message', $message8)
+      ->setValue('type', 'status')
+      );
+    $config_entity = $this->rulesStorage->create([
+      'id' => 'rule8',
+      'events' => [['event_name' => "rules_entity_update:$entityTypeId"]],
+      'expression' => $rule8->getConfiguration(),
+    ]);
+    $config_entity->save();
+
+    $this->drupalLogin($this->schedulerUser);
+
+    // Create a published entity.
+    $entity = $this->createEntity($entityTypeId, $bundle, [
+      'title' => "Scheduled Conditions - $entityTypeId $bundle",
+      'uid' => $this->schedulerUser->id(),
+      'status' => TRUE,
+    ]);
+
+    // Edit the entity but do not enter any scheduling dates, and check that
+    // only messages 5 and 6 are shown.
+    $this->drupalGet($entity->toUrl('edit-form'));
+    $this->submitForm([], 'Save');
+    $assert->pageTextContains($message5);
+    $assert->pageTextContains($message6);
+    $assert->pageTextNotContains($message7);
+    $assert->pageTextNotContains($message8);
+
+    // Edit the entity, set a publish_on date, and check that message 5 is now
+    // not shown and we get message 7 instead.
+    $edit = [
+      'publish_on[0][value][date]' => date('Y-m-d', strtotime('+1 day', $this->requestTime)),
+      'publish_on[0][value][time]' => date('H:i:s', strtotime('+1 day', $this->requestTime)),
+    ];
+    $this->drupalGet($entity->toUrl('edit-form'));
+    $this->submitForm($edit, 'Save');
+    $assert->pageTextNotContains($message5);
+    $assert->pageTextContains($message6);
+    $assert->pageTextContains($message7);
+    $assert->pageTextNotContains($message8);
+
+    // Edit the entity again, set an unpublish_on date, and check that message 6
+    // is now not shown and we get message 8 instead.
+    $edit = [
+      'unpublish_on[0][value][date]' => date('Y-m-d', strtotime('+2 day', $this->requestTime)),
+      'unpublish_on[0][value][time]' => date('H:i:s', strtotime('+2 day', $this->requestTime)),
+    ];
+    $this->drupalGet($entity->toUrl('edit-form'));
+    $this->submitForm($edit, 'Save');
+    $assert->pageTextNotContains($message5);
+    $assert->pageTextNotContains($message6);
+    $assert->pageTextContains($message7);
+    $assert->pageTextContains($message8);
+
+  }
+
+}
diff --git a/web/modules/scheduler/scheduler_rules_integration/tests/src/Functional/SchedulerRulesEventsTest.php b/web/modules/scheduler/scheduler_rules_integration/tests/src/Functional/SchedulerRulesEventsTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..62e1666c9997669759b50464857461b2a19fb880
--- /dev/null
+++ b/web/modules/scheduler/scheduler_rules_integration/tests/src/Functional/SchedulerRulesEventsTest.php
@@ -0,0 +1,246 @@
+<?php
+
+namespace Drupal\Tests\scheduler_rules_integration\Functional;
+
+use Drupal\rules\Context\ContextConfig;
+use Drupal\Tests\scheduler\Functional\SchedulerBrowserTestBase;
+
+/**
+ * Tests the six events that Scheduler provides for use in Rules module.
+ *
+ * phpcs:set Drupal.Arrays.Array lineLimit 140
+ *
+ * @group scheduler_rules_integration
+ */
+class SchedulerRulesEventsTest extends SchedulerBrowserTestBase {
+
+  /**
+   * Additional modules required.
+   *
+   * @var array
+   */
+  protected static $modules = ['scheduler_rules_integration'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+
+    $this->rulesStorage = $this->container->get('entity_type.manager')->getStorage('rules_reaction_rule');
+    $this->expressionManager = $this->container->get('plugin.manager.rules_expression');
+
+    // Create a reaction rule to display a system message for each of the six
+    // events that Scheduler triggers, for each entity type. The array of data
+    // contains the event name and the text to display.
+    // These rules are all active throughout all of the tests, which makes the
+    // tests stronger, because it will show not only that the correct events are
+    // triggered in the right places, but also that they are not triggered in
+    // the wrong places.
+    $rule_data = [
+      // The first six events are the originals, only dispatched for Nodes.
+      1 => ['scheduler_new_node_is_scheduled_for_publishing_event', 'A new node is created and is scheduled for publishing.'],
+      2 => ['scheduler_existing_node_is_scheduled_for_publishing_event', 'An existing node is saved and is scheduled for publishing.'],
+      3 => ['scheduler_has_published_this_node_event', 'Scheduler has published this node during cron.'],
+      4 => ['scheduler_new_node_is_scheduled_for_unpublishing_event', 'A new node is created and is scheduled for unpublishing.'],
+      5 => ['scheduler_existing_node_is_scheduled_for_unpublishing_event', 'An existing node is saved and is scheduled for unpublishing.'],
+      6 => ['scheduler_has_unpublished_this_node_event', 'Scheduler has unpublished this node during cron.'],
+      // These six events are dispatched only for Media entities.
+      7 => ['scheduler:new_media_is_scheduled_for_publishing', 'A new media item is created and scheduled for publishing.'],
+      8 => ['scheduler:existing_media_is_scheduled_for_publishing', 'An existing media item is saved and scheduled for publishing.'],
+      9 => ['scheduler:media_has_been_published_via_cron', 'Scheduler has published this media item during cron.'],
+      10 => ['scheduler:new_media_is_scheduled_for_unpublishing', 'A new media item is created and scheduled for unpublishing.'],
+      11 => ['scheduler:existing_media_is_scheduled_for_unpublishing', 'An existing media item is saved and scheduled for unpublishing.'],
+      12 => ['scheduler:media_has_been_unpublished_via_cron', 'Scheduler has unpublished this media item during cron.'],
+      // These six events are dispatched only for Commerce Product entities.
+      13 => ['scheduler:new_commerce_product_is_scheduled_for_publishing', 'A new product is created and scheduled for publishing.'],
+      14 => ['scheduler:existing_commerce_product_is_scheduled_for_publishing', 'An existing product is scheduled for publishing.'],
+      15 => ['scheduler:commerce_product_has_been_published_via_cron', 'Scheduler has published this product during cron.'],
+      16 => ['scheduler:new_commerce_product_is_scheduled_for_unpublishing', 'A new product is created and scheduled for unpublishing.'],
+      17 => ['scheduler:existing_commerce_product_is_scheduled_for_unpublishing', 'An existing product is scheduled for unpublishing.'],
+      18 => ['scheduler:commerce_product_has_been_unpublished_via_cron', 'Scheduler has unpublished this product during cron.'],
+      // These six events are dispatched only for Taxonomy Term entities.
+      19 => ['scheduler:new_taxonomy_term_is_scheduled_for_publishing', 'A new taxonomy term is created and scheduled for publishing.'],
+      20 => ['scheduler:existing_taxonomy_term_is_scheduled_for_publishing', 'An existing taxonomy term is scheduled for publishing.'],
+      21 => ['scheduler:taxonomy_term_has_been_published_via_cron', 'Scheduler has published this taxonomy term during cron.'],
+      22 => ['scheduler:new_taxonomy_term_is_scheduled_for_unpublishing', 'A new taxonomy term is created and scheduled for unpublishing.'],
+      23 => ['scheduler:existing_taxonomy_term_is_scheduled_for_unpublishing', 'An existing taxonomy term is scheduled for unpublishing.'],
+      24 => ['scheduler:taxonomy_term_has_been_unpublished_via_cron', 'Scheduler has unpublished this taxonomy term during cron.'],
+    ];
+
+    $rule = [];
+    foreach ($rule_data as $i => [$event_name, $description]) {
+      $rule[$i] = $this->expressionManager->createRule();
+      $this->message[$i] = 'RULES message ' . $i . '. ' . $description;
+      $rule[$i]->addAction('rules_system_message', ContextConfig::create()
+        ->setValue('message', $this->message[$i])
+        ->setValue('type', 'status')
+        );
+      $config_entity = $this->rulesStorage->create([
+        'id' => 'rule' . $i,
+        'events' => [['event_name' => $event_name]],
+        'expression' => $rule[$i]->getConfiguration(),
+      ]);
+      $config_entity->save();
+    }
+
+    $this->drupalLogin($this->schedulerUser);
+  }
+
+  /**
+   * Check the presence or absence of expected message texts on the page.
+   *
+   * @param string $entityTypeId
+   *   The entity type being tested.
+   * @param array $expectedMessages
+   *   The ids of the messages that should be showing on the current page. All
+   *   other messsages should not be displayed.
+   */
+  public function checkMessages(string $entityTypeId = NULL, array $expectedMessages = []) {
+    // Add the required entity offset to each message id in the expected array.
+    $offset = ['node' => 0, 'media' => 6, 'commerce_product' => 12, 'taxonomy_term' => 18];
+    array_walk($expectedMessages, function (&$item) use ($offset, $entityTypeId) {
+      $item = $item + $offset[$entityTypeId];
+    });
+
+    // Check that all the expected messages are shown.
+    foreach ($expectedMessages as $i) {
+      $this->assertSession()->pageTextContains($this->message[$i]);
+    }
+
+    // Check that none of the other messages are shown.
+    $notExpecting = array_diff(array_keys($this->message), $expectedMessages);
+    foreach ($notExpecting as $i) {
+      $this->assertSession()->pageTextNotContains($this->message[$i]);
+    }
+  }
+
+  /**
+   * Tests that no events are triggered when there are no scheduling dates.
+   *
+   * @dataProvider dataStandardEntityTypes()
+   */
+  public function testRulesEventsNone($entityTypeId, $bundle) {
+    // Add and save an entity without any scheduled dates and check that no
+    // events are triggered.
+    $titleField = $this->titleField($entityTypeId);
+    $title = 'A. Create with no dates';
+    $this->drupalGet($this->entityAddUrl($entityTypeId, $bundle));
+    $this->submitForm(["{$titleField}[0][value]" => $title], 'Save');
+    $this->checkMessages();
+
+    // Edit the entity and check that no events are triggered.
+    $entity = $this->getEntityByTitle($entityTypeId, $title);
+    $this->drupalGet($entity->toUrl('edit-form'));
+    $this->submitForm(["{$titleField}[0][value]" => 'B. Edit with no dates'], 'Save');
+    $this->checkMessages();
+  }
+
+  /**
+   * Tests the three events related to publishing an entity.
+   *
+   * @dataProvider dataStandardEntityTypes()
+   */
+  public function testRulesEventsPublish($entityTypeId, $bundle) {
+    // Allow dates in the past.
+    $this->entityTypeObject($entityTypeId, $bundle)
+      ->setThirdPartySetting('scheduler', 'publish_past_date', 'schedule')->save();
+
+    // Create an entity with a publish-on date, and check that only event 1 is
+    // triggered.
+    $titleField = $this->titleField($entityTypeId);
+    $title = 'C. Create with publish-on date';
+    $edit = [
+      "{$titleField}[0][value]" => $title,
+      'publish_on[0][value][date]' => date('Y-m-d', time() - 60),
+      'publish_on[0][value][time]' => date('H:i:s', time() - 60),
+    ];
+    $this->drupalGet($this->entityAddUrl($entityTypeId, $bundle));
+    $this->submitForm($edit, 'Save');
+    $this->checkMessages($entityTypeId, [1]);
+
+    // Edit the entity and check that only event 2 is triggered.
+    $entity = $this->getEntityByTitle($entityTypeId, $title);
+    $this->drupalGet($entity->toUrl('edit-form'));
+    $this->submitForm(["{$titleField}[0][value]" => 'D. Edit with publish-on date'], 'Save');
+    $this->checkMessages($entityTypeId, [2]);
+
+    // Run cron and check that only event 3 is triggered.
+    $this->cronRun();
+    $this->drupalGet($entity->toUrl());
+    $this->checkMessages($entityTypeId, [3]);
+  }
+
+  /**
+   * Tests the three events related to unpublishing an entity.
+   *
+   * @dataProvider dataStandardEntityTypes()
+   */
+  public function testRulesEventsUnpublish($entityTypeId, $bundle) {
+    // Create an entity with an unpublish-on date, and check that only event 4
+    // is triggered.
+    $titleField = $this->titleField($entityTypeId);
+    $title = 'E. Create with unpublish-on date';
+    $edit = [
+      "{$titleField}[0][value]" => $title,
+      'unpublish_on[0][value][date]' => date('Y-m-d', time() + 5),
+      'unpublish_on[0][value][time]' => date('H:i:s', time() + 5),
+    ];
+    $this->drupalGet($this->entityAddUrl($entityTypeId, $bundle));
+    $this->submitForm($edit, 'Save');
+    $this->checkMessages($entityTypeId, [4]);
+
+    // Edit the entity and check that only event 5 is triggered.
+    $entity = $this->getEntityByTitle($entityTypeId, $title);
+    $this->drupalGet($entity->toUrl('edit-form'));
+    $this->submitForm(["{$titleField}[0][value]" => 'F. Edit with unpublish-on date'], 'Save');
+    $this->checkMessages($entityTypeId, [5]);
+
+    // Delay to ensure that the dates are in the past so that the entity will be
+    // processed during cron, and check that only event 6 is triggered.
+    sleep(6);
+    $this->cronRun();
+    $this->drupalGet($entity->toUrl());
+    $this->checkMessages($entityTypeId, [6]);
+  }
+
+  /**
+   * Tests all six events related to publishing and unpublishing an entity.
+   *
+   * @dataProvider dataStandardEntityTypes()
+   */
+  public function testRulesEventsBoth($entityTypeId, $bundle) {
+    // Allow dates in the past.
+    $this->entityTypeObject($entityTypeId, $bundle)
+      ->setThirdPartySetting('scheduler', 'publish_past_date', 'schedule')->save();
+
+    // Create an entity with both publish-on and unpublish-on dates, and check
+    // that both event 1 and event 4 are triggered.
+    $titleField = $this->titleField($entityTypeId);
+    $title = 'G. Create with both dates';
+    $edit = [
+      "{$titleField}[0][value]" => $title,
+      'publish_on[0][value][date]' => date('Y-m-d', time() - 60),
+      'publish_on[0][value][time]' => date('H:i:s', time() - 60),
+      'unpublish_on[0][value][date]' => date('Y-m-d', time() + 5),
+      'unpublish_on[0][value][time]' => date('H:i:s', time() + 5),
+    ];
+    $this->drupalGet($this->entityAddUrl($entityTypeId, $bundle));
+    $this->submitForm($edit, 'Save');
+    $this->checkMessages($entityTypeId, [1, 4]);
+
+    // Edit the entity and check that only events 2 and 5 are triggered.
+    $entity = $this->getEntityByTitle($entityTypeId, $title);
+    $this->drupalGet($entity->toUrl('edit-form'));
+    $this->submitForm(["{$titleField}[0][value]" => 'H. Edit with both dates'], 'Save');
+    $this->checkMessages($entityTypeId, [2, 5]);
+
+    // Delay to ensure that the dates are in the past so that the entity will be
+    // processed during cron, and assert that events 3, 5 and 6 are triggered.
+    sleep(6);
+    $this->cronRun();
+    $this->drupalGet($entity->toUrl());
+    $this->checkMessages($entityTypeId, [3, 5, 6]);
+  }
+
+}
diff --git a/web/modules/scheduler/src/Access/ScheduledListAccess.php b/web/modules/scheduler/src/Access/ScheduledListAccess.php
deleted file mode 100644
index f5cac5e7b4c800402a202d831269b8bf4357325f..0000000000000000000000000000000000000000
--- a/web/modules/scheduler/src/Access/ScheduledListAccess.php
+++ /dev/null
@@ -1,60 +0,0 @@
-<?php
-
-namespace Drupal\scheduler\Access;
-
-use Drupal\Core\Access\AccessCheckInterface;
-use Drupal\Core\Access\AccessResult;
-use Drupal\Core\Session\AccountInterface;
-use Symfony\Component\Routing\Route;
-use Drupal\Core\Routing\RouteMatchInterface;
-
-/**
- * Checks access for displaying the scheduler list of scheduled nodes.
- */
-class ScheduledListAccess implements AccessCheckInterface {
-
-  /**
-   * The current route match.
-   *
-   * @var \Drupal\Core\Routing\RouteMatchInterface
-   */
-  protected $routeMatch;
-
-  /**
-   * Constructs a ScheduledListAccess object.
-   *
-   * @param \Drupal\Core\Routing\RouteMatchInterface $route_match
-   *   The current route match.
-   */
-  public function __construct(RouteMatchInterface $route_match) {
-    $this->routeMatch = $route_match;
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function applies(Route $route) {
-    return $route->hasRequirement('_access_scheduler_content');
-  }
-
-  /**
-   * Determine if the $account has access to the scheduled content list.
-   *
-   * The result will vary depending on whether the page being viewed is the user
-   * profile page or the scheduled content admin overview.
-   */
-  public function access(AccountInterface $account) {
-    // When viewing a user profile page routeMatch->getRawParameter('user')
-    // returns the user's id. If not on a user page it returns NULL silently.
-    $viewing_own_tab = $this->routeMatch->getRawParameter('user') == $account->id();
-
-    // Users with 'schedule publishing of nodes' can see their own scheduled
-    // content via a tab on their user page. Users with 'view scheduled content'
-    // will be able to access the 'scheduled' tab for any user, and also access
-    // the scheduled content overview page.
-    $allowed = $account->hasPermission('view scheduled content')
-      || ($viewing_own_tab && $account->hasPermission('schedule publishing of nodes'));
-    return $allowed ? AccessResult::allowed() : AccessResult::forbidden();
-  }
-
-}
diff --git a/web/modules/scheduler/src/Access/SchedulerRouteAccess.php b/web/modules/scheduler/src/Access/SchedulerRouteAccess.php
new file mode 100644
index 0000000000000000000000000000000000000000..31f6d63a672a982a5196ecfdf5231ce27cdcda23
--- /dev/null
+++ b/web/modules/scheduler/src/Access/SchedulerRouteAccess.php
@@ -0,0 +1,53 @@
+<?php
+
+namespace Drupal\scheduler\Access;
+
+use Drupal\Core\Access\AccessResult;
+use Drupal\Core\Session\AccountInterface;
+use Drupal\Core\Routing\RouteMatchInterface;
+use Drupal\user\Entity\User;
+
+/**
+ * Sets access for specific scheduler views routes.
+ */
+class SchedulerRouteAccess {
+
+  /**
+   * Provides custom access checks for the scheduled views on the user page.
+   *
+   * A user is given access if either of the following conditions are met:
+   * - they are viewing their own page and they have the permission to schedule
+   * content or view scheduled content of the required type.
+   * - they are viewing another user's page and they have permission to view
+   * user profiles and view scheduled content, and the user they are viewing has
+   * permission to schedule content or view scheduled content.
+   *
+   * @param \Drupal\Core\Session\AccountInterface $account
+   *   The currently logged in account.
+   * @param \Drupal\Core\Routing\RouteMatchInterface $route_match
+   *   The current route match.
+   */
+  public function access(AccountInterface $account, RouteMatchInterface $route_match) {
+    $user_being_viewed = $route_match->getParameter('user');
+    $viewing_own_page = $user_being_viewed == $account->id();
+
+    // getUserPageViewRoutes() returns an array of user page view routes, keyed
+    // on the entity id. Use this to get the entity id.
+    $scheduler_manager = \Drupal::service('scheduler.manager');
+    $entityTypeId = array_search($route_match->getRouteName(), $scheduler_manager->getUserPageViewRoutes());
+    $viewing_permission_name = $scheduler_manager->permissionName($entityTypeId, 'view');
+    $scheduling_permission_name = $scheduler_manager->permissionName($entityTypeId, 'schedule');
+
+    if ($viewing_own_page && ($account->hasPermission($viewing_permission_name) || $account->hasPermission($scheduling_permission_name))) {
+      return AccessResult::allowed();
+    }
+    if (!$viewing_own_page && $account->hasPermission($viewing_permission_name) && $account->hasPermission('access user profiles')) {
+      $other_user = User::load($user_being_viewed);
+      if ($other_user && ($other_user->hasPermission($viewing_permission_name) || $other_user->hasPermission($scheduling_permission_name))) {
+        return AccessResult::allowed();
+      }
+    }
+    return AccessResult::forbidden();
+  }
+
+}
diff --git a/web/modules/scheduler/src/Annotation/SchedulerPlugin.php b/web/modules/scheduler/src/Annotation/SchedulerPlugin.php
new file mode 100644
index 0000000000000000000000000000000000000000..ce0fd2b09480792e7fa49e339c96721817524448
--- /dev/null
+++ b/web/modules/scheduler/src/Annotation/SchedulerPlugin.php
@@ -0,0 +1,119 @@
+<?php
+
+namespace Drupal\scheduler\Annotation;
+
+use Drupal\Component\Annotation\Plugin;
+
+/**
+ * Annotation class for scheduler entity plugins.
+ *
+ * @package Drupal\scheduler\Annotation
+ *
+ * @Annotation
+ */
+class SchedulerPlugin extends Plugin {
+
+  /**
+   * The internal id / machine name of the plugin.
+   *
+   * @var string
+   */
+  public $id;
+
+  /**
+   * The readable name of the plugin.
+   *
+   * @var \Drupal\Core\Annotation\Translation
+   *
+   * @ingroup plugin_translatable
+   */
+  public $label;
+
+  /**
+   * Description of plugin.
+   *
+   * @var \Drupal\Core\Annotation\Translation
+   *
+   * @ingroup plugin_translatable
+   */
+  public $description;
+
+  /**
+   * The entity type.
+   *
+   * @var string
+   */
+  public $entityType;
+
+  /**
+   * The name of the type/bundle field.
+   *
+   * @var string
+   */
+  public $typeFieldName;
+
+  /**
+   * Module name that plugin requires.
+   *
+   * @var string
+   */
+  public $dependency;
+
+  /**
+   * The Form ID of the devel generate form (optional).
+   *
+   * @var string
+   */
+  public $develGenerateForm = '';
+
+  /**
+   * The route of the collection overview page.
+   *
+   * The default is entity.{$entityType}.collection so this property only needs
+   * to be specified if that route is not the correct one.
+   *
+   * @var string
+   */
+  public $collectionRoute;
+
+  /**
+   * The route of the scheduled view on the user profile page (optional).
+   *
+   * @var string
+   */
+  public $userViewRoute = '';
+
+  /**
+   * The event class for Scheduler events relating to activity on the entity.
+   *
+   * This is optional, and if not specified, will default to the standard class
+   *   \Drupal\scheduler\Event\Scheduler{EntityType}Events
+   * The class must be in UpperCamelCase with no underscores, so if entityType
+   * contains underscores then this property must be specified. The convention
+   * in this case is to convert each word to upper case and remove underscores.
+   *
+   * @var string
+   */
+  public $schedulerEventClass;
+
+  /**
+   * The name of the publish action for the entity type (optional).
+   *
+   * This is used when the action name does not match the default pattern of
+   * {entity type id}_publish_action.
+   *
+   * @var string
+   */
+  public $publishAction;
+
+  /**
+   * The name of the unpublish action for the entity type (optional).
+   *
+   * This is used when the action name does not match the default pattern of
+   * {entity type id}_unpublish_action.
+   *
+   * @var string
+   */
+  public $unpublishAction;
+
+}
diff --git a/web/modules/scheduler/src/Commands/SchedulerCommands.php b/web/modules/scheduler/src/Commands/SchedulerCommands.php
index 0d73d2b720f262fb19e20420f83d09935d88cdd2..83e2a5c5403391fc1a63b26a98cc7d071b5719f0 100644
--- a/web/modules/scheduler/src/Commands/SchedulerCommands.php
+++ b/web/modules/scheduler/src/Commands/SchedulerCommands.php
@@ -5,6 +5,7 @@
 use Drupal\Core\Messenger\MessengerInterface;
 use Drupal\scheduler\SchedulerManager;
 use Drush\Commands\DrushCommands;
+use Drush\Utils\StringUtils;
 
 /**
  * Drush 9 Scheduler commands for Drupal Core 8.4+.
@@ -61,4 +62,35 @@ public function cron(array $options = ['nomsg' => NULL, 'nolog' => NULL]) {
     $options['nomsg'] ? NULL : $this->messenger->addMessage(dt('Scheduler lightweight cron completed.'));
   }
 
+  /**
+   * Entity Update - add Scheduler fields for entities covered by plugins.
+   *
+   * Use the standard drush parameter -q for quiet mode (no terminal output).
+   *
+   * @command scheduler:entity-update
+   * @aliases sch-ent-upd, sch-upd, scheduler-entity-update
+   */
+  public function entityUpdate() {
+    $result = $this->schedulerManager->entityUpdate();
+    $updated = $result ? implode(', ', $result) : dt('nothing to update');
+    $this->messenger->addMessage(dt('Scheduler entity update - @updated', ['@updated' => $updated]));
+  }
+
+  /**
+   * Entity Revert - remove Scheduler fields and third-party-settings.
+   *
+   * Use the standard drush parameter -q for quiet mode (no terminal output).
+   *
+   * @option types A comma-delimited list of entity type ids. Default is all
+   *    entity types that need reverting.
+   *
+   * @command scheduler:entity-revert
+   * @aliases sch-ent-rev, sch-rev, scheduler-entity-revert
+   */
+  public function entityRevert(array $options = ['types' => '']) {
+    $result = $this->schedulerManager->entityRevert(StringUtils::csvToArray($options['types']));
+    $reverted = $result ? implode(', ', $result) : dt('nothing to do');
+    $this->messenger->addMessage(dt('Scheduler entity revert - @reverted', ['@reverted' => $reverted]));
+  }
+
 }
diff --git a/web/modules/scheduler/src/Controller/LightweightCronController.php b/web/modules/scheduler/src/Controller/LightweightCronController.php
index 896497c1736b2d520daba40aa492b23db60146a4..9b5fce84ed1b22a9e59217f600d5ccd1dc62d4ea 100644
--- a/web/modules/scheduler/src/Controller/LightweightCronController.php
+++ b/web/modules/scheduler/src/Controller/LightweightCronController.php
@@ -9,7 +9,7 @@
 use Symfony\Component\HttpFoundation\Response;
 
 /**
- * Class LightweightCronController.
+ * Controller for the lightweight cron.
  *
  * @package Drupal\scheduler\Controller
  */
diff --git a/web/modules/scheduler/src/Event/EventBase.php b/web/modules/scheduler/src/Event/EventBase.php
new file mode 100644
index 0000000000000000000000000000000000000000..ac4dd766b2accbd3fcaf1cd32bf0592ee17bf2cd
--- /dev/null
+++ b/web/modules/scheduler/src/Event/EventBase.php
@@ -0,0 +1,38 @@
+<?php
+
+namespace Drupal\scheduler\Event;
+
+// Drupal\Component\EventDispatcher\Event was introduced in Drupal core 9.1 to
+// assist with deprecations and the transition to Symfony 5.
+// @todo Remove this when core 9.1 is the lowest supported version.
+// @see https://www.drupal.org/project/scheduler/issues/3166688
+if (!class_exists('Drupal\Component\EventDispatcher\Event')) {
+  class_alias('Symfony\Component\EventDispatcher\Event', 'Drupal\Component\EventDispatcher\Event');
+}
+
+use Drupal\Component\EventDispatcher\Event;
+use Drupal\Core\Entity\EntityInterface;
+
+/**
+ * Base class on which all Scheduler events are extended.
+ */
+class EventBase extends Event {
+
+  /**
+   * The entity which is being processed.
+   *
+   * @var \Drupal\Core\Entity\EntityInterface
+   */
+  public $entity;
+
+  /**
+   * Constructs the object.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity which is being processed.
+   */
+  public function __construct(EntityInterface $entity) {
+    $this->entity = $entity;
+  }
+
+}
diff --git a/web/modules/scheduler/src/Event/SchedulerCommerceProductEvents.php b/web/modules/scheduler/src/Event/SchedulerCommerceProductEvents.php
new file mode 100644
index 0000000000000000000000000000000000000000..28e53f8b61db8f5e60c2ba2951edcbb0127f5e59
--- /dev/null
+++ b/web/modules/scheduler/src/Event/SchedulerCommerceProductEvents.php
@@ -0,0 +1,96 @@
+<?php
+
+namespace Drupal\scheduler\Event;
+
+/**
+ * Lists the six events dispatched by Scheduler for Commerce Product entities.
+ */
+final class SchedulerCommerceProductEvents {
+
+  /**
+   * The event triggered after a commerce product is published immediately.
+   *
+   * This event allows modules to react after an entity is published
+   * immediately when being saved after editing. The event listener method
+   * receives a \Drupal\Core\Entity\EntityInterface instance.
+   *
+   * @Event
+   *
+   * @see \Drupal\scheduler\Event\SchedulerEvent
+   *
+   * @var string
+   */
+  const PUBLISH_IMMEDIATELY = 'scheduler.commerce_product_publish_immediately';
+
+  /**
+   * The event triggered after a commerce product is published by cron.
+   *
+   * This event allows modules to react after an entity is published by Cron.
+   * The event listener receives a \Drupal\Core\Entity\EntityInterface instance.
+   *
+   * @Event
+   *
+   * @see \Drupal\scheduler\Event\SchedulerEvent
+   *
+   * @var string
+   */
+  const PUBLISH = 'scheduler.commerce_product_publish';
+
+  /**
+   * The event triggered before a commerce product is published immediately.
+   *
+   * This event allows modules to react before an entity is published
+   * immediately when being saved after editing. The event listener method
+   * receives a \Drupal\Core\Entity\EntityInterface instance.
+   *
+   * @Event
+   *
+   * @see \Drupal\scheduler\Event\SchedulerEvent
+   *
+   * @var string
+   */
+  const PRE_PUBLISH_IMMEDIATELY = 'scheduler.commerce_product_pre_publish_immediately';
+
+  /**
+   * The event triggered before a commerce product is published by cron.
+   *
+   * This event allows modules to react before an entity is published by Cron.
+   * The event listener receives a \Drupal\Core\Entity\EntityInterface instance.
+   *
+   * @Event
+   *
+   * @see \Drupal\scheduler\Event\SchedulerEvent
+   *
+   * @var string
+   */
+  const PRE_PUBLISH = 'scheduler.commerce_product_pre_publish';
+
+  /**
+   * The event triggered before a commerce product is unpublished by cron.
+   *
+   * This event allows modules to react before an entity is unpublished by Cron.
+   * The event listener receives a \Drupal\Core\Entity\EntityInterface instance.
+   *
+   * @Event
+   *
+   * @see \Drupal\scheduler\Event\SchedulerEvent
+   *
+   * @var string
+   */
+  const PRE_UNPUBLISH = 'scheduler.commerce_product_pre_unpublish';
+
+  /**
+   * The event triggered after a commerce product is unpublished by cron.
+   *
+   * This event allows modules to react after an entity is unpublished by Cron.
+   * The event listener receives a \Drupal\Core\Entity\EntityInterface instance.
+   *
+   * @Event
+   *
+   * @see \Drupal\scheduler\Event\SchedulerEvent
+   *
+   * @var string
+   */
+  const UNPUBLISH = 'scheduler.commerce_product_unpublish';
+
+}
diff --git a/web/modules/scheduler/src/Event/SchedulerEvent.php b/web/modules/scheduler/src/Event/SchedulerEvent.php
new file mode 100644
index 0000000000000000000000000000000000000000..e9e7fa2c6ac19436d4cd741015ea1f9b942de4f5
--- /dev/null
+++ b/web/modules/scheduler/src/Event/SchedulerEvent.php
@@ -0,0 +1,58 @@
+<?php
+
+namespace Drupal\scheduler\Event;
+
+use Drupal\Core\Entity\EntityInterface;
+
+/**
+ * Wraps a scheduler event for event listeners.
+ */
+class SchedulerEvent extends EventBase {
+
+  /**
+   * Gets the entity object.
+   *
+   * @return \Drupal\Core\Entity\EntityInterface
+   *   The entity object that caused the event to fire.
+   */
+  public function getEntity() {
+    return $this->entity;
+  }
+
+  /**
+   * Sets the entity object.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity object that caused the event to fire.
+   */
+  public function setEntity(EntityInterface $entity) {
+    $this->entity = $entity;
+  }
+
+  /**
+   * Gets the node object (same as the entity object).
+   *
+   * This method is retained for backwards compatibility because implementations
+   * of the event subscriber functions may be using $event->getNode().
+   *
+   * @return \Drupal\Core\Entity\EntityInterface
+   *   The entity object that caused the event to fire.
+   */
+  public function getNode() {
+    return $this->entity;
+  }
+
+  /**
+   * Sets the node object (same as the entity object).
+   *
+   * This method is retained for backwards compatibility because implementations
+   * of the event subscriber functions may be using $event->setNode().
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity object that caused the event to fire.
+   */
+  public function setNode(EntityInterface $entity) {
+    $this->entity = $entity;
+  }
+
+}
diff --git a/web/modules/scheduler/src/Event/SchedulerMediaEvents.php b/web/modules/scheduler/src/Event/SchedulerMediaEvents.php
new file mode 100644
index 0000000000000000000000000000000000000000..397291120f47978e0668e856aa7c73cb227b941d
--- /dev/null
+++ b/web/modules/scheduler/src/Event/SchedulerMediaEvents.php
@@ -0,0 +1,96 @@
+<?php
+
+namespace Drupal\scheduler\Event;
+
+/**
+ * Lists the six events dispatched by Scheduler relating to Media entities.
+ */
+final class SchedulerMediaEvents {
+
+  /**
+   * The event triggered after a media item is published immediately.
+   *
+   * This event allows modules to react after an entity is published
+   * immediately when being saved after editing. The event listener method
+   * receives a \Drupal\Core\Entity\EntityInterface instance.
+   *
+   * @Event
+   *
+   * @see \Drupal\scheduler\Event\SchedulerEvent
+   *
+   * @var string
+   */
+  const PUBLISH_IMMEDIATELY = 'scheduler.media_publish_immediately';
+
+  /**
+   * The event triggered after a media item is published by cron.
+   *
+   * This event allows modules to react after an entity is published by Cron.
+   * The event listener receives a \Drupal\Core\Entity\EntityInterface instance.
+   *
+   * @Event
+   *
+   * @see \Drupal\scheduler\Event\SchedulerEvent
+   *
+   * @var string
+   */
+  const PUBLISH = 'scheduler.media_publish';
+
+  /**
+   * The event triggered before a media item is published immediately.
+   *
+   * This event allows modules to react before an entity is published
+   * immediately when being saved after editing. The event listener method
+   * receives a \Drupal\Core\Entity\EntityInterface instance.
+   *
+   * @Event
+   *
+   * @see \Drupal\scheduler\Event\SchedulerEvent
+   *
+   * @var string
+   */
+  const PRE_PUBLISH_IMMEDIATELY = 'scheduler.media_pre_publish_immediately';
+
+  /**
+   * The event triggered before a media item is published by cron.
+   *
+   * This event allows modules to react before an entity is published by Cron.
+   * The event listener receives a \Drupal\Core\Entity\EntityInterface instance.
+   *
+   * @Event
+   *
+   * @see \Drupal\scheduler\Event\SchedulerEvent
+   *
+   * @var string
+   */
+  const PRE_PUBLISH = 'scheduler.media_pre_publish';
+
+  /**
+   * The event triggered before a media item is unpublished by cron.
+   *
+   * This event allows modules to react before an entity is unpublished by Cron.
+   * The event listener receives a \Drupal\Core\Entity\EntityInterface instance.
+   *
+   * @Event
+   *
+   * @see \Drupal\scheduler\Event\SchedulerEvent
+   *
+   * @var string
+   */
+  const PRE_UNPUBLISH = 'scheduler.media_pre_unpublish';
+
+  /**
+   * The event triggered after a media item is unpublished by cron.
+   *
+   * This event allows modules to react after an entity is unpublished by Cron.
+   * The event listener receives a \Drupal\Core\Entity\EntityInterface instance.
+   *
+   * @Event
+   *
+   * @see \Drupal\scheduler\Event\SchedulerEvent
+   *
+   * @var string
+   */
+  const UNPUBLISH = 'scheduler.media_unpublish';
+
+}
diff --git a/web/modules/scheduler/src/Event/SchedulerNodeEvents.php b/web/modules/scheduler/src/Event/SchedulerNodeEvents.php
new file mode 100644
index 0000000000000000000000000000000000000000..1480f26452523d55c0470054c746c2accc7fc263
--- /dev/null
+++ b/web/modules/scheduler/src/Event/SchedulerNodeEvents.php
@@ -0,0 +1,100 @@
+<?php
+
+namespace Drupal\scheduler\Event;
+
+/**
+ * Lists the six events dispatched by Scheduler relating to Node entities.
+ *
+ * The event names here are the original six, when only nodes were supported.
+ * See SchedulerTaxonomyTermEvents for the generic naming convention to follow
+ * for any new entity plugin implementations.
+ */
+final class SchedulerNodeEvents {
+
+  /**
+   * The event triggered after a node is published immediately.
+   *
+   * This event allows modules to react after an entity is published
+   * immediately when being saved after editing. The event listener method
+   * receives a \Drupal\Core\Entity\EntityInterface instance.
+   *
+   * @Event
+   *
+   * @see \Drupal\scheduler\Event\SchedulerEvent
+   *
+   * @var string
+   */
+  const PUBLISH_IMMEDIATELY = 'scheduler.publish_immediately';
+
+  /**
+   * The event triggered after a node is published by cron.
+   *
+   * This event allows modules to react after an entity is published by Cron.
+   * The event listener receives a \Drupal\Core\Entity\EntityInterface instance.
+   *
+   * @Event
+   *
+   * @see \Drupal\scheduler\Event\SchedulerEvent
+   *
+   * @var string
+   */
+  const PUBLISH = 'scheduler.publish';
+
+  /**
+   * The event triggered before a node is published immediately.
+   *
+   * This event allows modules to react before an entity is published
+   * immediately when being saved after editing. The event listener method
+   * receives a \Drupal\Core\Entity\EntityInterface instance.
+   *
+   * @Event
+   *
+   * @see \Drupal\scheduler\Event\SchedulerEvent
+   *
+   * @var string
+   */
+  const PRE_PUBLISH_IMMEDIATELY = 'scheduler.pre_publish_immediately';
+
+  /**
+   * The event triggered before a node is published by cron.
+   *
+   * This event allows modules to react before an entity is published by Cron.
+   * The event listener receives a \Drupal\Core\Entity\EntityInterface instance.
+   *
+   * @Event
+   *
+   * @see \Drupal\scheduler\Event\SchedulerEvent
+   *
+   * @var string
+   */
+  const PRE_PUBLISH = 'scheduler.pre_publish';
+
+  /**
+   * The event triggered before a node is unpublished by cron.
+   *
+   * This event allows modules to react before an entity is unpublished by Cron.
+   * The event listener receives a \Drupal\Core\Entity\EntityInterface instance.
+   *
+   * @Event
+   *
+   * @see \Drupal\scheduler\Event\SchedulerEvent
+   *
+   * @var string
+   */
+  const PRE_UNPUBLISH = 'scheduler.pre_unpublish';
+
+  /**
+   * The event triggered after a node is unpublished by cron.
+   *
+   * This event allows modules to react after an entity is unpublished by Cron.
+   * The event listener receives a \Drupal\Core\Entity\EntityInterface instance.
+   *
+   * @Event
+   *
+   * @see \Drupal\scheduler\Event\SchedulerEvent
+   *
+   * @var string
+   */
+  const UNPUBLISH = 'scheduler.unpublish';
+
+}
diff --git a/web/modules/scheduler/src/Event/SchedulerTaxonomyTermEvents.php b/web/modules/scheduler/src/Event/SchedulerTaxonomyTermEvents.php
new file mode 100644
index 0000000000000000000000000000000000000000..962121b1ea26f966d400adecd1deff0e1cea1d8e
--- /dev/null
+++ b/web/modules/scheduler/src/Event/SchedulerTaxonomyTermEvents.php
@@ -0,0 +1,96 @@
+<?php
+
+namespace Drupal\scheduler\Event;
+
+/**
+ * Lists the six events dispatched by Scheduler for Taxonomy Term entities.
+ */
+final class SchedulerTaxonomyTermEvents {
+
+  /**
+   * The event triggered after a taxonomy term is published immediately.
+   *
+   * This event allows modules to react after an entity is published
+   * immediately when being saved after editing. The event listener method
+   * receives a \Drupal\Core\Entity\EntityInterface instance.
+   *
+   * @Event
+   *
+   * @see \Drupal\scheduler\Event\SchedulerEvent
+   *
+   * @var string
+   */
+  const PUBLISH_IMMEDIATELY = 'scheduler.taxonomy_term_publish_immediately';
+
+  /**
+   * The event triggered after a taxonomy term is published by cron.
+   *
+   * This event allows modules to react after an entity is published by Cron.
+   * The event listener receives a \Drupal\Core\Entity\EntityInterface instance.
+   *
+   * @Event
+   *
+   * @see \Drupal\scheduler\Event\SchedulerEvent
+   *
+   * @var string
+   */
+  const PUBLISH = 'scheduler.taxonomy_term_publish';
+
+  /**
+   * The event triggered before a taxonomy term is published immediately.
+   *
+   * This event allows modules to react before an entity is published
+   * immediately when being saved after editing. The event listener method
+   * receives a \Drupal\Core\Entity\EntityInterface instance.
+   *
+   * @Event
+   *
+   * @see \Drupal\scheduler\Event\SchedulerEvent
+   *
+   * @var string
+   */
+  const PRE_PUBLISH_IMMEDIATELY = 'scheduler.taxonomy_term_pre_publish_immediately';
+
+  /**
+   * The event triggered before a taxonomy term is published by cron.
+   *
+   * This event allows modules to react before an entity is published by Cron.
+   * The event listener receives a \Drupal\Core\Entity\EntityInterface instance.
+   *
+   * @Event
+   *
+   * @see \Drupal\scheduler\Event\SchedulerEvent
+   *
+   * @var string
+   */
+  const PRE_PUBLISH = 'scheduler.taxonomy_term_pre_publish';
+
+  /**
+   * The event triggered before a taxonomy term is unpublished by cron.
+   *
+   * This event allows modules to react before an entity is unpublished by Cron.
+   * The event listener receives a \Drupal\Core\Entity\EntityInterface instance.
+   *
+   * @Event
+   *
+   * @see \Drupal\scheduler\Event\SchedulerEvent
+   *
+   * @var string
+   */
+  const PRE_UNPUBLISH = 'scheduler.taxonomy_term_pre_unpublish';
+
+  /**
+   * The event triggered after a taxonomy term is unpublished by cron.
+   *
+   * This event allows modules to react after an entity is unpublished by Cron.
+   * The event listener receives a \Drupal\Core\Entity\EntityInterface instance.
+   *
+   * @Event
+   *
+   * @see \Drupal\scheduler\Event\SchedulerEvent
+   *
+   * @var string
+   */
+  const UNPUBLISH = 'scheduler.taxonomy_term_unpublish';
+
+}
diff --git a/web/modules/scheduler/src/Exception/SchedulerEntityTypeNotEnabledException.php b/web/modules/scheduler/src/Exception/SchedulerEntityTypeNotEnabledException.php
new file mode 100644
index 0000000000000000000000000000000000000000..7f7931409ece4dedc0e4ae2d077e2f76be416f09
--- /dev/null
+++ b/web/modules/scheduler/src/Exception/SchedulerEntityTypeNotEnabledException.php
@@ -0,0 +1,14 @@
+<?php
+
+namespace Drupal\scheduler\Exception;
+
+/**
+ * Defines an exception when the entity type is not enabled for Scheduler.
+ *
+ * This exception is thrown when Scheduler attempts to publish or unpublish an
+ * entity during cron but the entity type/bundle is not enabled for Scheduler.
+ *
+ * @see \Drupal\scheduler\SchedulerManager::publish()
+ * @see \Drupal\scheduler\SchedulerManager::unpublish()
+ */
+class SchedulerEntityTypeNotEnabledException extends \Exception {}
diff --git a/web/modules/scheduler/src/Exception/SchedulerMissingDateException.php b/web/modules/scheduler/src/Exception/SchedulerMissingDateException.php
deleted file mode 100644
index eda12bc3a4e034cd9e3a7075f62763164992fec7..0000000000000000000000000000000000000000
--- a/web/modules/scheduler/src/Exception/SchedulerMissingDateException.php
+++ /dev/null
@@ -1,14 +0,0 @@
-<?php
-
-namespace Drupal\scheduler\Exception;
-
-/**
- * Defines an exception when the scheduled date is missing.
- *
- * This exception is thrown when Scheduler attempts to publish or unpublish a
- * node during cron but the date is missing.
- *
- * @see \Drupal\scheduler\SchedulerManager::publish()
- * @see \Drupal\scheduler\SchedulerManager::unpublish()
- */
-class SchedulerMissingDateException extends \Exception {}
diff --git a/web/modules/scheduler/src/Exception/SchedulerNodeTypeNotEnabledException.php b/web/modules/scheduler/src/Exception/SchedulerNodeTypeNotEnabledException.php
deleted file mode 100644
index f26b4a2809126caf2dfcbb3753afd676d6c70953..0000000000000000000000000000000000000000
--- a/web/modules/scheduler/src/Exception/SchedulerNodeTypeNotEnabledException.php
+++ /dev/null
@@ -1,14 +0,0 @@
-<?php
-
-namespace Drupal\scheduler\Exception;
-
-/**
- * Defines an exception when the node type is not enabled for Scheduler.
- *
- * This exception is thrown when Scheduler attempts to publish or unpublish a
- * node during cron but the node type is not enabled for Scheduler.
- *
- * @see \Drupal\scheduler\SchedulerManager::publish()
- * @see \Drupal\scheduler\SchedulerManager::unpublish()
- */
-class SchedulerNodeTypeNotEnabledException extends \Exception {}
diff --git a/web/modules/scheduler/src/Form/SchedulerAdminForm.php b/web/modules/scheduler/src/Form/SchedulerAdminForm.php
index d59fef4a0c29321c8d9e60e15f0bc3e1d0015d2d..ad195646fe3175f34d5211b0d32b80d68867826e 100644
--- a/web/modules/scheduler/src/Form/SchedulerAdminForm.php
+++ b/web/modules/scheduler/src/Form/SchedulerAdminForm.php
@@ -5,6 +5,7 @@
 use Drupal\Core\Datetime\DateFormatterInterface;
 use Drupal\Core\Form\ConfigFormBase;
 use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Url;
 use Symfony\Component\DependencyInjection\ContainerInterface;
 
 /**
@@ -19,12 +20,28 @@ class SchedulerAdminForm extends ConfigFormBase {
    */
   protected $dateFormatter;
 
+  /**
+   * The scheduler manager service.
+   *
+   * @var \Drupal\scheduler\SchedulerManager
+   */
+  protected $schedulerManager;
+
+  /**
+   * Entity Type Manager service object.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
   /**
    * {@inheritdoc}
    */
   public static function create(ContainerInterface $container) {
     $instance = parent::create($container);
     $instance->setDateFormatter($container->get('date.formatter'));
+    $instance->schedulerManager = $container->get('scheduler.manager');
+    $instance->entityTypeManager = $container->get('entity_type.manager');
     return $instance;
   }
 
@@ -56,6 +73,72 @@ protected function getEditableConfigNames() {
    * {@inheritdoc}
    */
   public function buildForm(array $form, FormStateInterface $form_state) {
+    $form['description'] = [
+      '#markup' => '<p>' . $this->t('Most of the Scheduler options are set independently for each entity type and bundle. These can be accessed from the <a href="@link">admin structure</a> page or directly by using the drop-button', ['@link' => Url::fromRoute('system.admin_structure')->toString()]) . '</p>',
+    ];
+
+    // Build a drop-button with links to configure all supported entity types.
+    $plugins = $this->schedulerManager->getPlugins();
+    $links = [];
+    $links[] = [
+      'title' => $this->t('Entity Types'),
+      'url' => Url::fromRoute('system.admin_structure'),
+    ];
+    foreach ($plugins as $entityTypeId => $plugin) {
+      $publishing_enabled_types = $this->schedulerManager->getEnabledTypes($entityTypeId, 'publish');
+      $unpublishing_enabled_types = $this->schedulerManager->getEnabledTypes($entityTypeId, 'unpublish');
+
+      // When a module is enabled via drush there is no automatic clear cache.
+      // Thus moduleHandler()->moduleExists({module}) can return false when
+      // the module is actually enabled. This means we get nothing for
+      // plugin->getTypes() and processing should stop with a useful exception
+      // message, instead of letting Core give a confusing exception later.
+      $bundle_id = $this->entityTypeManager->getDefinition($entityTypeId)->getBundleEntityType();
+      $entity_type_definition = $this->entityTypeManager->getDefinition($bundle_id, FALSE);
+      if (!$entity_type_definition) {
+        throw new \Exception(sprintf('Invalid or empty %s entity type definition for %s module. Do a full cache clear via admin/config/development/performance or drush cr.', $bundle_id, $plugin->dependency()));
+      }
+      $collection_label = (string) ($entity_type_definition->get('label_collection') ?: $entity_type_definition->get('label'));
+
+      // $plugin->getTypes() will usually give a non-empty array of values, but
+      // it can be empty if no default bundle type is defined, or all types have
+      // been deleted.
+      if (!$types = $plugin->getTypes()) {
+        // Some modules may not create a default entity type during installation
+        // or the entity type definitions may have been deleted. This is not an
+        // exception, but will cause an error if we do not stop this loop.
+        $message_parms = [
+          '%module' => $plugin->dependency(),
+          '%plugin_label' => $plugin->label(),
+          '%bundle_id' => $bundle_id,
+        ];
+        $this->logger('scheduler')->notice('No %bundle_id entity types returned by %module module for use in %plugin_label', $message_parms);
+        $links[] = ['title' => "-- $collection_label --  (" . $this->t('no entity types defined') . ')'];
+        continue;
+      }
+
+      $links[] = ['title' => "-- $collection_label --"];
+      foreach ($types as $id => $type) {
+        $text = [];
+        in_array($id, $publishing_enabled_types) ? $text[] = $this->t('publishing') : NULL;
+        in_array($id, $unpublishing_enabled_types) ? $text[] = $this->t('unpublishing') : NULL;
+        $links[] = [
+          'title' => $type->label() . (!empty($text) ? ' (' . implode(', ', $text) . ')' : ''),
+          // Example: the route 'entity.media_type.edit_form' with parameter
+          // media_type={typeid} has url /admin/structure/media/manage/{typeid}.
+          'url' => Url::fromRoute("entity.$bundle_id.edit_form", [$bundle_id => $type->id()]),
+        ];
+      }
+    }
+    $form['entity_type_links'] = [
+      '#type' => 'dropbutton',
+      '#links' => $links,
+    ];
+
+    $form['description2'] = [
+      '#markup' => '<p>' . $this->t('The settings below are common to all entity types.') . '</p>',
+    ];
+
     // Options for setting date-only with default time.
     $form['date_only_fieldset'] = [
       '#type' => 'fieldset',
@@ -73,8 +156,8 @@ public function buildForm(array $form, FormStateInterface $form_state) {
       '#title' => $this->t('Default time'),
       '#default_value' => $this->setting('default_time'),
       '#size' => 20,
-      '#maxlength' => 20,
-      '#description' => $this->t('This is the time that will be used if the user does not enter a value. Format: HH:MM:SS.'),
+      '#maxlength' => 8,
+      '#description' => $this->t('Provide a default time in @format format that will be used if the user does not enter a value.', ['@format' => $this->setting('hide_seconds') ? 'HH:MM' : 'HH:MM:SS']),
       '#states' => [
         'visible' => [
           ':input[name="allow_date_only"]' => ['checked' => TRUE],
@@ -82,6 +165,22 @@ public function buildForm(array $form, FormStateInterface $form_state) {
       ],
     ];
 
+    // Options for configuring the time input field.
+    $form['time_fieldset'] = [
+      '#type' => 'fieldset',
+      '#title' => $this->t('Time settings'),
+      '#collapsible' => FALSE,
+    ];
+    $form['time_fieldset']['hide_seconds'] = [
+      '#type' => 'checkbox',
+      '#title' => $this->t('Hide the seconds.'),
+      '#default_value' => $this->setting('hide_seconds'),
+      '#description' => $this->t('When entering a time, only show hours and minutes in the input field.'),
+    ];
+
+    // Attach library for admin css file.
+    $form['#attached']['library'][] = 'scheduler/admin-css';
+
     return parent::buildForm($form, $form_state);
   }
 
@@ -89,17 +188,19 @@ public function buildForm(array $form, FormStateInterface $form_state) {
    * {@inheritdoc}
    */
   public function validateForm(array &$form, FormStateInterface $form_state) {
+    $hide_seconds = $form_state->getValue(['hide_seconds']);
     // If date-only is enabled then check if a valid default time was entered.
     // Leading zeros and seconds can be omitted, eg. 6:30 is considered valid.
     if ($form_state->getValue(['allow_date_only'])) {
       $default_time = date_parse($form_state->getValue(['default_time']));
-      if ($default_time['error_count']) {
-        $form_state->setErrorByName('default_time', $this->t('The default time should be in the format HH:MM:SS'));
+      if ($default_time['error_count'] || strlen($form_state->getValue(['default_time'])) < 3) {
+        $form_state->setErrorByName('default_time', $this->t('The default time should be in the format @format', ['@format' => $hide_seconds ? 'HH:MM' : 'HH:MM:SS']));
       }
       else {
-        // Insert any possibly omitted leading zeroes.
-        $unix_time = mktime($default_time['hour'], $default_time['minute'], $default_time['second']);
-        $form_state->setValue(['default_time'], $this->dateFormatter->format($unix_time, 'custom', 'H:i:s'));
+        // Insert any possibly omitted leading zeroes. If hiding the seconds
+        // then ignore any entered seconds and save in H:i format.
+        $unix_time = mktime($default_time['hour'], $default_time['minute'], $hide_seconds ? 0 : $default_time['second']);
+        $form_state->setValue(['default_time'], $this->dateFormatter->format($unix_time, 'custom', $hide_seconds ? 'H:i' : 'H:i:s'));
       }
     }
 
@@ -112,6 +213,7 @@ public function submitForm(array &$form, FormStateInterface $form_state) {
     $this->config('scheduler.settings')
       ->set('allow_date_only', $form_state->getValue(['allow_date_only']))
       ->set('default_time', $form_state->getValue('default_time'))
+      ->set('hide_seconds', $form_state->getValue('hide_seconds'))
       ->save();
 
     parent::submitForm($form, $form_state);
diff --git a/web/modules/scheduler/src/Plugin/Derivative/DynamicLocalTasks.php b/web/modules/scheduler/src/Plugin/Derivative/DynamicLocalTasks.php
new file mode 100644
index 0000000000000000000000000000000000000000..0b58e377d91bdf75b6019874fb536df803f5456c
--- /dev/null
+++ b/web/modules/scheduler/src/Plugin/Derivative/DynamicLocalTasks.php
@@ -0,0 +1,161 @@
+<?php
+
+namespace Drupal\scheduler\Plugin\Derivative;
+
+use Drupal\Component\Plugin\Derivative\DeriverBase;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Plugin\Discovery\ContainerDeriverInterface;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Defines dynamic local tasks.
+ *
+ * The local tasks that define tabs for the 'Scheduled' entity views cannot be
+ * hard-coded in the links.task.yml file because if a view is disabled its route
+ * will not exist and this produces an exception "Route X does not exist." The
+ * routes are defined here instead to enable checking that the views are loaded.
+ */
+class DynamicLocalTasks extends DeriverBase implements ContainerDeriverInterface {
+
+  use StringTranslationTrait;
+
+  /**
+   * The entity type manager.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * Creates a DynamicLocalTasks object.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+   *   The entity type manager.
+   */
+  public function __construct(EntityTypeManagerInterface $entity_type_manager) {
+    $this->entityTypeManager = $entity_type_manager;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container, $base_plugin_id) {
+    return new static(
+      $container->get('entity_type.manager')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getDerivativeDefinitions($base_plugin_definition) {
+
+    $view_storage = $this->entityTypeManager->getStorage('view');
+
+    // Define a local task for scheduled content (nodes) view, only when the
+    // view can be loaded, is enabled and that the overview display exists.
+    /** @var \Drupal\views\ViewEntityInterface $view */
+    $view = $view_storage->load('scheduler_scheduled_content');
+    if ($view && $view->status() && $view->getDisplay('overview')) {
+      // The content overview has weight 0 and moderated content has weight 1
+      // so use weight 5 for the scheduled content tab.
+      $this->derivatives['scheduler.scheduled_content'] = [
+        'title' => $this->t('Scheduled content'),
+        'route_name' => 'view.scheduler_scheduled_content.overview',
+        'parent_id' => 'system.admin_content',
+        'weight' => 5,
+      ] + $base_plugin_definition;
+
+      // Core content_moderation module defines an 'overview' local task which
+      // is required when adding additional local tasks. If that module is not
+      // installed then define the tab here. This can be removed if
+      // https://www.drupal.org/project/drupal/issues/3199682 gets committed.
+      // See also scheduler_local_tasks_alter().
+      $this->derivatives['scheduler.content_overview'] = [
+        'title' => $this->t('Overview'),
+        'route_name' => 'system.admin_content',
+        'parent_id' => 'system.admin_content',
+      ] + $base_plugin_definition;
+    }
+
+    $view = $view_storage->load('scheduler_scheduled_media');
+    if ($view && $view->status() && $view->getDisplay('overview')) {
+      // Define local task for scheduled media view.
+      $this->derivatives['scheduler.scheduled_media'] = [
+        'title' => $this->t('Scheduled media'),
+        'route_name' => 'view.scheduler_scheduled_media.overview',
+        'parent_id' => 'entity.media.collection',
+        'weight' => 5,
+      ] + $base_plugin_definition;
+
+      // This task is added so that we get an 'overview' sub-task link alongside
+      // the 'scheduled media' sub-task link.
+      $this->derivatives['scheduler.media_overview'] = [
+        'title' => $this->t('Overview'),
+        'route_name' => 'entity.media.collection',
+        'parent_id' => 'entity.media.collection',
+      ] + $base_plugin_definition;
+    }
+
+    $view = $view_storage->load('scheduler_scheduled_commerce_product');
+    if ($view && $view->status() && $view->getDisplay('overview')) {
+      // The page created by route entity.commerce_product.collection does not
+      // have any tabs or sub-links, because the Commerce Product module does
+      // not specify any local tasks for this route. Therefore we need a
+      // top-level task which just defines the route name as a base route. This
+      // will be used as the parent for the two tabs defined below.
+      $this->derivatives['scheduler.commerce_products'] = [
+        'route_name' => 'entity.commerce_product.collection',
+        'base_route' => 'entity.commerce_product.collection',
+      ] + $base_plugin_definition;
+
+      // Define local task for the scheduled products view.
+      $this->derivatives['scheduler.scheduled_products'] = [
+        'title' => $this->t('Scheduled products'),
+        'route_name' => 'view.scheduler_scheduled_commerce_product.overview',
+        'parent_id' => 'scheduler.local_tasks:scheduler.commerce_products',
+        'weight' => 5,
+      ] + $base_plugin_definition;
+
+      // This task is added so that we get an 'overview' sub-task link alongside
+      // the 'scheduled products' sub-task link.
+      $this->derivatives['scheduler.commerce_product.collection'] = [
+        'title' => $this->t('Overview'),
+        'route_name' => 'entity.commerce_product.collection',
+        'parent_id' => 'scheduler.local_tasks:scheduler.commerce_products',
+      ] + $base_plugin_definition;
+    }
+
+    $view = $view_storage->load('scheduler_scheduled_taxonomy_term');
+    if ($view && $view->status() && $view->getDisplay('overview')) {
+      // In the same manner as for Commerce Products the page created by route
+      // entity.taxonomy_vocabulary.collection does not have tabs or sub-links,
+      // so we need to definine one with a route name and base route here, to be
+      // used as the parent for the two tabs defined below.
+      $this->derivatives['scheduler.taxonomy_collection'] = [
+        'route_name' => 'entity.taxonomy_vocabulary.collection',
+        'base_route' => 'entity.taxonomy_vocabulary.collection',
+      ] + $base_plugin_definition;
+
+      // Define local task for the scheduled taxonomy terms view.
+      $this->derivatives['scheduler.scheduled_taxonomy_terms'] = [
+        'title' => $this->t('Scheduled terms'),
+        'route_name' => 'view.scheduler_scheduled_taxonomy_term.overview',
+        'parent_id' => 'scheduler.local_tasks:scheduler.taxonomy_collection',
+        'weight' => 5,
+      ] + $base_plugin_definition;
+
+      // This task is added so that we get an 'overview' sub-task link alongside
+      // the 'scheduled taxonomy terms' sub-task link.
+      $this->derivatives['scheduler.taxonomy_vocabulary.collection'] = [
+        'title' => $this->t('Overview'),
+        'route_name' => 'entity.taxonomy_vocabulary.collection',
+        'parent_id' => 'scheduler.local_tasks:scheduler.taxonomy_collection',
+      ] + $base_plugin_definition;
+    }
+
+    return parent::getDerivativeDefinitions($base_plugin_definition);
+  }
+
+}
diff --git a/web/modules/scheduler/src/Plugin/Field/FieldWidget/TimestampDatetimeNoDefaultWidget.php b/web/modules/scheduler/src/Plugin/Field/FieldWidget/TimestampDatetimeNoDefaultWidget.php
index 9233b5327344da7ad9221ba09ad1d3ee3e42c4e3..818f096a4079472ca5c585b373f59415bf076f58 100644
--- a/web/modules/scheduler/src/Plugin/Field/FieldWidget/TimestampDatetimeNoDefaultWidget.php
+++ b/web/modules/scheduler/src/Plugin/Field/FieldWidget/TimestampDatetimeNoDefaultWidget.php
@@ -4,7 +4,6 @@
 
 use Drupal\Core\Datetime\DrupalDateTime;
 use Drupal\Core\Datetime\Element\Datetime;
-use Drupal\Core\Datetime\Entity\DateFormat;
 use Drupal\Core\Datetime\Plugin\Field\FieldWidget\TimestampDatetimeWidget;
 use Drupal\Core\Field\FieldItemListInterface;
 use Drupal\Core\Form\FormStateInterface;
@@ -15,7 +14,7 @@
  * @FieldWidget(
  *   id = "datetime_timestamp_no_default",
  *   label = @Translation("Datetime Timestamp for Scheduler"),
- *   description = @Translation("An optional datetime field. Does not provide a default time if left blank. Defined by Scheduler module."),
+ *   description = @Translation("An optional datetime field. Does not fill in the current datetime if left blank. Defined by Scheduler module."),
  *   field_types = {
  *     "timestamp",
  *   }
@@ -28,18 +27,31 @@ class TimestampDatetimeNoDefaultWidget extends TimestampDatetimeWidget {
    */
   public function formElement(FieldItemListInterface $items, $delta, array $element, array &$form, FormStateInterface $form_state) {
     $element = parent::formElement($items, $delta, $element, $form, $form_state);
-    // Remove 'Leave blank to use the time of form submission' which is in the
-    // #description inherited from TimestampDatetimeWidget. The text here is not
-    // used because it is entirely replaced in scheduler_form_node_form_alter()
-    // However the widget is generic and may be used elsewhere in future.
-    $date_format = DateFormat::load('html_date')->getPattern();
-    $time_format = DateFormat::load('html_time')->getPattern();
-    $element['value']['#description'] = $this->t('Format: %format. Leave blank for no date.', ['%format' => Datetime::formatExample($date_format . ' ' . $time_format)]);
+    // The default description "Format: html-date html-time. Leave blank to use
+    // the time of form submission" is inherited from TimestampDatetimeWidget,
+    // but this is entirely replaced in _scheduler_entity_form_alter().
+    // However this widget is generic and may be used elsewhere, so provide
+    // an accurate #description here.
+    $element['value']['#description'] = $this->t('Leave blank for no date.');
 
     // Set the callback function to allow interception of the submitted user
     // input and add the default time if needed. It is too late to try this in
     // function massageFormValues as the validation has already been done.
     $element['value']['#value_callback'] = [$this, 'valueCallback'];
+
+    // Hide the seconds portion of the time input element if that option is set.
+    if (\Drupal::config('scheduler.settings')->get('hide_seconds')) {
+      $element['value']['#date_increment'] = 60;
+      // Some browsers HTML5 date element implementations show the seconds on
+      // pre-existing date values event though the number cannot be changed. To
+      // reduce confusion set the seconds to zero so that the browsers
+      // validation messages only have hours and minutes.
+      $current_value = $element['value']['#default_value'];
+      if (is_object($current_value)) {
+        $current_value->setTime($current_value->format('H'), $current_value->format('i'), 0);
+      }
+    }
+
     return $element;
   }
 
@@ -63,9 +75,19 @@ public static function valueCallback(&$element, $input, FormStateInterface $form
         $input['time'] = $config->get('default_time');
       }
     }
+
+    // Temporarily set the #date_time_element to 'time' because if it had been
+    // hidden in the form by being set to 'none' then the default time set above
+    // would not be used and we would get the current hour and minute instead.
+    $originalTimeElement = $element['#date_time_element'];
+    $element['#date_time_element'] = 'time';
     // Chain on to the standard valueCallback for Datetime as we do not want to
     // duplicate that core code here.
-    return Datetime::valueCallback($element, $input, $form_state);
+    $value = Datetime::valueCallback($element, $input, $form_state);
+    // Restore the #date_time_element.
+    $element['#date_time_element'] = $originalTimeElement;
+
+    return $value;
   }
 
   /**
@@ -74,14 +96,19 @@ public static function valueCallback(&$element, $input, FormStateInterface $form
   public function massageFormValues(array $values, array $form, FormStateInterface $form_state) {
     foreach ($values as &$item) {
       // @todo The structure is different whether access is denied or not, to
-      //   be fixed in https://www.drupal.org/node/2326533.
-      $date = NULL;
+      //   be fixed in core issue https://www.drupal.org/node/2326533.
       if (isset($item['value']) && $item['value'] instanceof DrupalDateTime) {
         $date = $item['value'];
       }
       elseif (isset($item['value']['object']) && $item['value']['object'] instanceof DrupalDateTime) {
         $date = $item['value']['object'];
       }
+      else {
+        // The above is copied from core Datetime/Plugin/Field/FieldWidget
+        // TimestampDatetimeWidget. But here is where we do not return a current
+        // datetime when no value is sent in the form.
+        $date = NULL;
+      }
 
       $item['value'] = $date ? $date->getTimestamp() : NULL;
     }
diff --git a/web/modules/scheduler/src/Plugin/Scheduler/CommerceProductScheduler.php b/web/modules/scheduler/src/Plugin/Scheduler/CommerceProductScheduler.php
new file mode 100644
index 0000000000000000000000000000000000000000..9b313b26a9116130b33af6f87ba1a8ae14e5d8a2
--- /dev/null
+++ b/web/modules/scheduler/src/Plugin/Scheduler/CommerceProductScheduler.php
@@ -0,0 +1,24 @@
+<?php
+
+namespace Drupal\scheduler\Plugin\Scheduler;
+
+use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
+use Drupal\scheduler\SchedulerPluginBase;
+
+/**
+ * Plugin for Commerce Product entity type.
+ *
+ * @package Drupal\Scheduler\Plugin\Scheduler
+ *
+ * @SchedulerPlugin(
+ *  id = "commerce_product_scheduler",
+ *  label = @Translation("Commerce Product Scheduler Plugin"),
+ *  description = @Translation("Provides support for scheduling Commerce Product entities"),
+ *  entityType = "commerce_product",
+ *  dependency = "commerce_product",
+ *  schedulerEventClass = "\Drupal\scheduler\Event\SchedulerCommerceProductEvents",
+ *  publishAction = "commerce_publish_product",
+ *  unpublishAction = "commerce_unpublish_product"
+ * )
+ */
+class CommerceProductScheduler extends SchedulerPluginBase implements ContainerFactoryPluginInterface {}
diff --git a/web/modules/scheduler/src/Plugin/Scheduler/MediaScheduler.php b/web/modules/scheduler/src/Plugin/Scheduler/MediaScheduler.php
new file mode 100644
index 0000000000000000000000000000000000000000..0a8eca03eb44b7c864f548889ccd4e3628d99bf5
--- /dev/null
+++ b/web/modules/scheduler/src/Plugin/Scheduler/MediaScheduler.php
@@ -0,0 +1,23 @@
+<?php
+
+namespace Drupal\scheduler\Plugin\Scheduler;
+
+use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
+use Drupal\scheduler\SchedulerPluginBase;
+
+/**
+ * Plugin for Media entity type.
+ *
+ * @package Drupal\Scheduler\Plugin\Scheduler
+ *
+ * @SchedulerPlugin(
+ *  id = "media_scheduler",
+ *  label = @Translation("Media Scheduler Plugin"),
+ *  description = @Translation("Provides support for scheduling media entities"),
+ *  entityType = "media",
+ *  dependency = "media",
+ *  develGenerateForm = "devel_generate_form_media",
+ *  userViewRoute = "view.scheduler_scheduled_media.user_page",
+ * )
+ */
+class MediaScheduler extends SchedulerPluginBase implements ContainerFactoryPluginInterface {}
diff --git a/web/modules/scheduler/src/Plugin/Scheduler/NodeScheduler.php b/web/modules/scheduler/src/Plugin/Scheduler/NodeScheduler.php
new file mode 100644
index 0000000000000000000000000000000000000000..03db5dd7b660445851d66cc99dafd42b181aab47
--- /dev/null
+++ b/web/modules/scheduler/src/Plugin/Scheduler/NodeScheduler.php
@@ -0,0 +1,24 @@
+<?php
+
+namespace Drupal\scheduler\Plugin\Scheduler;
+
+use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
+use Drupal\scheduler\SchedulerPluginBase;
+
+/**
+ * Plugin for Node entity type.
+ *
+ * @package Drupal\Scheduler\Plugin\Scheduler
+ *
+ * @SchedulerPlugin(
+ *  id = "node_scheduler",
+ *  label = @Translation("Node Scheduler Plugin"),
+ *  description = @Translation("Provides support for scheduling node entities"),
+ *  entityType = "node",
+ *  dependency = "node",
+ *  develGenerateForm = "devel_generate_form_content",
+ *  collectionRoute = "system.admin_content",
+ *  userViewRoute = "view.scheduler_scheduled_content.user_page",
+ * )
+ */
+class NodeScheduler extends SchedulerPluginBase implements ContainerFactoryPluginInterface {}
diff --git a/web/modules/scheduler/src/Plugin/Scheduler/TaxonomyTermScheduler.php b/web/modules/scheduler/src/Plugin/Scheduler/TaxonomyTermScheduler.php
new file mode 100644
index 0000000000000000000000000000000000000000..6b8ad51f8c8e2452ed4d741cdab21bd80b203454
--- /dev/null
+++ b/web/modules/scheduler/src/Plugin/Scheduler/TaxonomyTermScheduler.php
@@ -0,0 +1,24 @@
+<?php
+
+namespace Drupal\scheduler\Plugin\Scheduler;
+
+use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
+use Drupal\scheduler\SchedulerPluginBase;
+
+/**
+ * Plugin for Taxonomy Term entity type.
+ *
+ * @package Drupal\Scheduler\Plugin\Scheduler
+ *
+ * @SchedulerPlugin(
+ *  id = "taxonomy_term_scheduler",
+ *  label = @Translation("Taxonomy Term Scheduler Plugin"),
+ *  description = @Translation("Provides support for scheduling Taxonomy Term entities"),
+ *  entityType = "taxonomy_term",
+ *  dependency = "taxonomy",
+ *  develGenerateForm = "devel_generate_form_term",
+ *  collectionRoute = "entity.taxonomy_vocabulary.collection",
+ *  schedulerEventClass = "\Drupal\scheduler\Event\SchedulerTaxonomyTermEvents",
+ * )
+ */
+class TaxonomyTermScheduler extends SchedulerPluginBase implements ContainerFactoryPluginInterface {}
diff --git a/web/modules/scheduler/src/Plugin/Validation/Constraint/SchedulerPublishOnConstraint.php b/web/modules/scheduler/src/Plugin/Validation/Constraint/SchedulerPublishOnConstraint.php
index a7eafe792de34d13f2522cf8dd928c2e21af686d..36aaab8c22ec0ab4c2c14b18dd58da0c5e0df3ff 100644
--- a/web/modules/scheduler/src/Plugin/Validation/Constraint/SchedulerPublishOnConstraint.php
+++ b/web/modules/scheduler/src/Plugin/Validation/Constraint/SchedulerPublishOnConstraint.php
@@ -10,13 +10,13 @@
  * @Constraint(
  *   id = "SchedulerPublishOn",
  *   label = @Translation("Scheduler publish on", context = "Validation"),
- *   type = "entity:node"
+ *   type = "entity"
  * )
  */
 class SchedulerPublishOnConstraint extends CompositeConstraintBase {
 
   /**
-   * Message shown when publish_on is not the future.
+   * Message shown when publish_on is not in the future.
    *
    * @var string
    */
diff --git a/web/modules/scheduler/src/Plugin/Validation/Constraint/SchedulerPublishOnConstraintValidator.php b/web/modules/scheduler/src/Plugin/Validation/Constraint/SchedulerPublishOnConstraintValidator.php
index b4963e1dd940ca127fa873c059e842633ebbd078..9df8df940cbd82f60131a29390dcf5d84735929e 100644
--- a/web/modules/scheduler/src/Plugin/Validation/Constraint/SchedulerPublishOnConstraintValidator.php
+++ b/web/modules/scheduler/src/Plugin/Validation/Constraint/SchedulerPublishOnConstraintValidator.php
@@ -16,7 +16,7 @@ class SchedulerPublishOnConstraintValidator extends ConstraintValidator {
   public function validate($entity, Constraint $constraint) {
     $publish_on = $entity->value;
     $default_publish_past_date = \Drupal::config('scheduler.settings')->get('default_publish_past_date');
-    $scheduler_publish_past_date = $entity->getEntity()->type->entity->getThirdPartySetting('scheduler', 'publish_past_date', $default_publish_past_date);
+    $scheduler_publish_past_date = \Drupal::service('scheduler.manager')->getThirdPartySetting($entity->getEntity(), 'publish_past_date', $default_publish_past_date);
 
     if ($publish_on && $scheduler_publish_past_date == 'error' && $publish_on < \Drupal::time()->getRequestTime()) {
       $this->context->buildViolation($constraint->messagePublishOnDateNotInFuture)
diff --git a/web/modules/scheduler/src/Plugin/Validation/Constraint/SchedulerUnpublishOnConstraint.php b/web/modules/scheduler/src/Plugin/Validation/Constraint/SchedulerUnpublishOnConstraint.php
index 85230349b54d1fd275b718c3b7d917754b401811..2f577c7acd3bd2965c1569c856dc55cd57eb8ccc 100644
--- a/web/modules/scheduler/src/Plugin/Validation/Constraint/SchedulerUnpublishOnConstraint.php
+++ b/web/modules/scheduler/src/Plugin/Validation/Constraint/SchedulerUnpublishOnConstraint.php
@@ -10,7 +10,7 @@
  * @Constraint(
  *   id = "SchedulerUnpublishOn",
  *   label = @Translation("Scheduler unpublish on", context = "Validation"),
- *   type = "entity:node"
+ *   type = "entity"
  * )
  */
 class SchedulerUnpublishOnConstraint extends CompositeConstraintBase {
@@ -23,11 +23,11 @@ class SchedulerUnpublishOnConstraint extends CompositeConstraintBase {
   public $messageUnpublishOnRequiredIfPublishOnEntered = "If you set a 'publish on' date then you must also set an 'unpublish on' date.";
 
   /**
-   * Message shown when unpublish_on is missing but node is published directly.
+   * Message shown when unpublish_on is missing but trying to save as published.
    *
    * @var string
    */
-  public $messageUnpublishOnRequiredIfPublishing = "Either you must set an 'unpublish on' date or save this node as unpublished.";
+  public $messageUnpublishOnRequiredIfPublishing = "Either you must set an 'unpublish on' date or save as unpublished.";
 
   /**
    * Message shown when unpublish_on is not in the future.
diff --git a/web/modules/scheduler/src/Plugin/Validation/Constraint/SchedulerUnpublishOnConstraintValidator.php b/web/modules/scheduler/src/Plugin/Validation/Constraint/SchedulerUnpublishOnConstraintValidator.php
index 62464450b322fb2cadce8f7399b41970a5d6fb01..c53545fb4be95ffd6372845d4975047bc5e48707 100644
--- a/web/modules/scheduler/src/Plugin/Validation/Constraint/SchedulerUnpublishOnConstraintValidator.php
+++ b/web/modules/scheduler/src/Plugin/Validation/Constraint/SchedulerUnpublishOnConstraintValidator.php
@@ -14,14 +14,20 @@ class SchedulerUnpublishOnConstraintValidator extends ConstraintValidator {
    * {@inheritdoc}
    */
   public function validate($entity, Constraint $constraint) {
+
+    // If the content type is not enabled for unpublishing then exit early.
+    if (!\Drupal::service('scheduler.manager')->getThirdPartySetting($entity->getEntity(), 'unpublish_enable', FALSE)) {
+      return;
+    }
+
     $default_unpublish_required = \Drupal::config('scheduler.settings')->get('default_unpublish_required');
-    $scheduler_unpublish_required = $entity->getEntity()->type->entity->getThirdPartySetting('scheduler', 'unpublish_required', $default_unpublish_required);
+    $scheduler_unpublish_required = \Drupal::service('scheduler.manager')->getThirdPartySetting($entity->getEntity(), 'unpublish_required', $default_unpublish_required);
     $publish_on = $entity->getEntity()->publish_on->value;
     $unpublish_on = $entity->value;
     $status = $entity->getEntity()->status->value;
 
     // When the 'required unpublishing' option is enabled the #required form
-    // attribute cannot set in every case. However a value must be entered if
+    // attribute cannot be set in every case. However a value must be entered if
     // also setting a publish-on date.
     if ($scheduler_unpublish_required && !empty($publish_on) && empty($unpublish_on)) {
       $this->context->buildViolation($constraint->messageUnpublishOnRequiredIfPublishOnEntered)
diff --git a/web/modules/scheduler/src/Plugin/migrate/process/SchedulerHideSeconds.php b/web/modules/scheduler/src/Plugin/migrate/process/SchedulerHideSeconds.php
new file mode 100644
index 0000000000000000000000000000000000000000..5b3bf3d3a7ff91dfb9707fc6acf68538ebbd3550
--- /dev/null
+++ b/web/modules/scheduler/src/Plugin/migrate/process/SchedulerHideSeconds.php
@@ -0,0 +1,33 @@
+<?php
+
+namespace Drupal\scheduler\Plugin\migrate\process;
+
+use Drupal\migrate\MigrateExecutableInterface;
+use Drupal\migrate\ProcessPluginBase;
+use Drupal\migrate\Row;
+
+/**
+ * Provides a process plugin for the hide_seconds global setting.
+ *
+ * The hide_seconds setting does not exist in Drupal 7 because the entire date
+ * and time input format could be specified. However we can use the date format
+ * as source input here and set hide_seconds to true if the seconds were not
+ * included in the full date format.
+ *
+ * @MigrateProcessPlugin(
+ *   id = "scheduler_hide_seconds"
+ * )
+ */
+class SchedulerHideSeconds extends ProcessPluginBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function transform($value, MigrateExecutableInterface $migrate_executable, Row $row, $destination_property) {
+    // The value of hide_seconds is set to true if the source date format does
+    // not contain seconds (lower case 's').
+    $hide_seconds = !strstr($value, 's');
+    return $hide_seconds;
+  }
+
+}
diff --git a/web/modules/scheduler/src/Plugin/views/access/Scheduler.php b/web/modules/scheduler/src/Plugin/views/access/Scheduler.php
index 4255a7fc830a74c5927c73ce2db98ff0ef0e6c1f..9f371c08b86d935fbb989acfc5dcd27ff2f169e3 100644
--- a/web/modules/scheduler/src/Plugin/views/access/Scheduler.php
+++ b/web/modules/scheduler/src/Plugin/views/access/Scheduler.php
@@ -2,58 +2,37 @@
 
 namespace Drupal\scheduler\Plugin\views\access;
 
-use Drupal\Core\Cache\Cache;
-use Drupal\Core\Cache\CacheableDependencyInterface;
 use Drupal\Core\Session\AccountInterface;
 use Drupal\views\Plugin\views\access\AccessPluginBase;
 use Symfony\Component\Routing\Route;
 
 /**
- * Access plugin that provides access control for Scheduler.
+ * Access plugin that provided access control for Scheduler views.
  *
- * @ingroup views_access_plugins
+ * This access plugin has been replaced by SchedulerRouteAccess, and is no
+ * longer needed. However it has to remain (temporarily) as it is used in the
+ * existing view. Deleting this class causes errors before the view can be
+ * updated via update.php. The content below has been reduced to the minimum
+ * necessary to avoid errors before update.php is run.
  *
  * @ViewsAccess(
  *   id = "scheduler",
- *   title = @Translation("Scheduled content access"),
- *   help = @Translation("All Scheduler users can see their own scheduled content via their user page. In addition, if they have 'view scheduled content' permission they will be able to see all scheduled content by all authors."),
+ *   title = @Translation("Scheduled content access. REDUNDANT, DO NOT USE THIS."),
+ *   help = @Translation("NOT USED"),
  * )
  */
-class Scheduler extends AccessPluginBase implements CacheableDependencyInterface {
+class Scheduler extends AccessPluginBase {
 
   /**
    * {@inheritdoc}
    */
   public function access(AccountInterface $account) {
-    return \Drupal::service('access_checker.scheduler_content')->access($account);
   }
 
   /**
    * {@inheritdoc}
    */
   public function alterRouteDefinition(Route $route) {
-    $route->setRequirement('_access_scheduler_content', 'TRUE');
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function getCacheContexts() {
-    return ['user'];
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function getCacheTags() {
-    return [];
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function getCacheMaxAge() {
-    return Cache::PERMANENT;
   }
 
 }
diff --git a/web/modules/scheduler/src/Routing/SchedulerRouteSubscriber.php b/web/modules/scheduler/src/Routing/SchedulerRouteSubscriber.php
new file mode 100644
index 0000000000000000000000000000000000000000..94f084ea730fb0158826d78ad705c34aeda73408
--- /dev/null
+++ b/web/modules/scheduler/src/Routing/SchedulerRouteSubscriber.php
@@ -0,0 +1,27 @@
+<?php
+
+namespace Drupal\scheduler\Routing;
+
+use Drupal\Core\Routing\RouteSubscriberBase;
+use Symfony\Component\Routing\RouteCollection;
+
+/**
+ * Scheduler route subscriber to add custom access for user views.
+ */
+class SchedulerRouteSubscriber extends RouteSubscriberBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function alterRoutes(RouteCollection $collection) {
+    $user_page_routes = \Drupal::service('scheduler.manager')->getUserPageViewRoutes();
+    foreach ($user_page_routes as $user_route) {
+      if ($route = $collection->get($user_route)) {
+        $requirements = $route->getRequirements();
+        $requirements['_custom_access'] = '\Drupal\scheduler\Access\SchedulerRouteAccess::access';
+        $route->setRequirements($requirements);
+      }
+    }
+  }
+
+}
diff --git a/web/modules/scheduler/src/SchedulerEvent.php b/web/modules/scheduler/src/SchedulerEvent.php
index b5d8fac9b9ff7b8f08c32e12f85d76ee3ce6f412..7faa2c4f5be8c385725a12ba81a4f22e57fe2c95 100644
--- a/web/modules/scheduler/src/SchedulerEvent.php
+++ b/web/modules/scheduler/src/SchedulerEvent.php
@@ -1,50 +1,24 @@
 <?php
 
-namespace Drupal\scheduler;
-
-use Drupal\Core\Entity\EntityInterface;
-use Symfony\Component\EventDispatcher\Event;
-
 /**
- * Wraps a scheduler event for event listeners.
+ * @file
+ * Class alias for Drupal\scheduler\SchedulerEvent.
  */
-class SchedulerEvent extends Event {
-
-  /**
-   * Node object.
-   *
-   * @var \Drupal\Core\Entity\EntityInterface
-   */
-  protected $node;
-
-  /**
-   * Constructs a scheduler event object.
-   *
-   * @param \Drupal\Core\Entity\EntityInterface $node
-   *   The node object that caused the event to fire.
-   */
-  public function __construct(EntityInterface $node) {
-    $this->node = $node;
-  }
 
-  /**
-   * Gets node object.
-   *
-   * @return \Drupal\Core\Entity\EntityInterface
-   *   The node object that caused the event to fire.
-   */
-  public function getNode() {
-    return $this->node;
-  }
-
-  /**
-   * Sets the node object.
-   *
-   * @param \Drupal\Core\Entity\EntityInterface $node
-   *   The node object that caused the event to fire.
-   */
-  public function setNode(EntityInterface $node) {
-    $this->node = $node;
-  }
+/**
+ * Create event class alias to maintain backwards-compatibility.
+ *
+ * The original event classes, named Drupal\scheduler\SchedulerEvent and
+ * Drupal\scheduler\SchedulerEvents must remain for backwards-compatibility
+ * with existing implementations of event subscribers for Node events. The
+ * namespace should have been Drupal\scheduler\Event and all the event-related
+ * files stored in a src/Event folder, but instead they were just in /src.
+ *
+ * Now that Scheduler supports non-node entities and each type has to have its
+ * own specific event class named 'Scheduler{Type}Events', they can be moved
+ * into a Drupal\scheduler\Event namespace, with all event files being stored in
+ * a src/Event folder. These two aliases, for the original node events, ensure
+ * that any existing event subscribers will continue work unchnaged.
+ */
 
-}
+class_alias('Drupal\scheduler\Event\SchedulerEvent', 'Drupal\scheduler\SchedulerEvent');
diff --git a/web/modules/scheduler/src/SchedulerEvents.php b/web/modules/scheduler/src/SchedulerEvents.php
index 404d5357e4abec63f3f2e2038b0a58865e66a478..4a3002aae72e6c598fa375d896c57a7dce7be7f4 100644
--- a/web/modules/scheduler/src/SchedulerEvents.php
+++ b/web/modules/scheduler/src/SchedulerEvents.php
@@ -1,98 +1,24 @@
 <?php
 
-namespace Drupal\scheduler;
-
 /**
- * Contains all events dispatched by Scheduler.
+ * @file
+ * Class alias for Drupal\scheduler\SchedulerEvents.
  */
-final class SchedulerEvents {
-
-  /**
-   * The event triggered after a node is published immediately.
-   *
-   * This event allows modules to react after a node is published immediately.
-   * The event listener method receives a \Drupal\Core\Entity\EntityInterface
-   * instance.
-   *
-   * @Event
-   *
-   * @see \Drupal\scheduler\SchedulerEvent
-   *
-   * @var string
-   */
-  const PUBLISH_IMMEDIATELY = 'scheduler.publish_immediately';
-
-  /**
-   * The event triggered after a node is published via cron.
-   *
-   * This event allows modules to react after a node is published. The event
-   * listener method receives a \Drupal\Core\Entity\EntityInterface instance.
-   *
-   * @Event
-   *
-   * @see \Drupal\scheduler\SchedulerEvent
-   *
-   * @var string
-   */
-  const PUBLISH = 'scheduler.publish';
-
-  /**
-   * The event triggered before a node is published immediately.
-   *
-   * This event allows modules to react before a node is published immediately.
-   * The event listener method receives a \Drupal\Core\Entity\EntityInterface
-   * instance.
-   *
-   * @Event
-   *
-   * @see \Drupal\scheduler\SchedulerEvent
-   *
-   * @var string
-   */
-  const PRE_PUBLISH_IMMEDIATELY = 'scheduler.pre_publish_immediately';
 
-  /**
-   * The event triggered before a node is published via cron.
-   *
-   * This event allows modules to react before a node is published. The event
-   * listener method receives a \Drupal\Core\Entity\EntityInterface
-   * instance.
-   *
-   * @Event
-   *
-   * @see \Drupal\scheduler\SchedulerEvent
-   *
-   * @var string
-   */
-  const PRE_PUBLISH = 'scheduler.pre_publish';
-
-  /**
-   * The event triggered before a node is unpublished via cron.
-   *
-   * This event allows modules to react before a node is unpublished. The
-   * event listener method receives a \Drupal\Core\Entity\EntityInterface
-   * instance.
-   *
-   * @Event
-   *
-   * @see \Drupal\scheduler\SchedulerEvent
-   *
-   * @var string
-   */
-  const PRE_UNPUBLISH = 'scheduler.pre_unpublish';
-
-  /**
-   * The event triggered after a node is unpublished via cron.
-   *
-   * This event allows modules to react after a node is unpublished. The event
-   * listener method receives a \Drupal\Core\Entity\EntityInterface instance.
-   *
-   * @Event
-   *
-   * @see \Drupal\scheduler\SchedulerEvent
-   *
-   * @var string
-   */
-  const UNPUBLISH = 'scheduler.unpublish';
+/**
+ * Create event class alias to maintain backwards-compatibility.
+ *
+ * The original event classes, named Drupal\scheduler\SchedulerEvent and
+ * Drupal\scheduler\SchedulerEvents must remain for backwards-compatibility
+ * with existing implementations of event subscribers for Node events. The
+ * namespace should have been Drupal\scheduler\Event and all the event-related
+ * files stored in a src/Event folder, but instead they were just in /src.
+ *
+ * Now that Scheduler supports non-node entities and each type has to have its
+ * own specific event class named 'Scheduler{Type}Events', they can be moved
+ * into a Drupal\scheduler\Event namespace, with all event files being stored in
+ * a src/Event folder. These two aliases, for the original node events, ensure
+ * that any existing event subscribers will continue work unchnaged.
+ */
 
-}
+class_alias('Drupal\scheduler\Event\SchedulerNodeEvents', 'Drupal\scheduler\SchedulerEvents');
diff --git a/web/modules/scheduler/src/SchedulerManager.php b/web/modules/scheduler/src/SchedulerManager.php
index f84b455ae39a1140ac532547ae069ddc8648ed41..cfda7e9bfea2b490082b75a1f0cf9c2a7fd3e17b 100644
--- a/web/modules/scheduler/src/SchedulerManager.php
+++ b/web/modules/scheduler/src/SchedulerManager.php
@@ -3,18 +3,22 @@
 namespace Drupal\scheduler;
 
 use Drupal\Component\Datetime\TimeInterface;
+use Drupal\Component\EventDispatcher\ContainerAwareEventDispatcher;
+use Drupal\Component\EventDispatcher\Event;
+use Drupal\Core\Cache\Cache;
 use Drupal\Core\Config\ConfigFactoryInterface;
+use Drupal\Core\Config\FileStorage;
 use Drupal\Core\Datetime\DateFormatterInterface;
+use Drupal\Core\Entity\EntityChangedInterface;
+use Drupal\Core\Entity\EntityFieldManagerInterface;
+use Drupal\Core\Entity\EntityInterface;
 use Drupal\Core\Entity\EntityTypeManagerInterface;
 use Drupal\Core\Extension\ModuleHandlerInterface;
 use Drupal\Core\Link;
+use Drupal\Core\Messenger\MessengerInterface;
 use Drupal\Core\StringTranslation\StringTranslationTrait;
 use Drupal\Core\Url;
-use Drupal\node\NodeInterface;
-use Drupal\scheduler\Exception\SchedulerMissingDateException;
-use Drupal\scheduler\Exception\SchedulerNodeTypeNotEnabledException;
 use Psr\Log\LoggerInterface;
-use Symfony\Component\EventDispatcher\EventDispatcherInterface;
 
 /**
  * Defines a scheduler manager.
@@ -61,7 +65,7 @@ class SchedulerManager {
   /**
    * The event dispatcher.
    *
-   * @var \Symfony\Component\EventDispatcher\EventDispatcherInterface
+   * @var \Drupal\Component\EventDispatcher\ContainerAwareEventDispatcher
    */
   protected $eventDispatcher;
 
@@ -72,10 +76,33 @@ class SchedulerManager {
    */
   protected $time;
 
+  /**
+   * Entity Field Manager service object.
+   *
+   * @var \Drupal\Core\Entity\EntityFieldManagerInterface
+   */
+  private $entityFieldManager;
+
+  /**
+   * Scheduler Plugin Manager service object.
+   *
+   * @var SchedulerPluginManager
+   */
+  private $pluginManager;
+
   /**
    * Constructs a SchedulerManager object.
    */
-  public function __construct(DateFormatterInterface $dateFormatter, LoggerInterface $logger, ModuleHandlerInterface $moduleHandler, EntityTypeManagerInterface $entityTypeManager, ConfigFactoryInterface $configFactory, EventDispatcherInterface $eventDispatcher, TimeInterface $time) {
+  public function __construct(DateFormatterInterface $dateFormatter,
+                              LoggerInterface $logger,
+                              ModuleHandlerInterface $moduleHandler,
+                              EntityTypeManagerInterface $entityTypeManager,
+                              ConfigFactoryInterface $configFactory,
+                              ContainerAwareEventDispatcher $eventDispatcher,
+                              TimeInterface $time,
+                              EntityFieldManagerInterface $entityFieldManager,
+                              SchedulerPluginManager $pluginManager
+  ) {
     $this->dateFormatter = $dateFormatter;
     $this->logger = $logger;
     $this->moduleHandler = $moduleHandler;
@@ -83,184 +110,333 @@ public function __construct(DateFormatterInterface $dateFormatter, LoggerInterfa
     $this->configFactory = $configFactory;
     $this->eventDispatcher = $eventDispatcher;
     $this->time = $time;
+    $this->entityFieldManager = $entityFieldManager;
+    $this->pluginManager = $pluginManager;
   }
 
   /**
-   * Publish scheduled nodes.
+   * Dispatch a Scheduler event.
    *
-   * @return bool
-   *   TRUE if any node has been published, FALSE otherwise.
+   * All Scheduler events should be dispatched through this common function.
    *
-   * @throws \Drupal\scheduler\Exception\SchedulerMissingDateException
-   * @throws \Drupal\scheduler\Exception\SchedulerNodeTypeNotEnabledException
+   * Drupal 8.8 and 8.9 use Symfony 3.4 and from Drupal 9.0 the Symfony version
+   * is 4.4. Starting with Symfony 4.3 the signature of the event dispatcher
+   * function has the parameters swapped round, the event object is first,
+   * followed by the event name string. At 9.0 the existing signature has to be
+   * used but from 9.1 the parameters must be switched.
+   *
+   * @param \Drupal\Component\EventDispatcher\Event $event
+   *   The event object.
+   * @param string $event_name
+   *   The text name for the event.
+   *
+   * @see https://www.drupal.org/project/scheduler/issues/3166688
    */
-  public function publish() {
-    $result = FALSE;
-    $action = 'publish';
-
-    // Select all nodes of the types that are enabled for scheduled publishing
-    // and where publish_on is less than or equal to the current time.
-    $nids = [];
-    $scheduler_enabled_types = array_keys(_scheduler_get_scheduler_enabled_node_types($action));
-    if (!empty($scheduler_enabled_types)) {
-      $query = $this->entityTypeManager->getStorage('node')->getQuery()
-        ->exists('publish_on')
-        ->condition('publish_on', $this->time->getRequestTime(), '<=')
-        ->condition('type', $scheduler_enabled_types, 'IN')
-        ->latestRevision()
-        ->sort('publish_on')
-        ->sort('nid');
-      // Disable access checks for this query.
-      // @see https://www.drupal.org/node/2700209
-      $query->accessCheck(FALSE);
-      $nids = $query->execute();
-    }
-
-    // Allow other modules to add to the list of nodes to be published.
-    $nids = array_unique(array_merge($nids, $this->nidList($action)));
-
-    // Allow other modules to alter the list of nodes to be published.
-    $this->moduleHandler->alter('scheduler_nid_list', $nids, $action);
-
-    // In 8.x the entity translations are all associated with one node id
-    // unlike 7.x where each translation was a separate node. This means that
-    // the list of node ids returned above may have some translations that need
-    // processing now and others that do not.
-    /** @var \Drupal\node\NodeInterface[] $nodes */
-    $nodes = $this->loadNodes($nids);
-    foreach ($nodes as $node_multilingual) {
-
-      // The API calls could return nodes of types which are not enabled for
-      // scheduled publishing, so do not process these. This check can be done
-      // once, here, as the setting will be the same for all translations.
-      if (!$node_multilingual->type->entity->getThirdPartySetting('scheduler', 'publish_enable', $this->setting('default_publish_enable'))) {
-        throw new SchedulerNodeTypeNotEnabledException(sprintf("Node %d '%s' will not be published because node type '%s' is not enabled for scheduled publishing", $node_multilingual->id(), $node_multilingual->getTitle(), node_get_type_label($node_multilingual)));
-      }
-
-      $languages = $node_multilingual->getTranslationLanguages();
-      foreach ($languages as $language) {
-        // The object returned by getTranslation() behaves the same as a $node.
-        $node = $node_multilingual->getTranslation($language->getId());
+  public function dispatch(Event $event, string $event_name) {
+    // \Symfony\Component\HttpKernel\Kernel::VERSION will give the symfony
+    // version. However, testing this does not give the required outcome, we
+    // need to test the Drupal core version.
+    // @todo Remove the check when Core 9.1 is the lowest supported version.
+    if (version_compare(\Drupal::VERSION, '9.1', '>=')) {
+      // The new way, with $event first.
+      $this->eventDispatcher->dispatch($event, $event_name);
+    }
+    else {
+      // Replicate the existing dispatch signature.
+      $this->eventDispatcher->dispatch($event_name, $event);
+    }
+  }
 
-        // If the current translation does not have a publish on value, or it is
-        // later than the date we are processing then move on to the next.
-        $publish_on = $node->publish_on->value;
-        if (empty($publish_on) || $publish_on > $this->time->getRequestTime()) {
-          continue;
-        }
+  /**
+   * Dispatches a Scheduler event for an entity.
+   *
+   * This function dispatches a Scheduler event, identified by $event_id, for
+   * the entity type of the provided $entity. Each entity type has its own
+   * events class Scheduler{EntityType}Events, for example SchedulerNodeEvents,
+   * SchedulerMediaEvents, etc. This class contains constants (with names
+   * matching the $event_id parameter) which uniquely define the final event
+   * name string to be dispatched. The actual event object dispatched is always
+   * of class SchedulerEvent.
+   *
+   * The $entity is passed by reference so that any changes made in the event
+   * subscriber implementations are automatically stored and passed forward.
+   *
+   * @param Drupal\Core\Entity\EntityInterface $entity
+   *   The entity object.
+   * @param string $event_id
+   *   The short text id the event, for example 'PUBLISH' or 'PRE_UNPUBLISH'.
+   */
+  public function dispatchSchedulerEvent(EntityInterface &$entity, string $event_id) {
+    // Get the fully named-spaced event class name for the entity type, for use
+    // in the constant() function.
+    $event_class = $this->getPlugin($entity->getEntityTypeId())->schedulerEventClass();
+    $event_name = constant("$event_class::$event_id");
+
+    // Create the event object and dispatch the required event_name.
+    $event = new SchedulerEvent($entity);
+    $this->dispatch($event, $event_name);
+    // Get the entity, as it may have been modified by an event subscriber.
+    $entity = $event->getEntity();
+  }
 
-        // Check that other modules allow the action on this node.
-        if (!$this->isAllowed($node, $action)) {
-          continue;
-        }
+  /**
+   * Handles throwing exceptions.
+   *
+   * @param Drupal\Core\Entity\EntityInterface $entity
+   *   The entity causing the exepction.
+   * @param string $exception_name
+   *   Which exception to throw.
+   * @param string $process
+   *   The process being performed (publish|unpublish).
+   *
+   * @throws \Drupal\scheduler\Exception\SchedulerEntityTypeNotEnabledException
+   */
+  private function throwSchedulerException(EntityInterface $entity, $exception_name, $process) {
+    $plugin = $this->getPlugin($entity->getEntityTypeId());
+
+    // Exception messages are developer-facing and do not need to be translated
+    // from English. So it is accpetable to create words such as "{$process}ed"
+    // and "{$process}ing".
+    switch ($exception_name) {
+      case 'SchedulerEntityTypeNotEnabledException':
+        $message = "'%s' (id %d) was not %s because %s %s '%s' is not enabled for scheduled %s. One of the following hook functions added the id incorrectly: %s. Processing halted";
+        $p1 = $entity->label();
+        $p2 = $entity->id();
+        $p3 = "{$process}ed";
+        $p4 = $entity->getEntityTypeId();
+        $p5 = $plugin->typeFieldName();
+        $p6 = $entity->{$plugin->typeFieldName()}->entity->label();
+        $p7 = "{$process}ing";
+        // Get a list of the hook function implementations, as one of these will
+        // have caused this exception.
+        $hooks = array_merge(
+          $this->getHookImplementations('list', $entity),
+          $this->getHookImplementations('list_alter', $entity)
+        );
+        asort($hooks);
+        $p8 = implode(', ', $hooks);
+        break;
+    }
 
-        // $node->setChangedTime($publish_on) will fail badly if an API call has
-        // removed the date. Trap this as an exception here and give a
-        // meaningful message.
-        // @TODO This will now never be thrown due to the empty(publish_on)
-        // check above to cater for translations. Remove this exception?
-        if (empty($node->publish_on->value)) {
-          $field_definitions = $this->entityTypeManager->getFieldDefinitions('node', $node->getType());
-          $field = (string) $field_definitions['publish_on']->getLabel();
-          throw new SchedulerMissingDateException(sprintf("Node %d '%s' will not be published because field '%s' has no value", $node->id(), $node->getTitle(), $field));
-        }
+    $class = "\\Drupal\\scheduler\\Exception\\$exception_name";
+    throw new $class(sprintf($message, $p1, $p2, $p3, $p4, $p5, $p6, $p7, $p8));
+  }
 
-        // Trigger the PRE_PUBLISH event so that modules can react before the
-        // node is published.
-        $event = new SchedulerEvent($node);
-        $this->eventDispatcher->dispatch(SchedulerEvents::PRE_PUBLISH, $event);
-        $node = $event->getNode();
-
-        // Update 'changed' timestamp.
-        $node->setChangedTime($publish_on);
-        $old_creation_date = $node->getCreatedTime();
-        $msg_extra = '';
-        // If required, set the created date to match published date.
-        if ($node->type->entity->getThirdPartySetting('scheduler', 'publish_touch', $this->setting('default_publish_touch')) ||
-          ($node->getCreatedTime() > $publish_on && $node->type->entity->getThirdPartySetting('scheduler', 'publish_past_date_created', $this->setting('default_publish_past_date_created')))
-        ) {
-          $node->setCreatedTime($publish_on);
-          $msg_extra = $this->t('The previous creation date was @old_creation_date, now updated to match the publishing date.', [
-            '@old_creation_date' => $this->dateFormatter->format($old_creation_date, 'short'),
-          ]);
+  /**
+   * Publish scheduled entities.
+   *
+   * @return bool
+   *   TRUE if any entity has been published, FALSE otherwise.
+   *
+   * @throws \Drupal\scheduler\Exception\SchedulerEntityTypeNotEnabledException
+   */
+  public function publish() {
+    $result = FALSE;
+    $process = 'publish';
+    $plugins = $this->getPlugins();
+
+    foreach ($plugins as $entityTypeId => $plugin) {
+      // Select all entities of the types for this plugin that are enabled for
+      // scheduled publishing and where publish_on is less than or equal to the
+      // current time.
+      $ids = [];
+      $scheduler_enabled_types = $this->getEnabledTypes($entityTypeId, $process);
+
+      if (!empty($scheduler_enabled_types)) {
+        $query = $this->entityTypeManager->getStorage($entityTypeId)->getQuery()
+          ->exists('publish_on')
+          ->condition('publish_on', $this->time->getRequestTime(), '<=')
+          ->condition($plugin->typeFieldName(), $scheduler_enabled_types, 'IN')
+          ->sort('publish_on');
+        // Disable access checks for this query.
+        // @see https://www.drupal.org/node/2700209
+        $query->accessCheck(FALSE);
+        // If the entity type is revisionable then make sure we look for the
+        // latest revision. This is important for moderated entities.
+        if ($plugin->entityTypeObject()->isRevisionable()) {
+          $query->latestRevision();
         }
+        $ids = $query->execute();
+      }
 
-        $create_publishing_revision = $node->type->entity->getThirdPartySetting('scheduler', 'publish_revision', $this->setting('default_publish_revision'));
-        if ($create_publishing_revision) {
-          $node->setNewRevision();
-          // Use a core date format to guarantee a time is included.
-          $revision_log_message = rtrim($this->t('Published by Scheduler. The scheduled publishing date was @publish_on.', [
-            '@publish_on' => $this->dateFormatter->format($publish_on, 'short'),
-          ]) . ' ' . $msg_extra);
-          $node->setRevisionLogMessage($revision_log_message)
-            ->setRevisionCreationTime($this->time->getRequestTime());
-        }
-        // Unset publish_on so the node will not get rescheduled by subsequent
-        // calls to $node->save().
-        $node->publish_on->value = NULL;
-
-        // Invoke all implementations of hook_scheduler_publish_action() to
-        // allow other modules to do the "publishing" process instead of
-        // Scheduler.
-        $hook = 'scheduler_publish_action';
-        $processed = FALSE;
-        $failed = FALSE;
-        foreach ($this->moduleHandler->getImplementations($hook) as $module) {
-          $function = $module . '_' . $hook;
-          $return = $function($node);
-          $processed = $processed || ($return === 1);
-          $failed = $failed || ($return === -1);
-        }
+      // Allow other modules to add to the list of entities to be published.
+      $hook_implementations = $this->getHookImplementations('list', $entityTypeId);
+      foreach ($hook_implementations as $function) {
+        // Cast each hook result as array, to protect from bad implementations.
+        $ids = array_merge($ids, (array) $function($process, $entityTypeId));
+      }
 
-        // Log the fact that a scheduled publication is about to take place.
-        $view_link = $node->toLink($this->t('View node'));
-        $node_type = $this->entityTypeManager->getStorage('node_type')->load($node->bundle());
-        $node_type_link = $node_type->toLink($this->t('@label settings', ['@label' => $node_type->label()]), 'edit-form');
-        $logger_variables = [
-          '@type' => $node_type->label(),
-          '%title' => $node->getTitle(),
-          'link' => $node_type_link->toString() . ' ' . $view_link->toString(),
-          '@hook' => 'hook_' . $hook,
-        ];
-
-        if ($failed) {
-          // At least one hook function returned a failure or exception, so stop
-          // processing this node and move on to the next one.
-          $this->logger->warning('Publishing failed for %title. Calls to @hook returned a failure code.', $logger_variables);
-          continue;
-        }
-        elseif ($processed) {
-          // The node had 'publishing' processed by a module implementing the
-          // hook, so no need to do anything more, apart from log this result.
-          $this->logger->notice('@type: scheduled processing of %title completed by calls to @hook.', $logger_variables);
-        }
-        else {
-          // None of the above hook calls processed the node and there were no
-          // errors detected so set the node to published.
-          $this->logger->notice('@type: scheduled publishing of %title.', $logger_variables);
-          $node->setPublished();
-        }
+      // Allow other modules to alter the list of entities to be published.
+      $hook_implementations = $this->getHookImplementations('list_alter', $entityTypeId);
+      foreach ($hook_implementations as $function) {
+        $function($ids, $process, $entityTypeId);
+      }
 
-        // Invoke the event to tell Rules that Scheduler has published the node.
-        if ($this->moduleHandler->moduleExists('scheduler_rules_integration')) {
-          _scheduler_rules_integration_dispatch_cron_event($node, 'publish');
+      // Finally ensure that there are no duplicates in the list of ids.
+      $ids = array_unique($ids);
+
+      // In 8.x the entity translations are all associated with one entity id
+      // unlike 7.x where each translation was a separate id. This means that
+      // the list of ids returned above may have some translations that need
+      // processing now and others that do not.
+      /** @var \Drupal\Core\Entity\EntityInterface[] $entities */
+      $entities = $this->loadEntities($ids, $entityTypeId);
+      foreach ($entities as $entity_multilingual) {
+
+        // The API calls could return entities of types which are not enabled
+        // for scheduled publishing, so do not process these. This check can be
+        // done once as the setting will be the same for all translations.
+        if (!$this->getThirdPartySetting($entity_multilingual, 'publish_enable', $this->setting('default_publish_enable'))) {
+          $this->throwSchedulerException($entity_multilingual, 'SchedulerEntityTypeNotEnabledException', $process);
         }
 
-        // Trigger the PUBLISH event so that modules can react after the node is
-        // published.
-        $event = new SchedulerEvent($node);
-        $this->eventDispatcher->dispatch(SchedulerEvents::PUBLISH, $event);
-
-        // Use the standard actions system to publish and save the node.
-        $node = $event->getNode();
-        $action_id = 'node_publish_action';
-        if ($this->moduleHandler->moduleExists('workbench_moderation_actions')) {
-          // workbench_moderation_actions module uses a custom action instead.
-          $action_id = 'state_change__node__published';
+        $languages = $entity_multilingual->getTranslationLanguages();
+        foreach ($languages as $language) {
+          // The object returned by getTranslation() is a normal $entity.
+          $entity = $entity_multilingual->getTranslation($language->getId());
+
+          // If the current translation does not have a publish on value, or it
+          // is later than the date we are processing then move on to the next.
+          $publish_on = $entity->publish_on->value;
+          if (empty($publish_on) || $publish_on > $this->time->getRequestTime()) {
+            continue;
+          }
+
+          // Check that other modules allow the process on this entity.
+          if (!$this->isAllowed($entity, $process)) {
+            continue;
+          }
+
+          // Trigger the PRE_PUBLISH Scheduler event so that modules can react
+          // before the entity is published.
+          $this->dispatchSchedulerEvent($entity, 'PRE_PUBLISH');
+
+          // Update 'changed' timestamp.
+          if ($entity instanceof EntityChangedInterface) {
+            $entity->setChangedTime($publish_on);
+          }
+
+          $msg_extra = '';
+
+          // If required, set the created date to match published date.
+          if ($this->getThirdPartySetting($entity, 'publish_touch', $this->setting('default_publish_touch')) ||
+            ($this->getThirdPartySetting($entity, 'publish_past_date_created', $this->setting('default_publish_past_date_created')) && $entity->getCreatedTime() > $publish_on)
+          ) {
+            $old_creation_date = $entity->getCreatedTime();
+            $entity->setCreatedTime($publish_on);
+            $msg_extra = $this->t('The previous creation date was @old_creation_date, now updated to match the publishing date.', [
+              '@old_creation_date' => $this->dateFormatter->format($old_creation_date, 'short'),
+            ]);
+          }
+
+          $create_publishing_revision = $this->getThirdPartySetting($entity, 'publish_revision', $this->setting('default_publish_revision'));
+          if ($create_publishing_revision && $entity->getEntityType()->isRevisionable()) {
+            $entity->setNewRevision();
+            // Use a core date format to guarantee a time is included.
+            $revision_log_message = rtrim($this->t('Published by Scheduler. The scheduled publishing date was @publish_on.', [
+              '@publish_on' => $this->dateFormatter->format($publish_on, 'short'),
+            ]) . ' ' . $msg_extra);
+            $entity->setRevisionLogMessage($revision_log_message)
+              ->setRevisionCreationTime($this->time->getRequestTime());
+          }
+          // Unset publish_on so the entity will not get rescheduled by any
+          // interim calls to $entity->save().
+          $entity->publish_on->value = NULL;
+
+          // Invoke all implementations of hook_scheduler_publish_process() and
+          // hook_scheduler_{type}_publish_process() to allow other modules to
+          // do the "publishing" process instead of Scheduler.
+          $hook_implementations = $this->getHookImplementations('publish_process', $entity);
+          $sucessful_hooks = [];
+          $failed_hooks = [];
+          foreach ($hook_implementations as $function) {
+            $return = $function($entity);
+            if ($return === 1) {
+              $sucessful_hooks[] = $function;
+              if (stristr($function, '_action')) {
+                // If this is a legacy action hook, for safety call ->save() as
+                // this used to be done here in Scheduler 8.x-1.x.
+                $entity->save();
+              }
+            }
+            $return === -1 ? $failed_hooks[] = $function : NULL;
+          }
+          $processed = count($sucessful_hooks) > 0;
+          $failed = count($failed_hooks) > 0;
+
+          // Create a set of variables for use in the log message.
+          $bundle_type = $entity->getEntityType()->getBundleEntityType();
+          $entity_type = $this->entityTypeManager->getStorage($bundle_type)->load($entity->bundle());
+          $links = [];
+          if ($entity->hasLinkTemplate('canonical')) {
+            $links[] = $entity->toLink($this->t('View @type', [
+              '@type' => strtolower($entity_type->label()),
+            ]))->toString();
+          }
+          if ($entity_type->hasLinkTemplate('edit-form')) {
+            $links[] = $entity_type->toLink($this->t('@label settings', [
+              '@label' => $entity_type->label(),
+            ]), 'edit-form')->toString();
+          }
+          $logger_variables = [
+            '@type' => $entity_type->label(),
+            '%title' => $entity->label(),
+            '@sucessful_hooks' => implode(', ', $sucessful_hooks),
+            '@failed_hooks' => implode(', ', $failed_hooks),
+            'link' => implode(' ', $links),
+          ];
+
+          if ($failed) {
+            // At least one hook function returned a failure or exception, so
+            // stop processing this entity and move on to the next one.
+            $this->logger->warning('Publishing failed for %title. @failed_hooks returned a failure code.', $logger_variables);
+            // Restore the publish_on date to allow another attempt next time.
+            $entity->publish_on->value = $publish_on;
+            $entity->save();
+            continue;
+          }
+          elseif ($processed) {
+            // The entity was 'published' by a module implementing the hook, so
+            // we only need to log this result.
+            $this->logger->notice('@type: scheduled "publish" processing of %title completed by @sucessful_hooks.', $logger_variables);
+          }
+          else {
+            // None of the above hook calls processed the entity and there were
+            // no errors detected so set the entity to published.
+            $this->logger->notice('@type: scheduled publishing of %title.', $logger_variables);
+
+            // Use the actions system to publish and save the entity.
+            $action_id = $plugin->publishAction();
+            if ($this->moduleHandler->moduleExists('workbench_moderation_actions')) {
+              // workbench_moderation_actions module replaces the standard
+              // action with a custom one which should be used only when the
+              // entity type is part of a moderation workflow.
+              /** @var \Drupal\workbench_moderation\ModerationInformationInterface $moderation_info */
+              $moderation_info = \Drupal::service('workbench_moderation.moderation_information');
+              if ($moderation_info->isModeratableEntity($entity)) {
+                $action_id = 'state_change__' . $entityTypeId . '__published';
+              }
+            }
+            if ($loaded_action = $this->entityTypeManager->getStorage('action')->load($action_id)) {
+              $loaded_action->getPlugin()->execute($entity);
+            }
+            else {
+              // Fallback to the direct method if the action does not exist.
+              $entity->setPublished()->save();
+            }
+          }
+
+          // Invoke event to tell Rules that Scheduler has published the entity.
+          if ($this->moduleHandler->moduleExists('scheduler_rules_integration')) {
+            _scheduler_rules_integration_dispatch_cron_event($entity, $process);
+          }
+
+          // Trigger the PUBLISH Scheduler event so that modules can react after
+          // the entity is published.
+          $this->dispatchSchedulerEvent($entity, 'PUBLISH');
+
+          $result = TRUE;
         }
-        $this->entityTypeManager->getStorage('action')->load($action_id)->getPlugin()->execute($node);
-
-        $result = TRUE;
       }
     }
 
@@ -268,174 +444,214 @@ public function publish() {
   }
 
   /**
-   * Unpublish scheduled nodes.
+   * Unpublish scheduled entities.
    *
    * @return bool
-   *   TRUE if any node has been unpublished, FALSE otherwise.
+   *   TRUE if any entity has been unpublished, FALSE otherwise.
    *
-   * @throws \Drupal\scheduler\Exception\SchedulerMissingDateException
-   * @throws \Drupal\scheduler\Exception\SchedulerNodeTypeNotEnabledException
+   * @throws \Drupal\scheduler\Exception\SchedulerEntityTypeNotEnabledException
    */
   public function unpublish() {
     $result = FALSE;
-    $action = 'unpublish';
-
-    // Select all nodes of the types that are enabled for scheduled unpublishing
-    // and where unpublish_on is less than or equal to the current time.
-    $nids = [];
-    $scheduler_enabled_types = array_keys(_scheduler_get_scheduler_enabled_node_types($action));
-    if (!empty($scheduler_enabled_types)) {
-      $query = $this->entityTypeManager->getStorage('node')->getQuery()
-        ->exists('unpublish_on')
-        ->condition('unpublish_on', $this->time->getRequestTime(), '<=')
-        ->condition('type', $scheduler_enabled_types, 'IN')
-        ->latestRevision()
-        ->sort('unpublish_on')
-        ->sort('nid');
-      // Disable access checks for this query.
-      // @see https://www.drupal.org/node/2700209
-      $query->accessCheck(FALSE);
-      $nids = $query->execute();
-    }
-
-    // Allow other modules to add to the list of nodes to be unpublished.
-    $nids = array_unique(array_merge($nids, $this->nidList($action)));
-
-    // Allow other modules to alter the list of nodes to be unpublished.
-    $this->moduleHandler->alter('scheduler_nid_list', $nids, $action);
-
-    /** @var \Drupal\node\NodeInterface[] $nodes */
-    $nodes = $this->loadNodes($nids);
-    foreach ($nodes as $node_multilingual) {
-      // The API calls could return nodes of types which are not enabled for
-      // scheduled unpublishing. Do not process these.
-      if (!$node_multilingual->type->entity->getThirdPartySetting('scheduler', 'unpublish_enable', $this->setting('default_unpublish_enable'))) {
-        throw new SchedulerNodeTypeNotEnabledException(sprintf("Node %d '%s' will not be unpublished because node type '%s' is not enabled for scheduled unpublishing", $node_multilingual->id(), $node_multilingual->getTitle(), node_get_type_label($node_multilingual)));
-      }
-
-      $languages = $node_multilingual->getTranslationLanguages();
-      foreach ($languages as $language) {
-        // The object returned by getTranslation() behaves the same as a $node.
-        $node = $node_multilingual->getTranslation($language->getId());
-
-        // If the current translation does not have an unpublish on value, or it
-        // is later than the date we are processing then move on to the next.
-        $unpublish_on = $node->unpublish_on->value;
-        if (empty($unpublish_on) || $unpublish_on > $this->time->getRequestTime()) {
-          continue;
-        }
-
-        // Do not process the node if it still has a publish_on time which is in
-        // the past, as this implies that scheduled publishing has been blocked
-        // by one of the hook functions we provide, and is still being blocked
-        // now that the unpublishing time has been reached.
-        $publish_on = $node->publish_on->value;
-        if (!empty($publish_on) && $publish_on <= $this->time->getRequestTime()) {
-          continue;
+    $process = 'unpublish';
+    $plugins = $this->getPlugins();
+
+    foreach ($plugins as $entityTypeId => $plugin) {
+      // Select all entities of the types for this plugin that are enabled for
+      // scheduled unpublishing and where unpublish_on is less than or equal to
+      // the current time.
+      $ids = [];
+      $scheduler_enabled_types = $this->getEnabledTypes($entityTypeId, $process);
+
+      if (!empty($scheduler_enabled_types)) {
+        $query = $this->entityTypeManager->getStorage($entityTypeId)->getQuery()
+          ->exists('unpublish_on')
+          ->condition('unpublish_on', $this->time->getRequestTime(), '<=')
+          ->condition($plugin->typeFieldName(), $scheduler_enabled_types, 'IN')
+          ->sort('unpublish_on');
+        // Disable access checks for this query.
+        // @see https://www.drupal.org/node/2700209
+        $query->accessCheck(FALSE);
+        // If the entity type is revisionable then make sure we look for the
+        // latest revision. This is important for moderated entities.
+        if ($plugin->entityTypeObject()->isRevisionable()) {
+          $query->latestRevision();
         }
+        $ids = $query->execute();
+      }
 
-        // Check that other modules allow the action on this node.
-        if (!$this->isAllowed($node, $action)) {
-          continue;
-        }
+      // Allow other modules to add to the list of entities to be unpublished.
+      $hook_implementations = $this->getHookImplementations('list', $entityTypeId);
+      foreach ($hook_implementations as $function) {
+        // Cast each hook result as array, to protect from bad implementations.
+        $ids = array_merge($ids, (array) $function($process, $entityTypeId));
+      }
 
-        // $node->setChangedTime($unpublish_on) will fail badly if an API call
-        // has removed the date. Trap this as an exception here and give a
-        // meaningful message.
-        // @TODO This will now never be thrown due to the empty(unpublish_on)
-        // check above to cater for translations. Remove this exception?
-        if (empty($unpublish_on)) {
-          $field_definitions = $this->entityTypeManager->getFieldDefinitions('node', $node->getType());
-          $field = (string) $field_definitions['unpublish_on']->getLabel();
-          throw new SchedulerMissingDateException(sprintf("Node %d '%s' will not be unpublished because field '%s' has no value", $node->id(), $node->getTitle(), $field));
-        }
+      // Allow other modules to alter the list of entities to be unpublished.
+      $hook_implementations = $this->getHookImplementations('list_alter', $entityTypeId);
+      foreach ($hook_implementations as $function) {
+        $function($ids, $process, $entityTypeId);
+      }
 
-        // Trigger the PRE_UNPUBLISH event so that modules can react before the
-        // node is unpublished.
-        $event = new SchedulerEvent($node);
-        $this->eventDispatcher->dispatch(SchedulerEvents::PRE_UNPUBLISH, $event);
-        $node = $event->getNode();
-
-        // Update 'changed' timestamp.
-        $node->setChangedTime($unpublish_on);
-
-        $create_unpublishing_revision = $node->type->entity->getThirdPartySetting('scheduler', 'unpublish_revision', $this->setting('default_unpublish_revision'));
-        if ($create_unpublishing_revision) {
-          $node->setNewRevision();
-          // Use a core date format to guarantee a time is included.
-          $revision_log_message = $this->t('Unpublished by Scheduler. The scheduled unpublishing date was @unpublish_on.', [
-            '@unpublish_on' => $this->dateFormatter->format($unpublish_on, 'short'),
-          ]);
-          // Create the new revision, setting message and revision timestamp.
-          $node->setRevisionLogMessage($revision_log_message)
-            ->setRevisionCreationTime($this->time->getRequestTime());
-        }
-        // Unset unpublish_on so the node will not get rescheduled by subsequent
-        // calls to $node->save().
-        $node->unpublish_on->value = NULL;
-
-        // Invoke all implementations of hook_scheduler_unpublish_action() to
-        // allow other modules to do the "unpublishing" process instead of
-        // Scheduler.
-        $hook = 'scheduler_unpublish_action';
-        $processed = FALSE;
-        $failed = FALSE;
-        foreach ($this->moduleHandler->getImplementations($hook) as $module) {
-          $function = $module . '_' . $hook;
-          $return = $function($node);
-          $processed = $processed || ($return === 1);
-          $failed = $failed || ($return === -1);
-        }
+      // Finally ensure that there are no duplicates in the list of ids.
+      $ids = array_unique($ids);
 
-        // Set up the log variables.
-        $view_link = $node->toLink($this->t('View node'));
-        $node_type = $this->entityTypeManager->getStorage('node_type')->load($node->bundle());
-        $node_type_link = $node_type->toLink($this->t('@label settings', ['@label' => $node_type->label()]), 'edit-form');
-        $logger_variables = [
-          '@type' => $node_type->label(),
-          '%title' => $node->getTitle(),
-          'link' => $node_type_link->toString() . ' ' . $view_link->toString(),
-          '@hook' => 'hook_' . $hook,
-        ];
-
-        if ($failed) {
-          // At least one hook function returned a failure or exception, so stop
-          // processing this node and move on to the next one.
-          $this->logger->warning('Unpublishing failed for %title. Calls to @hook returned a failure code.', $logger_variables);
-          continue;
-        }
-        elseif ($processed) {
-          // The node has 'unpublishing' processed by a module implementing the
-          // hook, so no need to do anything more, apart from log this result.
-          $this->logger->notice('@type: scheduled processing of %title completed by calls to @hook.', $logger_variables);
-        }
-        else {
-          // None of the above hook calls processed the node and there were no
-          // errors detected so set the node to unpublished.
-          $this->logger->notice('@type: scheduled unpublishing of %title.', $logger_variables);
-          $node->setUnpublished();
-        }
+      /** @var \Drupal\Core\Entity\EntityInterface[] $entities */
+      $entities = $this->loadEntities($ids, $entityTypeId);
+      foreach ($entities as $entity_multilingual) {
 
-        // Invoke event to tell Rules that Scheduler has unpublished this node.
-        if ($this->moduleHandler->moduleExists('scheduler_rules_integration')) {
-          _scheduler_rules_integration_dispatch_cron_event($node, 'unpublish');
+        // The API calls could return entities of types which are not enabled
+        // for scheduled unpublishing, so do not process these. This check can
+        // be done once as the setting will be the same for all translations.
+        if (!$this->getThirdPartySetting($entity_multilingual, 'unpublish_enable', $this->setting('default_unpublish_enable'))) {
+          $this->throwSchedulerException($entity_multilingual, 'SchedulerEntityTypeNotEnabledException', $process);
         }
 
-        // Trigger the UNPUBLISH event so that modules can react after the node
-        // is unpublished.
-        $event = new SchedulerEvent($node);
-        $this->eventDispatcher->dispatch(SchedulerEvents::UNPUBLISH, $event);
-
-        // Use the standard actions system to unpublish and save the node.
-        $node = $event->getNode();
-        $action_id = 'node_unpublish_action';
-        if ($this->moduleHandler->moduleExists('workbench_moderation_actions')) {
-          // workbench_moderation_actions module uses a custom action instead.
-          $action_id = 'state_change__node__archived';
+        $languages = $entity_multilingual->getTranslationLanguages();
+        foreach ($languages as $language) {
+          // The object returned by getTranslation() is a normal $entity.
+          $entity = $entity_multilingual->getTranslation($language->getId());
+
+          // If the current translation does not have an unpublish-on value, or
+          // it is later than the date we are processing then move to the next.
+          $unpublish_on = $entity->unpublish_on->value;
+          if (empty($unpublish_on) || $unpublish_on > $this->time->getRequestTime()) {
+            continue;
+          }
+
+          // Do not process the entity if it still has a publish_on time which
+          // is in the past, as this implies that scheduled publishing has been
+          // blocked by one of the hook functions we provide, and is still being
+          // blocked now that the unpublishing time has been reached.
+          $publish_on = $entity->publish_on->value;
+          if (!empty($publish_on) && $publish_on <= $this->time->getRequestTime()) {
+            continue;
+          }
+
+          // Check that other modules allow the process on this entity.
+          if (!$this->isAllowed($entity, $process)) {
+            continue;
+          }
+
+          // Trigger the PRE_UNPUBLISH Scheduler event so that modules can react
+          // before the entity is unpublished.
+          $this->dispatchSchedulerEvent($entity, 'PRE_UNPUBLISH');
+
+          // Update 'changed' timestamp.
+          if ($entity instanceof EntityChangedInterface) {
+            $entity->setChangedTime($unpublish_on);
+          }
+
+          $create_unpublishing_revision = $this->getThirdPartySetting($entity, 'unpublish_revision', $this->setting('default_unpublish_revision'));
+          if ($create_unpublishing_revision && $entity->getEntityType()->isRevisionable()) {
+            $entity->setNewRevision();
+            // Use a core date format to guarantee a time is included.
+            $revision_log_message = $this->t('Unpublished by Scheduler. The scheduled unpublishing date was @unpublish_on.', [
+              '@unpublish_on' => $this->dateFormatter->format($unpublish_on, 'short'),
+            ]);
+            // Create the new revision, setting message and revision timestamp.
+            $entity->setRevisionLogMessage($revision_log_message)
+              ->setRevisionCreationTime($this->time->getRequestTime());
+          }
+          // Unset publish_on so the entity will not get rescheduled by any
+          // interim calls to $entity->save().
+          $entity->unpublish_on->value = NULL;
+
+          // Invoke all implementations of hook_scheduler_unpublish_process()
+          // and hook_scheduler_{type}_unpublish_process() to allow other
+          // modules to do the "unpublishing" process instead of Scheduler.
+          $hook_implementations = $this->getHookImplementations('unpublish_process', $entity);
+          $sucessful_hooks = [];
+          $failed_hooks = [];
+          foreach ($hook_implementations as $function) {
+            $return = $function($entity);
+            if ($return === 1) {
+              $sucessful_hooks[] = $function;
+              if (stristr($function, '_action')) {
+                // If this is a legacy action hook, for safety call ->save() as
+                // this used to be done here in Scheduler 8.x-1.x.
+                $entity->save();
+              }
+            }
+            $return === -1 ? $failed_hooks[] = $function : NULL;
+          }
+          $processed = count($sucessful_hooks) > 0;
+          $failed = count($failed_hooks) > 0;
+
+          // Create a set of variables for use in the log message.
+          $bundle_type = $entity->getEntityType()->getBundleEntityType();
+          $entity_type = $this->entityTypeManager->getStorage($bundle_type)->load($entity->bundle());
+          $links = [];
+          if ($entity->hasLinkTemplate('canonical')) {
+            $links[] = $entity->toLink($this->t('View @type', [
+              '@type' => strtolower($entity_type->label()),
+            ]))->toString();
+          }
+          if ($entity_type->hasLinkTemplate('edit-form')) {
+            $links[] = $entity_type->toLink($this->t('@label settings', [
+              '@label' => $entity_type->label(),
+            ]), 'edit-form')->toString();
+          }
+          $logger_variables = [
+            '@type' => $entity_type->label(),
+            '%title' => $entity->label(),
+            '@sucessful_hooks' => implode(', ', $sucessful_hooks),
+            '@failed_hooks' => implode(', ', $failed_hooks),
+            'link' => implode(' ', $links),
+          ];
+
+          if ($failed) {
+            // At least one hook function returned a failure or exception, so
+            // stop processing this entity and move on to the next one.
+            $this->logger->warning('Unpublishing failed for %title. @failed_hooks returned a failure code.', $logger_variables);
+            // Restore the unpublish_on date to allow another attempt next time.
+            $entity->unpublish_on->value = $unpublish_on;
+            $entity->save();
+            continue;
+          }
+          elseif ($processed) {
+            // The entity was 'unpublished' by a module implementing the hook,
+            // so we only need to log this result.
+            $this->logger->notice('@type: scheduled "unpublish" processing of %title completed by @sucessful_hooks.', $logger_variables);
+          }
+          else {
+            // None of the above hook calls processed the entity and there were
+            // no errors detected so set the entity to unpublished.
+            $this->logger->notice('@type: scheduled unpublishing of %title.', $logger_variables);
+
+            // Use the actions system to unpublish and save the entity.
+            $action_id = $plugin->unpublishAction();
+            if ($this->moduleHandler->moduleExists('workbench_moderation_actions')) {
+              // workbench_moderation_actions module replaces the standard
+              // action with a custom one which should be used only when the
+              // entity type is part of a moderation workflow.
+              /** @var \Drupal\workbench_moderation\ModerationInformationInterface $moderation_info */
+              $moderation_info = \Drupal::service('workbench_moderation.moderation_information');
+              if ($moderation_info->isModeratableEntity($entity)) {
+                $action_id = 'state_change__' . $entityTypeId . '__archived';
+              }
+            }
+            if ($loaded_action = $this->entityTypeManager->getStorage('action')->load($action_id)) {
+              $loaded_action->getPlugin()->execute($entity);
+            }
+            else {
+              // Fallback to the direct method if the action does not exist.
+              $entity->setUnpublished()->save();
+            }
+          }
+
+          // Invoke event to tell Rules that Scheduler has unpublished the
+          // entity.
+          if ($this->moduleHandler->moduleExists('scheduler_rules_integration')) {
+            _scheduler_rules_integration_dispatch_cron_event($entity, $process);
+          }
+
+          // Trigger the UNPUBLISH Scheduler event so that modules can react
+          // after the entity is unpublished.
+          $this->dispatchSchedulerEvent($entity, 'UNPUBLISH');
+
+          $result = TRUE;
         }
-        $this->entityTypeManager->getStorage('action')->load($action_id)->getPlugin()->execute($node);
-
-        $result = TRUE;
       }
     }
 
@@ -443,57 +659,143 @@ public function unpublish() {
   }
 
   /**
-   * Checks whether a scheduled action on a node is allowed.
+   * Checks whether a scheduled process on an entity is allowed.
    *
-   * This provides a way for other modules to prevent scheduled publishing or
-   * unpublishing, by implementing hook_scheduler_allow_publishing() or
-   * hook_scheduler_allow_unpublishing().
+   * Other modules can prevent scheduled publishing or unpublishing by
+   * implementing any or all of the following:
+   *   hook_scheduler_publishing_allowed()
+   *   hook_scheduler_unpublishing_allowed()
+   *   hook_scheduler_{type}_publishing_allowed()
+   *   hook_scheduler_{type}_unpublishing_allowed()
    *
-   * @param \Drupal\node\NodeInterface $node
-   *   The node on which the action is to be performed.
-   * @param string $action
-   *   The action that needs to be checked. Can be 'publish' or 'unpublish'.
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity on which the process is to be performed.
+   * @param string $process
+   *   The process to be checked. Values are 'publish' or 'unpublish'.
    *
    * @return bool
-   *   TRUE if the action is allowed, FALSE if not.
-   *
-   * @see hook_scheduler_allow_publishing()
-   * @see hook_scheduler_allow_unpublishing()
+   *   TRUE if the process is allowed, FALSE if not.
    */
-  public function isAllowed(NodeInterface $node, $action) {
+  public function isAllowed(EntityInterface $entity, $process) {
     // Default to TRUE.
     $result = TRUE;
-    // Check that other modules allow the action.
-    $hook = 'scheduler_allow_' . $action . 'ing';
-    foreach ($this->moduleHandler->getImplementations($hook) as $module) {
-      $function = $module . '_' . $hook;
-      $result &= $function($node);
-    }
 
+    // Get all implementations of the required hook function.
+    $hook_implementations = $this->getHookImplementations($process . 'ing_allowed', $entity);
+
+    // Call the hook functions. If any specifically return FALSE the overall
+    // result is FALSE. If a hook returns nothing it will not affect the result.
+    foreach ($hook_implementations as $function) {
+      $returned = $function($entity);
+      $result &= !(isset($returned) && $returned == FALSE);
+    }
     return $result;
   }
 
   /**
-   * Gather node IDs for all nodes that need to be $action'ed.
+   * Returns an array of hook function names implemented for a hook type.
    *
-   * Modules can implement hook_scheduler_nid_list($action) and return an array
-   * of node ids which will be added to the existing list.
+   * The return array will include all implementations of the general hook
+   * function called for all entity types, plus all implemented hooks for the
+   * specific type of entity being processed. In addition, for node entities,
+   * the original hook functions (prior to entity plugins) are added to maintain
+   * backwards-compatibility.
    *
-   * @param string $action
-   *   The action being performed, either "publish" or "unpublish".
+   * @param string $hookType
+   *   The identifier of the hook function, for example 'publish_process' or
+   *   'unpublishing_allowed' or 'hide_publish_date'.
+   * @param \Drupal\Core\Entity\EntityInterface|string $entity
+   *   The entity object which is being processed, or a string containing the
+   *   entity type id (for example 'node' or 'media').
    *
    * @return array
-   *   An array of node ids.
+   *   An array of callable function names for the implementations of this hook
+   *   function for the type of entity being processed.
    */
-  public function nidList($action) {
-    $nids = [];
+  public function getHookImplementations(string $hookType, $entity) {
+    $entityTypeId = (is_object($entity)) ? $entity->getEntityTypeid() : $entity;
+    $hooks = [$hookType, "{$entityTypeId}_{$hookType}"];
+
+    // For backwards compatibility the original node hook is also added.
+    if ($entityTypeId == 'node') {
+      $legacy_node_hooks = [
+        'hide_publish_date' => 'hide_publish_on_field',
+        'hide_unpublish_date' => 'hide_unpublish_on_field',
+        'list' => 'nid_list',
+        'list_alter' => 'nid_list_alter',
+        'publish_process' => 'publish_action',
+        'unpublish_process' => 'unpublish_action',
+        'publishing_allowed' => 'allow_publishing',
+        'unpublishing_allowed' => 'allow_unpublishing',
+      ];
+      $hooks[] = $legacy_node_hooks[$hookType];
+    }
+
+    // Find all modules that implement these hooks, then append the $hookName to
+    // the end of the module, thus giving the full function name.
+    $all_hook_implementations = [];
+    foreach ($hooks as $hook) {
+      $hookName = "scheduler_$hook";
+      if (version_compare(\Drupal::VERSION, '9.4', '>=')) {
+        // getImplementations() is deprecated in D9.4, use invokeAllWith().
+        $this->moduleHandler->invokeAllWith($hookName, function (callable $hook, string $module) use ($hookName, &$all_hook_implementations) {
+          $all_hook_implementations[] = $module . "_" . $hookName;
+        });
+      }
+      else {
+        // Use getImplementations() to maintain compatibility with Drupal 8.9.
+        $implementations = $this->moduleHandler->getImplementations($hookName);
+        array_walk($implementations, function (&$module) use ($hookName, &$all_hook_implementations) {
+          $all_hook_implementations[] = $module . "_" . $hookName;
+        });
+      }
+    }
+    return $all_hook_implementations;
+  }
 
-    foreach ($this->moduleHandler->getImplementations('scheduler_nid_list') as $module) {
-      $function = $module . '_scheduler_nid_list';
-      $nids = array_merge($nids, $function($action));
+  /**
+   * Gives details and throws exception when a required action is missing.
+   *
+   * This displays a screen error message which is useful if the cron run was
+   * initiated via the site UI. This will also be shown on the terminal if cron
+   * was run via drush. If the Config Update module is installed then a link is
+   * given to the actions report in Config UI, which lists the missing items and
+   * provides a button to import from source. If Config Update is not installed
+   * then a link is provided to its Drupal project page.
+   *
+   * @param string $action_id
+   *   The id of the missing action.
+   * @param string $process
+   *   The Scheduler process being run, 'publish' or 'unpublish'.
+   */
+  protected function missingAction(string $action_id, string $process) {
+    $logger_variables = ['%action_id' => $action_id];
+    // If the Config Update module is available then link to the UI report. If
+    // not then link to the project page on drupal.org.
+    if (\Drupal::moduleHandler()->moduleExists('config_update')) {
+      // If the report UI sub-module is enabled then link directly to the
+      // actions report. Otherwise link to 'Extend' so it can be enabled.
+      if (\Drupal::moduleHandler()->moduleExists('config_update_ui')) {
+        $link = Link::fromTextAndUrl($this->t('Config Update for actions'), Url::fromRoute('config_update_ui.report', [
+          'report_type' => 'type',
+          'name' => 'action',
+        ]));
+      }
+      else {
+        $link = Link::fromTextAndUrl($this->t('Enable Config Update Reports'), Url::fromRoute('system.modules_list', ['filter' => 'config_update']));
+      }
+      $logger_variables['link'] = $link->toString();
+      $logger_variables[':url'] = $link->getUrl()->toString();
+    }
+    else {
+      $project_page = 'https://www.drupal.org/project/config_update';
+      $logger_variables[':url'] = $project_page;
+      $logger_variables['link'] = Link::fromTextAndUrl('Config Update project page', Url::fromUri($project_page))->toString();
     }
 
-    return $nids;
+    \Drupal::messenger()->addError($this->t("Action '%action_id' is missing. Use <a href=':url'>Config Update</a> to import the missing action.", $logger_variables));
+    $this->logger->warning("Action '%action_id' is missing. Use Config Update to import the missing action.", $logger_variables);
+    throw new \Exception("Action '{$action_id}' is missing. Scheduled $process halted.");
   }
 
   /**
@@ -524,6 +826,7 @@ public function runLightweightCron(array $options = []) {
       else {
         $trigger = 'url';
       }
+      // This has to be 'notice' not 'info' so that drush can show the message.
       $this->logger->notice('Lightweight cron run activated by @trigger.', ['@trigger' => $trigger]);
     }
     scheduler_cron();
@@ -553,30 +856,608 @@ protected function setting($key) {
   }
 
   /**
-   * Helper method to load latest revision of each node.
+   * Get third-party setting for an entity type, via the entity object.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity.
+   * @param string $setting
+   *   The setting to retrieve.
+   * @param mixed $default
+   *   The default value for setting if none is found.
+   *
+   * @return mixed
+   *   The value of the setting.
+   */
+  public function getThirdPartySetting(EntityInterface $entity, $setting, $default) {
+    $typeFieldName = $this->getPlugin($entity->getEntityTypeId())->typeFieldName();
+    if (empty($entity->$typeFieldName)) {
+      // Avoid exception and give details if the typeFieldName does not exist.
+      $params = [
+        '%field' => $typeFieldName,
+        '%id' => $this->getPlugin($entity->getEntityTypeId())->getPluginId(),
+        '%entity' => $entity->getEntityTypeId(),
+      ];
+      \Drupal::messenger()->addError($this->t("Field '%field' specified by typeFieldName in the Scheduler plugin %id is not found in entity type %entity", $params));
+      $this->logger->error("Field '%field' specified by typeFieldName in the Scheduler plugin %id is not found in entity type %entity", $params);
+      return $default;
+    }
+    else {
+      return $entity->$typeFieldName->entity->getThirdPartySetting('scheduler', $setting, $default);
+    }
+  }
+
+  /**
+   * Helper method to load latest revision of each entity.
    *
-   * @param array $nids
-   *   Array of node ids.
+   * @param array $ids
+   *   Array of entity ids.
+   * @param string $type
+   *   The type of entity.
    *
    * @return array
-   *   Array of loaded nodes.
+   *   Array of loaded entity objects, keyed by id.
+   */
+  protected function loadEntities(array $ids, string $type) {
+    $storage = $this->entityTypeManager->getStorage($type);
+    $entities = [];
+    foreach ($ids as $id) {
+      // Avoid errors when an implementation of hook_scheduler_{type}_list has
+      // added an id of the wrong type.
+      if (!$entity = $storage->load($id)) {
+        $this->logger->warning('Entity id @id is not a @type entity. Processing skipped.', [
+          '@id' => $id,
+          '@type' => $type,
+        ]);
+        continue;
+      }
+      // If the entity type is revisionable then load the latest revision. For
+      // moderated entities this may be an unpublished draft update of a
+      // currently published entity.
+      if ($entity->getEntityType()->isRevisionable()) {
+        $vid = $storage->getLatestRevisionId($id);
+        $entities[$id] = $storage->loadRevision($vid);
+      }
+      else {
+        $entities[$id] = $entity;
+      }
+    }
+    return $entities;
+  }
+
+  /**
+   * Get a list of all scheduler plugin definitions.
+   *
+   * @return array|mixed[]|null
+   *   A list of definitions for the registered scheduler plugins.
+   */
+  public function getPluginDefinitions() {
+    $plugin_definitions = $this->pluginManager->getDefinitions();
+    // Sort in reverse order so that we have 'node_scheduler' followed by
+    // 'media_scheduler'. When a third entity type plugin gets implemented it
+    // would be possible to add a 'weight' property and sort by that.
+    arsort($plugin_definitions);
+    return $plugin_definitions;
+  }
+
+  /**
+   * Gets instances of applicable Scheduler plugins for the enabled modules.
    *
-   * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
-   * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
+   * @param string $provider
+   *   Optional. Filter the plugins to return only those that are provided by
+   *   the named $provider module.
+   *
+   * @return array
+   *   Array of plugin objects, keyed by the entity type the plugin supports.
    */
-  protected function loadNodes(array $nids) {
-    $node_storage = $this->entityTypeManager->getStorage('node');
-    $nodes = [];
+  public function getPlugins(string $provider = NULL) {
+    $cache = \Drupal::cache()->get('scheduler.plugins');
+    if (!empty($cache) && !empty($cache->data) && empty($provider)) {
+      return $cache->data;
+    }
+
+    $definitions = $this->getPluginDefinitions();
+    $plugins = [];
+    foreach ($definitions as $definition) {
+      $plugin = $this->pluginManager->createInstance($definition['id']);
+      $dependency = $plugin->dependency();
+      // Ignore plugins if there is a dependency module and it is not enabled.
+      if ($dependency && !\Drupal::moduleHandler()->moduleExists($dependency)) {
+        continue;
+      }
+      // Ignore plugins that do not match the specified provider module name.
+      if ($provider && $definition['provider'] != $provider) {
+        continue;
+      }
+      $plugins[$plugin->entityType()] = $plugin;
+    }
 
-    // Load the latest revision for each node.
-    foreach ($nids as $nid) {
-      $node = $node_storage->load($nid);
-      $revision_ids = $node_storage->revisionIds($node);
-      $vid = end($revision_ids);
-      $nodes[] = $node_storage->loadRevision($vid);
+    // Save to the cache only when not filtered for a particular a provider.
+    if (empty($provider)) {
+      \Drupal::cache()->set('scheduler.plugins', $plugins);
     }
+    return $plugins;
+  }
+
+  /**
+   * Reset the scheduler plugins cache.
+   */
+  public function invalidatePluginCache() {
+    \Drupal::cache()->invalidate('scheduler.plugins');
+  }
 
-    return $nodes;
+  /**
+   * Get the supported entity types applicable to the currently enabled modules.
+   *
+   * @param string $provider
+   *   Optional. Filter the returned entity types for only those from the
+   *   plugins that are provided by the named $provider module.
+   *
+   * @return array
+   *   A list of the entity type ids.
+   */
+  public function getPluginEntityTypes(string $provider = NULL) {
+    return array_keys($this->getPlugins($provider));
+  }
+
+  /**
+   * Get a plugin for a specific entity type.
+   *
+   * @param string $entityTypeId
+   *   The entity type id, for example 'node' or 'media'.
+   *
+   * @return mixed
+   *   The plugin object associated with a specific entity, or NULL if none.
+   */
+  public function getPlugin($entityTypeId) {
+    $plugins = $this->getPlugins();
+    return $plugins[$entityTypeId] ?? NULL;
+  }
+
+  /**
+   * Gets the names of the types/bundles enabled for a specific process.
+   *
+   * If the entity type is not supported by Scheduler, or there are no enabled
+   * bundles for this process within the entity type, then an empty array is
+   * returned.
+   *
+   * @param string $entityTypeId
+   *   The entity type id, for example 'node' or 'media'.
+   * @param string $process
+   *   The process to check - 'publish' or 'unpublish'.
+   *
+   * @return array
+   *   The entity's type/bundle names that are enabled for the required process.
+   */
+  public function getEnabledTypes($entityTypeId, $process) {
+    if (!$plugin = $this->getPlugin($entityTypeId)) {
+      return [];
+    };
+    $types = $plugin->getTypes();
+    $types = array_filter($types, function ($bundle) use ($process) {
+      return $bundle->getThirdPartySetting('scheduler', $process . '_enable', $this->setting('default_' . $process . '_enable'));
+    });
+    return array_keys($types);
+  }
+
+  /**
+   * Gets list of entity add/edit form IDs.
+   *
+   * @return array
+   *   List of entity add/edit form IDs for all registered scheduler plugins.
+   */
+  public function getEntityFormIds() {
+    $plugins = $this->getPlugins();
+    $form_ids = [];
+    foreach ($plugins as $plugin) {
+      $form_ids = array_merge($form_ids, $plugin->entityFormIDs());
+    }
+    return $form_ids;
+  }
+
+  /**
+   * Gets list of entity type add/edit form IDs.
+   *
+   * @return array
+   *   List of entity type add/edit form IDs for registered scheduler plugins.
+   */
+  public function getEntityTypeFormIds() {
+    $plugins = $this->getPlugins();
+    $form_ids = [];
+    foreach ($plugins as $plugin) {
+      $form_ids = array_merge($form_ids, $plugin->entityTypeFormIDs());
+    }
+    return $form_ids;
+  }
+
+  /**
+   * Gets the supported Devel Generate form IDs.
+   *
+   * @return array
+   *   List of form IDs used by Devel Generate, keyed by entity type.
+   */
+  public function getDevelGenerateFormIds() {
+    $plugins = $this->getPlugins();
+    $form_ids = [];
+    foreach ($plugins as $entityTypeId => $plugin) {
+      // The devel_generate form id is optional so only save if a value exists.
+      // Use entity type as key so we can get back from form_id to entity.
+      if ($form_id = $plugin->develGenerateForm()) {
+        $form_ids[$entityTypeId] = $form_id;
+      }
+    }
+    return $form_ids;
+  }
+
+  /**
+   * Gets the routes for the entity collection pages.
+   *
+   * @return array
+   *   List of routes for collection pages, keyed by entity type.
+   */
+  public function getCollectionRoutes() {
+    $plugins = $this->getPlugins();
+    $routes = [];
+    foreach ($plugins as $entityTypeId => $plugin) {
+      $routes[$entityTypeId] = $plugin->collectionRoute();
+    }
+    return $routes;
+  }
+
+  /**
+   * Gets the routes for user profile page scheduled views.
+   *
+   * @return array
+   *   List of routes for the user page views, keyed by entity type.
+   */
+  public function getUserPageViewRoutes() {
+    $plugins = $this->getPlugins();
+    $routes = [];
+    foreach ($plugins as $entityTypeId => $plugin) {
+      // The user view is optional so only save if there is a value.
+      if ($route = $plugin->userViewRoute()) {
+        $routes[$entityTypeId] = $route;
+      }
+    }
+    return $routes;
+  }
+
+  /**
+   * Derives the permission name for an entity type and permission type.
+   *
+   * This function is added because for backwards-compatibility the node
+   * permission names have to end with 'nodes' and 'content'. For all other
+   * newly-supported entity types it is $entityTypeId.
+   *
+   * @param string $entityTypeId
+   *   The entity type id, for example 'node', 'media' etc.
+   * @param string $permissionType
+   *   The type of permission - 'schedule' or 'view'.
+   *
+   * @return string
+   *   The internal name of the scheduler permission.
+   */
+  public function permissionName($entityTypeId, $permissionType) {
+    switch ($permissionType) {
+      case 'schedule':
+        return 'schedule publishing of ' . ($entityTypeId == 'node' ? 'nodes' : $entityTypeId);
+
+      case 'view':
+        return 'view scheduled ' . ($entityTypeId == 'node' ? 'content' : $entityTypeId);
+    }
+  }
+
+  /**
+   * Updates db tables for entities that should have the Scheduler fields.
+   *
+   * This is called from scheduler_modules_installed and scheduler_update_8201.
+   * It can also be called manually via drush command scheduler-entity-update.
+   *
+   * @return array
+   *   Labels of the entity types updated.
+   */
+  public function entityUpdate() {
+    $entityUpdateManager = \Drupal::entityDefinitionUpdateManager();
+    $updated = [];
+    $list = $entityUpdateManager->getChangeList();
+    foreach ($list as $entity_type_id => $definitions) {
+      if ($definitions['field_storage_definitions']['publish_on'] ?? 0) {
+        $entity_type = $entityUpdateManager->getEntityType($entity_type_id);
+        $fields = scheduler_entity_base_field_info($entity_type);
+        foreach ($fields as $field_name => $field_definition) {
+          $entityUpdateManager->installFieldStorageDefinition($field_name, $entity_type_id, $entity_type_id, $field_definition);
+        }
+        $this->logger->notice('%entity updated with Scheduler publish_on and unpublish_on fields.', [
+          '%entity' => $entity_type->getLabel(),
+        ]);
+        $updated[] = (string) $entity_type->getLabel();
+      }
+    }
+    return $updated;
+  }
+
+  /**
+   * Refreshes scheduler views from source.
+   *
+   * If the view exists in the site's active storage it will be updated from the
+   * source yml file. If the view is now required but does not exist in active
+   * storage it will be loaded.
+   *
+   * Called from scheduler_modules_installed() and scheduler_update_8202().
+   *
+   * @param array $only_these_types
+   *   List of entity types to restrict the update of views to these types only.
+   *   Optional. If none then revert/load all applicable scheduler views.
+   *
+   * @return array
+   *   Labels of the views that were updated.
+   */
+  public function viewsUpdate(array $only_these_types = []) {
+    $updated = [];
+    $definition = $this->entityTypeManager->getDefinition('view');
+    $view_storage = $this->entityTypeManager->getStorage('view');
+    // Get the supported entity type ids for enabled modules where the provider
+    // is Scheduler. Third-party plugins do not need to be processed here.
+    $entity_types = $this->getPluginEntityTypes('scheduler');
+    if ($only_these_types) {
+      $entity_types = array_intersect($entity_types, $only_these_types);
+    }
+
+    foreach ($entity_types as $entity_type) {
+      $name = 'scheduler_scheduled_' . ($entity_type == 'node' ? 'content' : $entity_type);
+      $full_name = $definition->getConfigPrefix() . '.' . $name;
+
+      // Read the view definition from the .yml file. First try the /optional
+      // folder, then the main /config folder.
+      $optional_folder = \Drupal::service('extension.list.module')->getPath('scheduler') . '/config/optional';
+      $source_storage = new FileStorage($optional_folder);
+      if (!$source = $source_storage->read($full_name)) {
+        $install_folder = \Drupal::service('extension.list.module')->getPath('scheduler') . '/config/install';
+        $source_storage = new FileStorage($install_folder);
+        if (!$source = $source_storage->read($full_name)) {
+          $this->logger->notice('No source file for %full_name in either %install_folder or %optional_folder folders',
+            ['%full_name' => $full_name, '%install_folder' => $install_folder, '%optional_folder' => $optional_folder]);
+          continue;
+        }
+      }
+
+      // Try to read the view definition from active config storage.
+      /** @var \Drupal\Core\Config\StorageInterface $config_storage */
+      $config_storage = \Drupal::service('config.storage');
+      if ($config_storage->read($full_name)) {
+        // The view does exist in active storage, so load it, then replace the
+        // value with the source, but retain the _core and uuid values.
+        $view = $view_storage->load($name);
+        $core = $view->get('_core');
+        $uuid = $view->get('uuid');
+        $view = $view_storage->updateFromStorageRecord($view, $source);
+        $view->set('_core', $core);
+        $view->set('uuid', $uuid);
+        $view->save();
+        $this->logger->info('%view view updated.', ['%view' => $source['label']]);
+      }
+      else {
+        // The view does not exist in active storage so import it from source.
+        $view = $view_storage->createFromStorageRecord($source);
+        $view->save();
+        $this->logger->info('%view view loaded from source.', ['%view' => $source['label']]);
+      }
+      $updated[] = $source['label'];
+    }
+    // The views are loaded OK but the publish-on and unpublish-on views field
+    // handlers are not found. Clearing the views data cache solves the problem.
+    Cache::invalidateTags(['views_data']);
+    return $updated;
+  }
+
+  /**
+   * Reverts entity types that are no longer supported by Scheduler plugins.
+   *
+   * In normal situations this function is not required. However in the case
+   * when a plugin (either provided by Scheduler or another modules) is removed
+   * after being used, the db fields and third-party-settings remain and have to
+   * be deleted. This function was added to clean up the Paragraphs entity type
+   * but has been made generic for future use. It is called from a hook_update()
+   * and can also be run via drush command scheduler:entity-revert.
+   * See https://www.drupal.org/project/scheduler/issues/3259200
+   *
+   * @param array $only_these_types
+   *   Optional list of entity type ids to restrict the updates. If none given
+   *   then reverts all applicable entity types that have schema changes showing
+   *   that the db fields need to be removed.
+   *
+   * @return array
+   *   Messages about the entity types reverted.
+   */
+  public function entityRevert(array $only_these_types = []) {
+    // Find all changed entity definitions.
+    $entityUpdateManager = \Drupal::entityDefinitionUpdateManager();
+    $changeList = $entityUpdateManager->getChangeList();
+
+    $output = [];
+    if ($only_these_types) {
+      // First remove any non-existent entity types requested.
+      $all_entity_types = array_keys($this->entityTypeManager->getDefinitions());
+      if ($unknown = array_diff($only_these_types, $all_entity_types)) {
+        $output['unknown'] = $this->t('Unknown entity types (@unknown)', ['@unknown' => implode(' ', $unknown)]);
+      }
+      $entity_type_ids = array_intersect($only_these_types, $all_entity_types);
+    }
+    else {
+      // Nothing given. Get the list of changed entity types.
+      $entity_type_ids = array_keys($changeList);
+    }
+    // Remove any requested entity types that do have enabled plugins, as these
+    // must not be reverted.
+    $supported_types = $this->getPluginEntityTypes();
+    $entity_type_ids = array_diff($entity_type_ids, $supported_types);
+
+    foreach ($entity_type_ids as $entity_type_id) {
+      $entityType = $this->entityTypeManager->getDefinition($entity_type_id);
+      $bundleType = $entityType->getBundleEntityType();
+
+      // Remove the Scheduler fields from the entity type if they are shown in
+      // the changeList as 'deleted'.
+      if (isset($changeList[$entity_type_id]['field_storage_definitions'])) {
+        foreach (['publish_on', 'unpublish_on'] as $field_name) {
+          $change = ($changeList[$entity_type_id]['field_storage_definitions'][$field_name] ?? NULL);
+          // If the field is marked as deleted then remove it.
+          if ($change == $entityUpdateManager::DEFINITION_DELETED && $field = $entityUpdateManager->getFieldStorageDefinition($field_name, $entity_type_id)) {
+            $entityUpdateManager->uninstallFieldStorageDefinition($field);
+            $output["{$entity_type_id} fields"] = $this->t('Scheduler fields removed from @entityType', [
+              '@entityType' => $entityType->getLabel(),
+            ]);
+            $this->logger->info('%field field removed from %entityType entity type', [
+              '%field' => $field->getLabel(),
+              '%entityType' => $entityType->getLabel(),
+            ]);
+          }
+        }
+      }
+
+      // Remove Scheduler third-party-settings from each bundle.
+      foreach ($this->entityTypeManager->getStorage($bundleType)->loadMultiple() as $bundle) {
+        // Remove each third_party_setting. The last one to be removed will also
+        // cause the 'scheduler' top-level array to be deleted.
+        $third_party_settings = $bundle->getThirdPartySettings('scheduler');
+        if ($third_party_settings) {
+          foreach (array_keys($third_party_settings) as $setting) {
+            $bundle->unsetThirdPartySetting('scheduler', $setting)->save();
+          }
+          $this->logger->info('Scheduler settings removed from %entity %bundle', [
+            '%entity' => $bundle->getEntityType()->getLabel(),
+            '%bundle' => $bundle->label(),
+          ]);
+          $output["{$bundle->id()} settings"] = $this->t('Settings removed from @bundle', [
+            '@bundle' => $bundle->label(),
+          ]);
+        }
+      }
+    }
+
+    return $output;
+  }
+
+  /**
+   * Reset the form display fields to match the Scheduler enabled settings.
+   *
+   * The Scheduler fields are disabled by default and only enabled in a form
+   * display when that entity bundle is enabled for scheduled publishing or
+   * unpublishing. See _scheduler_form_entity_type_submit() for details.
+   *
+   * This was a design change during the development of Scheduler 2.0 and any
+   * site that had installed Scheduler prior to 2.0-rc8 will have all fields
+   * enabled. Whilst this should not be a problem, it is preferrable to update
+   * the displays to match the scenario when the modules is freshly installed.
+   * Hence this function was added and called from scheduler_update_8208().
+   */
+  public function resetFormDisplayFields() {
+    /** @var \Drupal\Core\Entity\EntityDisplayRepositoryInterface $display_repository */
+    $display_repository = \Drupal::service('entity_display.repository');
+    $fields_displayed = [];
+    $fields_hidden = [];
+
+    foreach ($this->getPlugins() as $entityTypeId => $plugin) {
+      // Get all active display modes. getFormModes() returns the additional
+      // modes then add the default.
+      $all_display_modes = array_keys($display_repository->getFormModes($entityTypeId));
+      $all_display_modes[] = $display_repository::DEFAULT_DISPLAY_MODE;
+
+      $supported_display_modes = $plugin->entityFormDisplayModes();
+
+      $bundles = $plugin->getTypes();
+      foreach ($bundles as $bundle_id => $bundle) {
+        foreach ($all_display_modes as $display_mode) {
+          $form_display = $display_repository->getFormDisplay($entityTypeId, $bundle_id, $display_mode);
+
+          foreach (['publish', 'unpublish'] as $value) {
+            $field = $value . '_on';
+            $setting = $value . '_enable';
+            // If this bundle is not enabled for scheduled (un)publishing or the
+            // form display mode is not supported then remove the field.
+            if (!$bundle->getThirdPartySetting('scheduler', $setting, FALSE) || !in_array($display_mode, $supported_display_modes)) {
+              $form_display->removeComponent($field)->save();
+              if ($display_mode == $display_repository::DEFAULT_DISPLAY_MODE) {
+                $fields_hidden[$field]["{$bundle->getEntityType()->getCollectionLabel()}"][] = $bundle->label();
+              }
+            }
+            else {
+              // Scheduling is enabled. Get the existing component to preserve
+              // any changed settings, but if the type is empty or set the to
+              // the core default 'datetime_timestamp' then change it to
+              // Scheduler's 'datetime_timestamp_no_default'.
+              $component = $form_display->getComponent($field);
+              if (empty($component['type']) || $component['type'] == 'datetime_timestamp') {
+                $component['type'] = 'datetime_timestamp_no_default';
+              }
+              $component['weight'] = ($field == 'publish_on' ? 52 : 54);
+              // Make sure the field and the settings group are displayed.
+              $form_display->setComponent('scheduler_settings', ['weight' => 50])
+                ->setComponent($field, $component)->save();
+              if ($display_mode == $display_repository::DEFAULT_DISPLAY_MODE) {
+                $fields_displayed[$field]["{$bundle->getEntityType()->getCollectionLabel()}"][] = $bundle->label();
+              }
+            }
+          }
+          // If the display mode is not supported remove the group fieldset.
+          if (!in_array($display_mode, $supported_display_modes)) {
+            $form_display->removeComponent('scheduler_settings')->save();
+          }
+        }
+      }
+    }
+
+    // It is not possible to determine whether a field on an enabled entity type
+    // had been manually hidden before this update. It is a rare scenario but
+    // inform the admin that there is potentially some manual work to do.
+    $uri = 'https://www.drupal.org/project/scheduler/issues/3320341';
+    $link = Link::fromTextAndUrl($this->t('Scheduler issue 3320341'), Url::fromUri($uri));
+    \Drupal::messenger()->addMessage($this->t(
+      'The Scheduler fields are now hidden by default and automatically changed to be displayed when an entity
+      bundle is enabled for scheduling. If you have previously manually hidden scheduler fields for enabled
+      entity types then these fields will now be displayed. You will need to manually hide them again or
+      implement hook_scheduler_hide_publish_date() or hook_scheduler_{TYPE}_hide_publish_date() and the
+      equivalent for unpublish_date. See @issue for details.',
+      ['@issue' => $link->toString()]), MessengerInterface::TYPE_STATUS, FALSE);
+    $this->logger->warning(
+      'The Scheduler fields are now hidden by default and automatically changed to be displayed when an entity
+      bundle is enabled for scheduling. If you have previously manually hidden scheduler fields for enabled
+      entity types then these fields will now be displayed. You will need to manually hide them again or
+      implement hook_scheduler_hide_publish_date() or hook_scheduler_{TYPE}_hide_publish_date() and the
+      equivalent for unpublish_date. See @issue for details.',
+      ['@issue' => $link->toString(), 'link' => $link->toString()]
+    );
+
+    /**
+     * Helper function to format the list of fields on bundles.
+     */
+    function formatOutputText($fields) {
+      return implode(', ', array_map(function ($name, $bundles) {
+        return "$name (" . implode(',', $bundles) . ")";
+      }, array_keys($fields), $fields));
+    }
+
+    $output = [];
+    if (isset($fields_displayed['publish_on'])) {
+      $output[] = $this->t('Publish On field displayed for: @list', [
+        '@list' => formatOutputText($fields_displayed['publish_on']),
+      ]);
+    }
+    if (isset($fields_displayed['unpublish_on'])) {
+      $output[] = $this->t('Unpublish On field displayed for: @list', [
+        '@list' => formatOutputText($fields_displayed['unpublish_on']),
+      ]);
+    }
+    if (isset($fields_hidden['publish_on'])) {
+      $output[] = $this->t('Publish On field hidden for: @list', [
+        '@list' => formatOutputText($fields_hidden['publish_on']),
+      ]);
+    }
+    if (isset($fields_hidden['unpublish_on'])) {
+      $output[] = $this->t('Unpublish On field hidden for: @list', [
+        '@list' => formatOutputText($fields_hidden['unpublish_on']),
+      ]);
+    }
+    return $output;
   }
 
 }
diff --git a/web/modules/scheduler/src/SchedulerPermissions.php b/web/modules/scheduler/src/SchedulerPermissions.php
new file mode 100644
index 0000000000000000000000000000000000000000..0af7384dd4467eae1617a793f05c0f9251ece747
--- /dev/null
+++ b/web/modules/scheduler/src/SchedulerPermissions.php
@@ -0,0 +1,95 @@
+<?php
+
+namespace Drupal\scheduler;
+
+use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Provides dynamic permissions for scheduler plugins.
+ */
+class SchedulerPermissions implements ContainerInjectionInterface {
+
+  use StringTranslationTrait;
+
+  /**
+   * The scheduler manager service.
+   *
+   * @var SchedulerManager
+   */
+  private $schedulerManager;
+
+  /**
+   * The entity type manager.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * Constructs a \Drupal\scheduler\SchedulerPermissions instance.
+   *
+   * @param \Drupal\scheduler\SchedulerManager $scheduler_manager
+   *   The entity type manager.
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+   *   The entity type manager.
+   */
+  public function __construct(SchedulerManager $scheduler_manager, EntityTypeManagerInterface $entity_type_manager) {
+    $this->schedulerManager = $scheduler_manager;
+    $this->entityTypeManager = $entity_type_manager;
+  }
+
+  /**
+   * {@inheritDoc}
+   */
+  public static function create(ContainerInterface $container) {
+    return new static($container->get('scheduler.manager'), $container->get('entity_type.manager'));
+  }
+
+  /**
+   * Build permissions for each entity type.
+   *
+   * SchedulerManager function permissionName() can be used to return the
+   * permission name for a given entity type and permission type.
+   *
+   * @return array|array[]
+   *   The full list of permissions to schedule and to view each entity type.
+   */
+  public function permissions() {
+    $permissions = [];
+    $types = $this->schedulerManager->getPluginEntityTypes();
+    foreach ($types as $entity_type_id) {
+      $entity_type = $this->entityTypeManager->getDefinition($entity_type_id);
+      // For backwards-compatibility with existing permissions, the node
+      // permission names have to end with 'nodes' and 'content'. For all other
+      // entity types we use $entity_type_id for both permissions.
+      if ($entity_type_id == 'node') {
+        $edit_key = 'nodes';
+        $view_key = 'content';
+      }
+      else {
+        $edit_key = $view_key = $entity_type_id;
+      }
+      $t_args = [
+        '%label' => $entity_type->getLabel(),
+        '%singular_label' => $entity_type->getSingularLabel(),
+        '%plural_label' => $entity_type->getPluralLabel(),
+      ];
+
+      $permissions += [
+        "schedule publishing of $edit_key"  => [
+          'title' => $this->t('Schedule publishing and unpublishing of %label', $t_args),
+          'description' => $this->t('Allows users to set a start and end time for %singular_label publication.', $t_args),
+        ],
+        "view scheduled $view_key" => [
+          'title' => $this->t('View scheduled %label', $t_args),
+          'description' => $this->t('Allows users to see a list of all %plural_label that are scheduled.', $t_args),
+        ],
+      ];
+    }
+    return $permissions;
+  }
+
+}
diff --git a/web/modules/scheduler/src/SchedulerPluginBase.php b/web/modules/scheduler/src/SchedulerPluginBase.php
new file mode 100644
index 0000000000000000000000000000000000000000..fb5ca8facda06b49450f85374fc5f0ef9e9ba0a8
--- /dev/null
+++ b/web/modules/scheduler/src/SchedulerPluginBase.php
@@ -0,0 +1,282 @@
+<?php
+
+namespace Drupal\scheduler;
+
+use Drupal\Core\Entity\EntityDisplayRepositoryInterface;
+use Drupal\Core\Plugin\PluginBase;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Base class for scheduler plugins.
+ */
+abstract class SchedulerPluginBase extends PluginBase implements SchedulerPluginInterface {
+
+  /**
+   * The entity type manager.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * The entity type object for this plugin.
+   *
+   * @var Drupal\Core\Config\Entity\ConfigEntityType
+   */
+  protected $entityTypeObject;
+
+  /**
+   * A static cache of create/edit entity form IDs.
+   *
+   * @var string[]
+   */
+  protected $entityFormIds;
+
+  /**
+   * A static cache of create/edit entity type form IDs.
+   *
+   * @var string[]
+   */
+  protected $entityTypeFormIds;
+
+  /**
+   * Create method.
+   */
+  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
+    $instance = new static($configuration, $plugin_id, $plugin_definition);
+    $instance->entityTypeManager = $container->get('entity_type.manager');
+    $instance->entityTypeObject = $instance->entityTypeManager
+      ->getDefinition($plugin_definition['entityType']);
+
+    return $instance;
+  }
+
+  /**
+   * Get plugin label.
+   *
+   * @return string
+   *   The label.
+   */
+  public function label() {
+    return $this->pluginDefinition['label'];
+  }
+
+  /**
+   * Get the plugin description.
+   *
+   * @inheritDoc
+   */
+  public function description() {
+    return $this->pluginDefinition['description'];
+  }
+
+  /**
+   * Get the type of entity supported by this plugin.
+   *
+   * @return string
+   *   The name of the entity type.
+   */
+  public function entityType() {
+    return $this->pluginDefinition['entityType'];
+  }
+
+  /**
+   * Get the entity type object supported by this plugin.
+   *
+   * @return Drupal\Core\Config\Entity\ConfigEntityType
+   *   The entity type object.
+   */
+  public function entityTypeObject() {
+    return $this->entityTypeObject;
+  }
+
+  /**
+   * Get module dependency.
+   *
+   * @return string
+   *   The name of the required module.
+   */
+  public function dependency() {
+    return $this->pluginDefinition['dependency'];
+  }
+
+  /**
+   * Get the id of the Devel Generate form for this entity type.
+   *
+   * @return string
+   *   The form id, or an empty string if none.
+   */
+  public function develGenerateForm() {
+    return $this->pluginDefinition['develGenerateForm'];
+  }
+
+  /**
+   * Get the route of the entity collection page.
+   *
+   * @return string
+   *   The route. Defaults to entity.{entityType}.collection.
+   */
+  public function collectionRoute() {
+    return $this->pluginDefinition['collectionRoute'] ?? "entity.{$this->entityType()}.collection";
+  }
+
+  /**
+   * Get the route of the scheduled view on the user profile page.
+   *
+   * @return string
+   *   The route, or blank if none.
+   */
+  public function userViewRoute() {
+    return $this->pluginDefinition['userViewRoute'];
+  }
+
+  /**
+   * Get the Scheduler event class.
+   *
+   * @return string
+   *   The event class.
+   */
+  public function schedulerEventClass() {
+    // If no event class is defined in the plugin then it will default to
+    // '\Drupal\scheduler\Event\Scheduler{entityType}Events'. Specifying an
+    // event class is only required when the entityType value contains an
+    // underscore because that produces an invalid class name.
+    $class = $this->pluginDefinition['schedulerEventClass'] ??
+      '\Drupal\scheduler\Event\Scheduler' . ucfirst($this->entityType()) . 'Events';
+    return $class;
+  }
+
+  /**
+   * Get the publish action name of the entity type.
+   *
+   * If no value is given in the plugin annotation then default to the commonly
+   * used {entity type id}_publish_action.
+   *
+   * @return string
+   *   The action name.
+   */
+  public function publishAction() {
+    return $this->pluginDefinition['publishAction'] ?? $this->entityType() . '_publish_action';
+  }
+
+  /**
+   * Get the unpublish action name of the entity type.
+   *
+   * If no value is given in the plugin annotation then default to the commonly
+   * used {entity type id}_unpublish_action.
+   *
+   * @return string
+   *   The action name.
+   */
+  public function unpublishAction() {
+    return $this->pluginDefinition['unpublishAction'] ?? $this->entityType() . '_unpublish_action';
+  }
+
+  /**
+   * Get the field name for the 'type' or 'bundle'.
+   *
+   * @return string
+   *   The name of the type/bundle field for this entity type.
+   */
+  public function typeFieldName() {
+    return $this->entityTypeObject->getKey('bundle');
+  }
+
+  /**
+   * Get all the type/bundle objects for this entity.
+   *
+   * @return array
+   *   The type/bundle objects, keyed by type/bundle name.
+   */
+  public function getTypes() {
+    $bundleEntityType = $this->entityTypeObject->getBundleEntityType();
+
+    return $this->entityTypeManager
+      ->getStorage($bundleEntityType)
+      ->loadMultiple();
+  }
+
+  /**
+   * Get the form IDs for entity add/edit forms.
+   */
+  public function entityFormIds() {
+    if (isset($this->entityFormIds)) {
+      return $this->entityFormIds;
+    }
+
+    return $this->entityFormIds = $this->entityFormIdsByType($this->entityType(), FALSE);
+  }
+
+  /**
+   * Get the form IDs for entity type add/edit forms.
+   */
+  public function entityTypeFormIds() {
+    if (isset($this->entityTypeFormIds)) {
+      return $this->entityTypeFormIds;
+    }
+
+    $bundleEntityType = $this->entityTypeObject->getBundleEntityType();
+
+    return $this->entityTypeFormIds = $this->entityFormIdsByType($bundleEntityType, TRUE);
+  }
+
+  /**
+   * Get the form IDs for the add/edit forms of a certain entity type.
+   *
+   * The logic for this function is based on EntityForm::getFormId.
+   *
+   * @param string $entityType
+   *   The entity type for which to return the form ids.
+   * @param bool $isBundle
+   *   TRUE if this is the entity type/bundle form.
+   *
+   * @see \Drupal\Core\Entity\EntityForm::getFormId()
+   */
+  protected function entityFormIdsByType(string $entityType, bool $isBundle): array {
+    $ids = [];
+    $definition = $this->entityTypeManager->getDefinition($entityType);
+    $operations = [];
+
+    // Some entity types, such as node, do not have 'add' in the add form id.
+    if ($definition->getFormClass('add')) {
+      $operations[] = 'add';
+    }
+    else {
+      $operations[] = 'default';
+    }
+    // Some entity types, for example taxonomy_vocabulary and taxonomy_term, do
+    // not have a separate edit form.
+    if ($definition->getFormClass('edit')) {
+      $operations[] = 'edit';
+    }
+
+    // When creating the first type/bundle there will be nothing returned for
+    // $this->getTypes(). This is only a problem when getting the 'type' forms,
+    // which do not actually need the list of types anyway. Hence for this case
+    // we need an element in $types, one is enough and it can be anything.
+    $types = $isBundle ? [''] : array_keys($this->getTypes());
+    foreach ($types as $typeId) {
+      foreach ($operations as $operation) {
+        $form_id = $entityType;
+        // Do not add typeId for the entity type forms.
+        if ($definition->hasKey('bundle')) {
+          $form_id .= '_' . $typeId;
+        }
+        if ($operation != 'default') {
+          $form_id .= '_' . $operation;
+        }
+        $ids[] = $form_id . '_form';
+      }
+    }
+
+    return array_unique($ids);
+  }
+
+  /**
+   * Return all supported entity form display modes.
+   */
+  public function entityFormDisplayModes() {
+    return [EntityDisplayRepositoryInterface::DEFAULT_DISPLAY_MODE];
+  }
+
+}
diff --git a/web/modules/scheduler/src/SchedulerPluginInterface.php b/web/modules/scheduler/src/SchedulerPluginInterface.php
new file mode 100644
index 0000000000000000000000000000000000000000..050ab7fb779ba55fe1efe763ff2eeb74290b8f6a
--- /dev/null
+++ b/web/modules/scheduler/src/SchedulerPluginInterface.php
@@ -0,0 +1,148 @@
+<?php
+
+namespace Drupal\scheduler;
+
+/**
+ * Interface for Scheduler entity plugin definition.
+ */
+interface SchedulerPluginInterface {
+
+  /**
+   * Get the label.
+   *
+   * @return mixed
+   *   The label.
+   */
+  public function label();
+
+  /**
+   * Get the description.
+   *
+   * @return mixed
+   *   The description.
+   */
+  public function description();
+
+  /**
+   * Get the type of entity supported by this plugin.
+   *
+   * @return string
+   *   The name of the entity type.
+   */
+  public function entityType();
+
+  /**
+   * Get the name of the "type" field for the entity.
+   *
+   * @return string
+   *   The name of the type/bundle field for this entity type.
+   */
+  public function typeFieldName();
+
+  /**
+   * Get module dependency.
+   *
+   * @return string
+   *   The name of the required module.
+   */
+  public function dependency();
+
+  /**
+   * Get the id of the Devel Generate form for this entity type. Optional.
+   *
+   * @return string
+   *   The form id.
+   */
+  public function develGenerateForm();
+
+  /**
+   * Get the route of the entity collection page.
+   *
+   * Optional. Defaults to entity.{entity type id}.collection.
+   *
+   * @return string
+   *   The route id.
+   */
+  public function collectionRoute();
+
+  /**
+   * Get the route of the user page scheduled view. Optional.
+   *
+   * @return string
+   *   The route id.
+   */
+  public function userViewRoute();
+
+  /**
+   * Get the scheduler event class.
+   *
+   * Optional. Defaults to '\Drupal\scheduler\Event\Scheduler{Type}Events' the
+   * event class within the Scheduler module namespace.
+   *
+   * @return string
+   *   The event class.
+   */
+  public function schedulerEventClass();
+
+  /**
+   * Get the publish action name of the entity type.
+   *
+   * Optional. Defaults to the commonly used {entity type id}_publish_action.
+   *
+   * @return string
+   *   The action name.
+   */
+  public function publishAction();
+
+  /**
+   * Get the unpublish action name of the entity type.
+   *
+   * Optional. Defaults to the commonly used {entity type id}_unpublish_action.
+   *
+   * @return string
+   *   The action name.
+   */
+  public function unpublishAction();
+
+  /**
+   * Get all the type/bundle objects for this entity.
+   *
+   * @return array
+   *   The type/bundle objects.
+   */
+  public function getTypes();
+
+  /**
+   * Get the form IDs for entity add/edit forms.
+   *
+   * @return array
+   *   A list of add/edit form ids for all bundles in this entity type.
+   */
+  public function entityFormIds();
+
+  /**
+   * Get the form IDs for entity type add/edit forms.
+   *
+   * @return array
+   *   A list of add/edit form ids for this entity type.
+   */
+  public function entityTypeFormIds();
+
+  /**
+   * Return all supported entity edit form display modes.
+   *
+   * \Drupal\Core\Entity\EntityDisplayRepositoryInterface::DEFAULT_DISPLAY_MODE
+   * is the 'default' display mode and this is always supported. If there are no
+   * other supported modes then this function does not need to be implemented in
+   * the plugin. However if additional form display modes are provided by other
+   * modules and Scheduler has been updated to support these modes for editting
+   * the entity, then the plugin implementaion of this function should return
+   * all supported modes including 'default'. The implementation does not need
+   * to check if the third-party module is actually available or enabled.
+   *
+   * @return array
+   *   A list of entity form display mode ids.
+   */
+  public function entityFormDisplayModes();
+
+}
diff --git a/web/modules/scheduler/src/SchedulerPluginManager.php b/web/modules/scheduler/src/SchedulerPluginManager.php
new file mode 100755
index 0000000000000000000000000000000000000000..5e215ae37b6b122a52cd99aa2c830a496325d1b9
--- /dev/null
+++ b/web/modules/scheduler/src/SchedulerPluginManager.php
@@ -0,0 +1,75 @@
+<?php
+
+namespace Drupal\scheduler;
+
+use Drupal\Core\Cache\CacheBackendInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Extension\ModuleHandlerInterface;
+use Drupal\Core\Plugin\DefaultPluginManager;
+use Drupal\scheduler\Annotation\SchedulerPlugin;
+
+/**
+ * Provides a Scheduler Plugin Manager.
+ *
+ * @package Drupal\scheduler
+ */
+class SchedulerPluginManager extends DefaultPluginManager {
+
+  /**
+   * The entity type manager.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * Constructs a new SchedulerPluginManager object.
+   */
+  public function __construct(
+    \Traversable $namespaces,
+    CacheBackendInterface $cacheBackend,
+    ModuleHandlerInterface $module_handler,
+    EntityTypeManagerInterface $entity_type_manager
+  ) {
+    $subdir = 'Plugin/Scheduler';
+    $plugin_interface = SchedulerPluginInterface::class;
+    $plugin_definition_annotation_name = SchedulerPlugin::class;
+
+    parent::__construct(
+      $subdir,
+      $namespaces,
+      $module_handler,
+      $plugin_interface,
+      $plugin_definition_annotation_name
+    );
+
+    $this->alterInfo('scheduler_info');
+    $this->setCacheBackend($cacheBackend, 'scheduler_info');
+    $this->entityTypeManager = $entity_type_manager;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function findDefinitions() {
+    // This is an overridden method from the parent class. This version filters
+    // out plugins with missing module and entity type depencencies before they
+    // are initialized, removing the need for separate checks per plugin.
+    $definitions = parent::findDefinitions();
+
+    foreach ($definitions as $plugin_id => $plugin_definition) {
+      if (!empty($plugin_definition['dependency']) && !$this->moduleHandler->moduleExists($plugin_definition['dependency'])) {
+        unset($definitions[$plugin_id]);
+        continue;
+      }
+
+      $entityType = $this->entityTypeManager->getDefinition($plugin_definition['entityType'], FALSE);
+      if (!$entityType || !$entityType->getBundleEntityType()) {
+        unset($definitions[$plugin_id]);
+      }
+    }
+
+    return $definitions;
+  }
+
+}
diff --git a/web/modules/scheduler/src/Theme/SchedulerThemeNegotiator.php b/web/modules/scheduler/src/Theme/SchedulerThemeNegotiator.php
index 11f5a454babfcbe92e1cb5591b87390b4a890b41..33eec06a75dfd1108fddbda3d4395ee45f39d6b5 100644
--- a/web/modules/scheduler/src/Theme/SchedulerThemeNegotiator.php
+++ b/web/modules/scheduler/src/Theme/SchedulerThemeNegotiator.php
@@ -14,8 +14,9 @@ class SchedulerThemeNegotiator implements ThemeNegotiatorInterface {
    * {@inheritdoc}
    */
   public function applies(RouteMatchInterface $route_match) {
-    // Use the Scheduler theme negotiator for the user 'scheduled' tab.
-    $applies = ($route_match->getRouteName() == 'view.scheduler_scheduled_content.user_page');
+    // Use the Scheduler theme negotiator for scheduler views on the user page.
+    $user_page_routes = \Drupal::service('scheduler.manager')->getUserPageViewRoutes();
+    $applies = (in_array($route_match->getRouteName(), $user_page_routes));
     return $applies;
   }
 
@@ -23,10 +24,12 @@ public function applies(RouteMatchInterface $route_match) {
    * {@inheritdoc}
    */
   public function determineActiveTheme(RouteMatchInterface $route_match) {
-    // Return the admin theme.
     $config = \Drupal::service('config.factory')->getEditable('system.theme');
     $admin_theme = $config->get('admin');
-    return $admin_theme;
+    // Return the admin theme only if the user has permission to use it.
+    if (\Drupal::currentUser()->hasPermission('view the administration theme')) {
+      return $admin_theme;
+    }
   }
 
 }
diff --git a/web/modules/scheduler/tests/fixtures/node_type_config.php b/web/modules/scheduler/tests/fixtures/node_type_config.php
new file mode 100644
index 0000000000000000000000000000000000000000..f488fb0688bc768c74a73fb6358d735beaf74bc6
--- /dev/null
+++ b/web/modules/scheduler/tests/fixtures/node_type_config.php
@@ -0,0 +1,122 @@
+<?php
+
+/**
+ * @file
+ * DB fixture for scheduler node type migration tests on top of core's fixture.
+ */
+
+use Drupal\Core\Database\Database;
+
+$connection = Database::getConnection();
+
+$connection->insert('system')
+  ->fields([
+    'filename',
+    'name',
+    'type',
+    'owner',
+    'status',
+    'bootstrap',
+    'schema_version',
+    'weight',
+    'info',
+  ])
+  ->values([
+    'filename' => 'sites/all/modules/contrib/scheduler/scheduler.module',
+    'name' => 'scheduler',
+    'type' => 'module',
+    'owner' => '',
+    'status' => '1',
+    'bootstrap' => '0',
+    'schema_version' => '7103',
+    'weight' => '0',
+    'info' => 'a:14:{s:4:\"name\";s:9:\"Scheduler\";s:11:\"description\";s:85:\"This module allows nodes to be published and unpublished on specified dates and time.\";s:4:\"core\";s:3:\"7.x\";s:9:\"configure\";s:30:\"admin/config/content/scheduler\";s:5:\"files\";a:3:{i:0;s:47:\"scheduler_handler_field_scheduler_countdown.inc\";i:1;s:20:\"tests/scheduler.test\";i:2;s:24:\"tests/scheduler_api.test\";}s:17:\"test_dependencies\";a:2:{i:0;s:4:\"date\";i:1;s:5:\"rules\";}s:7:\"version\";s:7:\"7.x-1.6\";s:7:\"project\";s:9:\"scheduler\";s:9:\"datestamp\";s:10:\"1600171819\";s:5:\"mtime\";i:1600171819;s:12:\"dependencies\";a:0:{}s:7:\"package\";s:5:\"Other\";s:3:\"php\";s:5:\"5.2.4\";s:9:\"bootstrap\";i:0;}',
+  ])
+  ->execute();
+
+$connection->insert('variable')
+  ->fields([
+    'name',
+    'value',
+  ])
+  ->values([
+    'name' => 'scheduler_expand_fieldset_article',
+    'value' => 's:1:"0";',
+  ])
+  ->values([
+    'name' => 'scheduler_expand_fieldset_page',
+    'value' => 's:1:"1";',
+  ])
+  ->values([
+    'name' => 'scheduler_publish_enable_article',
+    'value' => 'i:1;',
+  ])
+  ->values([
+    'name' => 'scheduler_publish_enable_page',
+    'value' => 'i:1;',
+  ])
+  ->values([
+    'name' => 'scheduler_publish_past_date_article',
+    'value' => 's:5:"error";',
+  ])
+  ->values([
+    'name' => 'scheduler_publish_past_date_page',
+    'value' => 's:7:"publish";',
+  ])
+  ->values([
+    'name' => 'scheduler_publish_required_article',
+    'value' => 'i:1;',
+  ])
+  ->values([
+    'name' => 'scheduler_publish_required_page',
+    'value' => 'i:0;',
+  ])
+  ->values([
+    'name' => 'scheduler_publish_revision_article',
+    'value' => 'i:1;',
+  ])
+  ->values([
+    'name' => 'scheduler_publish_revision_page',
+    'value' => 'i:0;',
+  ])
+  ->values([
+    'name' => 'scheduler_publish_touch_article',
+    'value' => 'i:0;',
+  ])
+  ->values([
+    'name' => 'scheduler_publish_touch_page',
+    'value' => 'i:1;',
+  ])
+  ->values([
+    'name' => 'scheduler_unpublish_enable_article',
+    'value' => 'i:1;',
+  ])
+  ->values([
+    'name' => 'scheduler_unpublish_enable_page',
+    'value' => 'i:0;',
+  ])
+  ->values([
+    'name' => 'scheduler_unpublish_required_article',
+    'value' => 'i:1;',
+  ])
+  ->values([
+    'name' => 'scheduler_unpublish_required_page',
+    'value' => 'i:0;',
+  ])
+  ->values([
+    'name' => 'scheduler_unpublish_revision_article',
+    'value' => 'i:1;',
+  ])
+  ->values([
+    'name' => 'scheduler_unpublish_revision_page',
+    'value' => 'i:0;',
+  ])
+  ->values([
+    'name' => 'scheduler_use_vertical_tabs_article',
+    'value' => 's:1:"1";',
+  ])
+  ->values([
+    'name' => 'scheduler_use_vertical_tabs_page',
+    'value' => 's:1:"0";',
+  ])
+  ->execute();
diff --git a/web/modules/scheduler/tests/fixtures/scheduler_config.php b/web/modules/scheduler/tests/fixtures/scheduler_config.php
new file mode 100644
index 0000000000000000000000000000000000000000..fc08e2599bf67fe77654e318fc2181c7f0ce9b70
--- /dev/null
+++ b/web/modules/scheduler/tests/fixtures/scheduler_config.php
@@ -0,0 +1,54 @@
+<?php
+
+/**
+ * @file
+ * DB fixture for scheduler migration tests on top of core's fixture.
+ */
+
+use Drupal\Core\Database\Database;
+
+$connection = Database::getConnection();
+
+$connection->insert('system')
+  ->fields([
+    'filename',
+    'name',
+    'type',
+    'owner',
+    'status',
+    'bootstrap',
+    'schema_version',
+    'weight',
+    'info',
+  ])
+  ->values([
+    'filename' => 'sites/all/modules/contrib/scheduler/scheduler.module',
+    'name' => 'scheduler',
+    'type' => 'module',
+    'owner' => '',
+    'status' => '1',
+    'bootstrap' => '0',
+    'schema_version' => '7103',
+    'weight' => '0',
+    'info' => 'a:14:{s:4:\"name\";s:9:\"Scheduler\";s:11:\"description\";s:85:\"This module allows nodes to be published and unpublished on specified dates and time.\";s:4:\"core\";s:3:\"7.x\";s:9:\"configure\";s:30:\"admin/config/content/scheduler\";s:5:\"files\";a:3:{i:0;s:47:\"scheduler_handler_field_scheduler_countdown.inc\";i:1;s:20:\"tests/scheduler.test\";i:2;s:24:\"tests/scheduler_api.test\";}s:17:\"test_dependencies\";a:2:{i:0;s:4:\"date\";i:1;s:5:\"rules\";}s:7:\"version\";s:7:\"7.x-1.6\";s:7:\"project\";s:9:\"scheduler\";s:9:\"datestamp\";s:10:\"1600171819\";s:5:\"mtime\";i:1600171819;s:12:\"dependencies\";a:0:{}s:7:\"package\";s:5:\"Other\";s:3:\"php\";s:5:\"5.2.4\";s:9:\"bootstrap\";i:0;}',
+  ])
+  ->execute();
+
+$connection->insert('variable')
+  ->fields([
+    'name',
+    'value',
+  ])
+  ->values([
+    'name' => 'scheduler_allow_date_only',
+    'value' => 'i:1;',
+  ])
+  ->values([
+    'name' => 'scheduler_default_time',
+    'value' => 's:8:"00:00:38";',
+  ])
+  ->values([
+    'name' => 'scheduler_date_format',
+    'value' => 's:9:"Y-m-d H:i";',
+  ])
+  ->execute();
diff --git a/web/modules/scheduler/tests/fixtures/scheduler_data.php b/web/modules/scheduler/tests/fixtures/scheduler_data.php
new file mode 100644
index 0000000000000000000000000000000000000000..7eab2abacbd66cd57fa134051a8d679b7672c1ac
--- /dev/null
+++ b/web/modules/scheduler/tests/fixtures/scheduler_data.php
@@ -0,0 +1,18 @@
+<?php
+
+/**
+ * @file
+ * Include all database dump files.
+ */
+
+include 'scheduler_data' . DIRECTORY_SEPARATOR . 'field_config.php';
+include 'scheduler_data' . DIRECTORY_SEPARATOR . 'field_config_instance.php';
+include 'scheduler_data' . DIRECTORY_SEPARATOR . 'node.php';
+include 'scheduler_data' . DIRECTORY_SEPARATOR . 'node_revision.php';
+include 'scheduler_data' . DIRECTORY_SEPARATOR . 'node_type.php';
+include 'scheduler_data' . DIRECTORY_SEPARATOR . 'role.php';
+include 'scheduler_data' . DIRECTORY_SEPARATOR . 'role_permission.php';
+include 'scheduler_data' . DIRECTORY_SEPARATOR . 'scheduler.php';
+include 'scheduler_data' . DIRECTORY_SEPARATOR . 'system.php';
+include 'scheduler_data' . DIRECTORY_SEPARATOR . 'users.php';
+include 'scheduler_data' . DIRECTORY_SEPARATOR . 'variable.php';
diff --git a/web/modules/scheduler/tests/fixtures/scheduler_data/field_config.php b/web/modules/scheduler/tests/fixtures/scheduler_data/field_config.php
new file mode 100644
index 0000000000000000000000000000000000000000..d2f4f05e9993a67ec2259b6531f81171451a4b83
--- /dev/null
+++ b/web/modules/scheduler/tests/fixtures/scheduler_data/field_config.php
@@ -0,0 +1,120 @@
+<?php
+
+/**
+ * @file
+ * A database agnostic dump for testing purposes.
+ *
+ * This file was generated by the Drupal 9.2.6 db-tools.php script.
+ */
+
+use Drupal\Core\Database\Database;
+
+$connection = Database::getConnection();
+
+$connection->schema()->createTable('field_config', [
+  'fields' => [
+    'id' => [
+      'type' => 'serial',
+      'not null' => TRUE,
+      'size' => 'normal',
+    ],
+    'field_name' => [
+      'type' => 'varchar',
+      'not null' => TRUE,
+      'length' => '32',
+    ],
+    'type' => [
+      'type' => 'varchar',
+      'not null' => TRUE,
+      'length' => '128',
+    ],
+    'module' => [
+      'type' => 'varchar',
+      'not null' => TRUE,
+      'length' => '128',
+      'default' => '',
+    ],
+    'active' => [
+      'type' => 'int',
+      'not null' => TRUE,
+      'size' => 'tiny',
+      'default' => '0',
+    ],
+    'storage_type' => [
+      'type' => 'varchar',
+      'not null' => TRUE,
+      'length' => '128',
+    ],
+    'storage_module' => [
+      'type' => 'varchar',
+      'not null' => TRUE,
+      'length' => '128',
+      'default' => '',
+    ],
+    'storage_active' => [
+      'type' => 'int',
+      'not null' => TRUE,
+      'size' => 'tiny',
+      'default' => '0',
+    ],
+    'locked' => [
+      'type' => 'int',
+      'not null' => TRUE,
+      'size' => 'tiny',
+      'default' => '0',
+    ],
+    'data' => [
+      'type' => 'blob',
+      'not null' => TRUE,
+      'size' => 'big',
+    ],
+    'cardinality' => [
+      'type' => 'int',
+      'not null' => TRUE,
+      'size' => 'tiny',
+      'default' => '0',
+    ],
+    'translatable' => [
+      'type' => 'int',
+      'not null' => TRUE,
+      'size' => 'tiny',
+      'default' => '0',
+    ],
+    'deleted' => [
+      'type' => 'int',
+      'not null' => TRUE,
+      'size' => 'tiny',
+      'default' => '0',
+    ],
+  ],
+  'primary key' => [
+    'id',
+  ],
+  'indexes' => [
+    'field_name' => [
+      'field_name',
+    ],
+    'active' => [
+      'active',
+    ],
+    'storage_active' => [
+      'storage_active',
+    ],
+    'deleted' => [
+      'deleted',
+    ],
+    'module' => [
+      'module',
+    ],
+    'storage_module' => [
+      'storage_module',
+    ],
+    'type' => [
+      'type',
+    ],
+    'storage_type' => [
+      'storage_type',
+    ],
+  ],
+  'mysql_character_set' => 'utf8mb3',
+]);
diff --git a/web/modules/scheduler/tests/fixtures/scheduler_data/field_config_instance.php b/web/modules/scheduler/tests/fixtures/scheduler_data/field_config_instance.php
new file mode 100644
index 0000000000000000000000000000000000000000..e0713c82d4c0426bdccf03dc09bab001cb44a42a
--- /dev/null
+++ b/web/modules/scheduler/tests/fixtures/scheduler_data/field_config_instance.php
@@ -0,0 +1,70 @@
+<?php
+
+/**
+ * @file
+ * A database agnostic dump for testing purposes.
+ *
+ * This file was generated by the Drupal 9.2.6 db-tools.php script.
+ */
+
+use Drupal\Core\Database\Database;
+
+$connection = Database::getConnection();
+
+$connection->schema()->createTable('field_config_instance', [
+  'fields' => [
+    'id' => [
+      'type' => 'serial',
+      'not null' => TRUE,
+      'size' => 'normal',
+    ],
+    'field_id' => [
+      'type' => 'int',
+      'not null' => TRUE,
+      'size' => 'normal',
+    ],
+    'field_name' => [
+      'type' => 'varchar',
+      'not null' => TRUE,
+      'length' => '32',
+      'default' => '',
+    ],
+    'entity_type' => [
+      'type' => 'varchar',
+      'not null' => TRUE,
+      'length' => '32',
+      'default' => '',
+    ],
+    'bundle' => [
+      'type' => 'varchar',
+      'not null' => TRUE,
+      'length' => '128',
+      'default' => '',
+    ],
+    'data' => [
+      'type' => 'blob',
+      'not null' => TRUE,
+      'size' => 'big',
+    ],
+    'deleted' => [
+      'type' => 'int',
+      'not null' => TRUE,
+      'size' => 'tiny',
+      'default' => '0',
+    ],
+  ],
+  'primary key' => [
+    'id',
+  ],
+  'indexes' => [
+    'field_name_bundle' => [
+      'field_name',
+      'entity_type',
+      'bundle',
+    ],
+    'deleted' => [
+      'deleted',
+    ],
+  ],
+  'mysql_character_set' => 'utf8mb3',
+]);
diff --git a/web/modules/scheduler/tests/fixtures/scheduler_data/node.php b/web/modules/scheduler/tests/fixtures/scheduler_data/node.php
new file mode 100644
index 0000000000000000000000000000000000000000..62c05b746b07e615f469f815cea2757774b815b2
--- /dev/null
+++ b/web/modules/scheduler/tests/fixtures/scheduler_data/node.php
@@ -0,0 +1,222 @@
+<?php
+
+/**
+ * @file
+ * A database agnostic dump for testing purposes.
+ *
+ * This file was generated by the Drupal 9.2.6 db-tools.php script.
+ */
+
+use Drupal\Core\Database\Database;
+
+$connection = Database::getConnection();
+
+$connection->schema()->createTable('node', [
+  'fields' => [
+    'nid' => [
+      'type' => 'serial',
+      'not null' => TRUE,
+      'size' => 'normal',
+      'unsigned' => TRUE,
+    ],
+    'vid' => [
+      'type' => 'int',
+      'not null' => FALSE,
+      'size' => 'normal',
+      'unsigned' => TRUE,
+    ],
+    'type' => [
+      'type' => 'varchar',
+      'not null' => TRUE,
+      'length' => '32',
+      'default' => '',
+    ],
+    'language' => [
+      'type' => 'varchar',
+      'not null' => TRUE,
+      'length' => '12',
+      'default' => '',
+    ],
+    'title' => [
+      'type' => 'varchar',
+      'not null' => TRUE,
+      'length' => '255',
+      'default' => '',
+    ],
+    'uid' => [
+      'type' => 'int',
+      'not null' => TRUE,
+      'size' => 'normal',
+      'default' => '0',
+    ],
+    'status' => [
+      'type' => 'int',
+      'not null' => TRUE,
+      'size' => 'normal',
+      'default' => '1',
+    ],
+    'created' => [
+      'type' => 'int',
+      'not null' => TRUE,
+      'size' => 'normal',
+      'default' => '0',
+    ],
+    'changed' => [
+      'type' => 'int',
+      'not null' => TRUE,
+      'size' => 'normal',
+      'default' => '0',
+    ],
+    'comment' => [
+      'type' => 'int',
+      'not null' => TRUE,
+      'size' => 'normal',
+      'default' => '0',
+    ],
+    'promote' => [
+      'type' => 'int',
+      'not null' => TRUE,
+      'size' => 'normal',
+      'default' => '0',
+    ],
+    'sticky' => [
+      'type' => 'int',
+      'not null' => TRUE,
+      'size' => 'normal',
+      'default' => '0',
+    ],
+    'tnid' => [
+      'type' => 'int',
+      'not null' => TRUE,
+      'size' => 'normal',
+      'default' => '0',
+      'unsigned' => TRUE,
+    ],
+    'translate' => [
+      'type' => 'int',
+      'not null' => TRUE,
+      'size' => 'normal',
+      'default' => '0',
+    ],
+  ],
+  'primary key' => [
+    'nid',
+  ],
+  'unique keys' => [
+    'vid' => [
+      'vid',
+    ],
+  ],
+  'indexes' => [
+    'node_changed' => [
+      'changed',
+    ],
+    'node_created' => [
+      'created',
+    ],
+    'node_frontpage' => [
+      'promote',
+      'status',
+      'sticky',
+      'created',
+    ],
+    'node_status_type' => [
+      'status',
+      'type',
+      'nid',
+    ],
+    'node_title_type' => [
+      'title',
+      [
+        'type',
+        '4',
+      ],
+    ],
+    'node_type' => [
+      [
+        'type',
+        '4',
+      ],
+    ],
+    'uid' => [
+      'uid',
+    ],
+    'tnid' => [
+      'tnid',
+    ],
+    'translate' => [
+      'translate',
+    ],
+    'language' => [
+      'language',
+    ],
+  ],
+  'mysql_character_set' => 'utf8mb3',
+]);
+
+$connection->insert('node')
+  ->fields([
+    'nid',
+    'vid',
+    'type',
+    'language',
+    'title',
+    'uid',
+    'status',
+    'created',
+    'changed',
+    'comment',
+    'promote',
+    'sticky',
+    'tnid',
+    'translate',
+  ])
+  ->values([
+    'nid' => '1',
+    'vid' => '1',
+    'type' => 'article',
+    'language' => 'und',
+    'title' => 'Article one',
+    'uid' => '1',
+    'status' => '0',
+    'created' => '1647492702',
+    'changed' => '1647492702',
+    'comment' => '2',
+    'promote' => '1',
+    'sticky' => '0',
+    'tnid' => '0',
+    'translate' => '0',
+  ])
+  ->values([
+    'nid' => '2',
+    'vid' => '2',
+    'type' => 'article',
+    'language' => 'und',
+    'title' => 'Article two',
+    'uid' => '1',
+    'status' => '0',
+    'created' => '1647492753',
+    'changed' => '1647492753',
+    'comment' => '2',
+    'promote' => '1',
+    'sticky' => '0',
+    'tnid' => '0',
+    'translate' => '0',
+  ])
+  ->values([
+    'nid' => '3',
+    'vid' => '3',
+    'type' => 'article',
+    'language' => 'und',
+    'title' => 'Article three',
+    'uid' => '1',
+    'status' => '1',
+    'created' => '1647492779',
+    'changed' => '1647492779',
+    'comment' => '2',
+    'promote' => '1',
+    'sticky' => '0',
+    'tnid' => '0',
+    'translate' => '0',
+  ])
+  ->execute();
diff --git a/web/modules/scheduler/tests/fixtures/scheduler_data/node_revision.php b/web/modules/scheduler/tests/fixtures/scheduler_data/node_revision.php
new file mode 100644
index 0000000000000000000000000000000000000000..34a9d36733dc121d66fccabab44eb639404df578
--- /dev/null
+++ b/web/modules/scheduler/tests/fixtures/scheduler_data/node_revision.php
@@ -0,0 +1,140 @@
+<?php
+
+/**
+ * @file
+ * A database agnostic dump for testing purposes.
+ *
+ * This file was generated by the Drupal 9.2.6 db-tools.php script.
+ */
+
+use Drupal\Core\Database\Database;
+
+$connection = Database::getConnection();
+
+$connection->schema()->createTable('node_revision', [
+  'fields' => [
+    'nid' => [
+      'type' => 'int',
+      'not null' => TRUE,
+      'size' => 'normal',
+      'default' => '0',
+      'unsigned' => TRUE,
+    ],
+    'vid' => [
+      'type' => 'serial',
+      'not null' => TRUE,
+      'size' => 'normal',
+      'unsigned' => TRUE,
+    ],
+    'uid' => [
+      'type' => 'int',
+      'not null' => TRUE,
+      'size' => 'normal',
+      'default' => '0',
+    ],
+    'title' => [
+      'type' => 'varchar',
+      'not null' => TRUE,
+      'length' => '255',
+      'default' => '',
+    ],
+    'log' => [
+      'type' => 'text',
+      'not null' => TRUE,
+      'size' => 'big',
+    ],
+    'timestamp' => [
+      'type' => 'int',
+      'not null' => TRUE,
+      'size' => 'normal',
+      'default' => '0',
+    ],
+    'status' => [
+      'type' => 'int',
+      'not null' => TRUE,
+      'size' => 'normal',
+      'default' => '1',
+    ],
+    'comment' => [
+      'type' => 'int',
+      'not null' => TRUE,
+      'size' => 'normal',
+      'default' => '0',
+    ],
+    'promote' => [
+      'type' => 'int',
+      'not null' => TRUE,
+      'size' => 'normal',
+      'default' => '0',
+    ],
+    'sticky' => [
+      'type' => 'int',
+      'not null' => TRUE,
+      'size' => 'normal',
+      'default' => '0',
+    ],
+  ],
+  'primary key' => [
+    'vid',
+  ],
+  'indexes' => [
+    'nid' => [
+      'nid',
+    ],
+    'uid' => [
+      'uid',
+    ],
+  ],
+  'mysql_character_set' => 'utf8mb3',
+]);
+
+$connection->insert('node_revision')
+  ->fields([
+    'nid',
+    'vid',
+    'uid',
+    'title',
+    'log',
+    'timestamp',
+    'status',
+    'comment',
+    'promote',
+    'sticky',
+  ])
+  ->values([
+    'nid' => '1',
+    'vid' => '1',
+    'uid' => '1',
+    'title' => 'Article one',
+    'log' => '',
+    'timestamp' => '1647492702',
+    'status' => '0',
+    'comment' => '2',
+    'promote' => '1',
+    'sticky' => '0',
+  ])
+  ->values([
+    'nid' => '2',
+    'vid' => '2',
+    'uid' => '1',
+    'title' => 'Article two',
+    'log' => '',
+    'timestamp' => '1647492753',
+    'status' => '0',
+    'comment' => '2',
+    'promote' => '1',
+    'sticky' => '0',
+  ])
+  ->values([
+    'nid' => '3',
+    'vid' => '3',
+    'uid' => '1',
+    'title' => 'Article three',
+    'log' => '',
+    'timestamp' => '1647492779',
+    'status' => '1',
+    'comment' => '2',
+    'promote' => '1',
+    'sticky' => '0',
+  ])
+  ->execute();
diff --git a/web/modules/scheduler/tests/fixtures/scheduler_data/node_type.php b/web/modules/scheduler/tests/fixtures/scheduler_data/node_type.php
new file mode 100644
index 0000000000000000000000000000000000000000..1cd74f17c629c750a22862bea3c1ff21e9c635ba
--- /dev/null
+++ b/web/modules/scheduler/tests/fixtures/scheduler_data/node_type.php
@@ -0,0 +1,127 @@
+<?php
+
+/**
+ * @file
+ * A database agnostic dump for testing purposes.
+ *
+ * This file was generated by the Drupal 9.2.6 db-tools.php script.
+ */
+
+use Drupal\Core\Database\Database;
+
+$connection = Database::getConnection();
+
+$connection->schema()->createTable('node_type', [
+  'fields' => [
+    'type' => [
+      'type' => 'varchar',
+      'not null' => TRUE,
+      'length' => '32',
+    ],
+    'name' => [
+      'type' => 'varchar',
+      'not null' => TRUE,
+      'length' => '255',
+      'default' => '',
+    ],
+    'base' => [
+      'type' => 'varchar',
+      'not null' => TRUE,
+      'length' => '255',
+    ],
+    'module' => [
+      'type' => 'varchar',
+      'not null' => TRUE,
+      'length' => '255',
+    ],
+    'description' => [
+      'type' => 'text',
+      'not null' => TRUE,
+      'size' => 'medium',
+    ],
+    'help' => [
+      'type' => 'text',
+      'not null' => TRUE,
+      'size' => 'medium',
+    ],
+    'has_title' => [
+      'type' => 'int',
+      'not null' => TRUE,
+      'size' => 'tiny',
+      'unsigned' => TRUE,
+    ],
+    'title_label' => [
+      'type' => 'varchar',
+      'not null' => TRUE,
+      'length' => '255',
+      'default' => '',
+    ],
+    'custom' => [
+      'type' => 'int',
+      'not null' => TRUE,
+      'size' => 'tiny',
+      'default' => '0',
+    ],
+    'modified' => [
+      'type' => 'int',
+      'not null' => TRUE,
+      'size' => 'tiny',
+      'default' => '0',
+    ],
+    'locked' => [
+      'type' => 'int',
+      'not null' => TRUE,
+      'size' => 'tiny',
+      'default' => '0',
+    ],
+    'disabled' => [
+      'type' => 'int',
+      'not null' => TRUE,
+      'size' => 'tiny',
+      'default' => '0',
+    ],
+    'orig_type' => [
+      'type' => 'varchar',
+      'not null' => TRUE,
+      'length' => '255',
+      'default' => '',
+    ],
+  ],
+  'primary key' => [
+    'type',
+  ],
+  'mysql_character_set' => 'utf8mb3',
+]);
+
+$connection->insert('node_type')
+  ->fields([
+    'type',
+    'name',
+    'base',
+    'module',
+    'description',
+    'help',
+    'has_title',
+    'title_label',
+    'custom',
+    'modified',
+    'locked',
+    'disabled',
+    'orig_type',
+  ])
+  ->values([
+    'type' => 'article',
+    'name' => 'Article',
+    'base' => 'node_content',
+    'module' => 'node',
+    'description' => 'Use <em>articles</em> for time-sensitive content like news, press releases or blog posts.',
+    'help' => '',
+    'has_title' => '1',
+    'title_label' => 'Title',
+    'custom' => '1',
+    'modified' => '1',
+    'locked' => '0',
+    'disabled' => '0',
+    'orig_type' => 'article',
+  ])
+  ->execute();
diff --git a/web/modules/scheduler/tests/fixtures/scheduler_data/role.php b/web/modules/scheduler/tests/fixtures/scheduler_data/role.php
new file mode 100644
index 0000000000000000000000000000000000000000..e4d52cea697bf2a8900081bedbdfeb2e8a79917e
--- /dev/null
+++ b/web/modules/scheduler/tests/fixtures/scheduler_data/role.php
@@ -0,0 +1,50 @@
+<?php
+
+/**
+ * @file
+ * A database agnostic dump for testing purposes.
+ *
+ * This file was generated by the Drupal 9.2.6 db-tools.php script.
+ */
+
+use Drupal\Core\Database\Database;
+
+$connection = Database::getConnection();
+
+$connection->schema()->createTable('role', [
+  'fields' => [
+    'rid' => [
+      'type' => 'serial',
+      'not null' => TRUE,
+      'size' => 'normal',
+      'unsigned' => TRUE,
+    ],
+    'name' => [
+      'type' => 'varchar',
+      'not null' => TRUE,
+      'length' => '64',
+      'default' => '',
+    ],
+    'weight' => [
+      'type' => 'int',
+      'not null' => TRUE,
+      'size' => 'normal',
+      'default' => '0',
+    ],
+  ],
+  'primary key' => [
+    'rid',
+  ],
+  'unique keys' => [
+    'name' => [
+      'name',
+    ],
+  ],
+  'indexes' => [
+    'name_weight' => [
+      'name',
+      'weight',
+    ],
+  ],
+  'mysql_character_set' => 'utf8mb3',
+]);
diff --git a/web/modules/scheduler/tests/fixtures/scheduler_data/role_permission.php b/web/modules/scheduler/tests/fixtures/scheduler_data/role_permission.php
new file mode 100644
index 0000000000000000000000000000000000000000..9605848fb615cd22586799f6b32ce06eed089ce6
--- /dev/null
+++ b/web/modules/scheduler/tests/fixtures/scheduler_data/role_permission.php
@@ -0,0 +1,45 @@
+<?php
+
+/**
+ * @file
+ * A database agnostic dump for testing purposes.
+ *
+ * This file was generated by the Drupal 9.2.6 db-tools.php script.
+ */
+
+use Drupal\Core\Database\Database;
+
+$connection = Database::getConnection();
+
+$connection->schema()->createTable('role_permission', [
+  'fields' => [
+    'rid' => [
+      'type' => 'int',
+      'not null' => TRUE,
+      'size' => 'normal',
+      'unsigned' => TRUE,
+    ],
+    'permission' => [
+      'type' => 'varchar',
+      'not null' => TRUE,
+      'length' => '128',
+      'default' => '',
+    ],
+    'module' => [
+      'type' => 'varchar',
+      'not null' => TRUE,
+      'length' => '255',
+      'default' => '',
+    ],
+  ],
+  'primary key' => [
+    'rid',
+    'permission',
+  ],
+  'indexes' => [
+    'permission' => [
+      'permission',
+    ],
+  ],
+  'mysql_character_set' => 'utf8mb3',
+]);
diff --git a/web/modules/scheduler/tests/fixtures/scheduler_data/scheduler.php b/web/modules/scheduler/tests/fixtures/scheduler_data/scheduler.php
new file mode 100644
index 0000000000000000000000000000000000000000..8e802398e4f6834a9e43a4eb919ccc9094a90785
--- /dev/null
+++ b/web/modules/scheduler/tests/fixtures/scheduler_data/scheduler.php
@@ -0,0 +1,67 @@
+<?php
+
+/**
+ * @file
+ * A database agnostic dump for testing purposes.
+ *
+ * This file was generated by the Drupal 9.2.6 db-tools.php script.
+ */
+
+use Drupal\Core\Database\Database;
+
+$connection = Database::getConnection();
+
+$connection->schema()->createTable('scheduler', [
+  'fields' => [
+    'nid' => [
+      'type' => 'int',
+      'not null' => TRUE,
+      'size' => 'normal',
+      'unsigned' => TRUE,
+    ],
+    'publish_on' => [
+      'type' => 'int',
+      'not null' => TRUE,
+      'size' => 'normal',
+      'default' => '0',
+      'unsigned' => TRUE,
+    ],
+    'unpublish_on' => [
+      'type' => 'int',
+      'not null' => TRUE,
+      'size' => 'normal',
+      'default' => '0',
+      'unsigned' => TRUE,
+    ],
+  ],
+  'primary key' => [
+    'nid',
+  ],
+  'indexes' => [
+    'scheduler_publish_on' => [
+      'publish_on',
+    ],
+    'scheduler_unpublish_on' => [
+      'unpublish_on',
+    ],
+  ],
+  'mysql_character_set' => 'utf8mb3',
+]);
+
+$connection->insert('scheduler')
+  ->fields([
+    'nid',
+    'publish_on',
+    'unpublish_on',
+  ])
+  ->values([
+    'nid' => '1',
+    'publish_on' => '1647751855',
+    'unpublish_on' => '1647838255',
+  ])
+  ->values([
+    'nid' => '2',
+    'publish_on' => '1647579055',
+    'unpublish_on' => '0',
+  ])
+  ->execute();
diff --git a/web/modules/scheduler/tests/fixtures/scheduler_data/system.php b/web/modules/scheduler/tests/fixtures/scheduler_data/system.php
new file mode 100644
index 0000000000000000000000000000000000000000..84599ac86bef3af450d37861268c8739df853506
--- /dev/null
+++ b/web/modules/scheduler/tests/fixtures/scheduler_data/system.php
@@ -0,0 +1,178 @@
+<?php
+
+/**
+ * @file
+ * A database agnostic dump for testing purposes.
+ *
+ * This file was generated by the Drupal 9.2.6 db-tools.php script.
+ */
+
+use Drupal\Core\Database\Database;
+
+$connection = Database::getConnection();
+
+$connection->schema()->createTable('system', [
+  'fields' => [
+    'filename' => [
+      'type' => 'varchar',
+      'not null' => TRUE,
+      'length' => '255',
+      'default' => '',
+    ],
+    'name' => [
+      'type' => 'varchar',
+      'not null' => TRUE,
+      'length' => '255',
+      'default' => '',
+    ],
+    'type' => [
+      'type' => 'varchar',
+      'not null' => TRUE,
+      'length' => '12',
+      'default' => '',
+    ],
+    'owner' => [
+      'type' => 'varchar',
+      'not null' => TRUE,
+      'length' => '255',
+      'default' => '',
+    ],
+    'status' => [
+      'type' => 'int',
+      'not null' => TRUE,
+      'size' => 'normal',
+      'default' => '0',
+    ],
+    'bootstrap' => [
+      'type' => 'int',
+      'not null' => TRUE,
+      'size' => 'normal',
+      'default' => '0',
+    ],
+    'schema_version' => [
+      'type' => 'int',
+      'not null' => TRUE,
+      'size' => 'small',
+      'default' => '-1',
+    ],
+    'weight' => [
+      'type' => 'int',
+      'not null' => TRUE,
+      'size' => 'normal',
+      'default' => '0',
+    ],
+    'info' => [
+      'type' => 'blob',
+      'not null' => FALSE,
+      'size' => 'normal',
+    ],
+  ],
+  'primary key' => [
+    'filename',
+  ],
+  'indexes' => [
+    'system_list' => [
+      'status',
+      'bootstrap',
+      'type',
+      'weight',
+      'name',
+    ],
+    'type_name' => [
+      'type',
+      'name',
+    ],
+  ],
+  'mysql_character_set' => 'utf8mb3',
+]);
+
+$connection->insert('system')
+  ->fields([
+    'filename',
+    'name',
+    'type',
+    'owner',
+    'status',
+    'bootstrap',
+    'schema_version',
+    'weight',
+    'info',
+  ])
+  ->values([
+    'filename' => 'modules/field/field.module',
+    'name' => 'field',
+    'type' => 'module',
+    'owner' => '',
+    'status' => '1',
+    'bootstrap' => '0',
+    'schema_version' => '7004',
+    'weight' => '0',
+    'info' => 'a:14:{s:4:"name";s:5:"Field";s:11:"description";s:57:"Field API to add fields to entities like nodes and users.";s:7:"package";s:4:"Core";s:7:"version";s:4:"7.82";s:4:"core";s:3:"7.x";s:5:"files";a:4:{i:0;s:12:"field.module";i:1;s:16:"field.attach.inc";i:2;s:20:"field.info.class.inc";i:3;s:16:"tests/field.test";}s:12:"dependencies";a:1:{i:0;s:17:"field_sql_storage";}s:8:"required";b:1;s:11:"stylesheets";a:1:{s:3:"all";a:1:{s:15:"theme/field.css";s:29:"modules/field/theme/field.css";}}s:7:"project";s:6:"drupal";s:9:"datestamp";s:10:"1626883669";s:5:"mtime";i:1626883669;s:3:"php";s:5:"5.2.4";s:9:"bootstrap";i:0;}',
+  ])
+  ->values([
+    'filename' => 'modules/field/modules/text/text.module',
+    'name' => 'text',
+    'type' => 'module',
+    'owner' => '',
+    'status' => '1',
+    'bootstrap' => '0',
+    'schema_version' => '7000',
+    'weight' => '0',
+    'info' => 'a:14:{s:4:"name";s:4:"Text";s:11:"description";s:32:"Defines simple text field types.";s:7:"package";s:4:"Core";s:7:"version";s:4:"7.82";s:4:"core";s:3:"7.x";s:12:"dependencies";a:1:{i:0;s:5:"field";}s:5:"files";a:1:{i:0;s:9:"text.test";}s:8:"required";b:1;s:7:"project";s:6:"drupal";s:9:"datestamp";s:10:"1626883669";s:5:"mtime";i:1626883669;s:3:"php";s:5:"5.2.4";s:9:"bootstrap";i:0;s:11:"explanation";s:73:"Field type(s) in use - see <a href="/admin/reports/fields">Field list</a>";}',
+  ])
+  ->values([
+    'filename' => 'modules/filter/filter.module',
+    'name' => 'filter',
+    'type' => 'module',
+    'owner' => '',
+    'status' => '1',
+    'bootstrap' => '0',
+    'schema_version' => '7010',
+    'weight' => '0',
+    'info' => 'a:14:{s:4:"name";s:6:"Filter";s:11:"description";s:43:"Filters content in preparation for display.";s:7:"package";s:4:"Core";s:7:"version";s:4:"7.82";s:4:"core";s:3:"7.x";s:5:"files";a:1:{i:0;s:11:"filter.test";}s:8:"required";b:1;s:9:"configure";s:28:"admin/config/content/formats";s:7:"project";s:6:"drupal";s:9:"datestamp";s:10:"1626883669";s:5:"mtime";i:1626883669;s:12:"dependencies";a:0:{}s:3:"php";s:5:"5.2.4";s:9:"bootstrap";i:0;}',
+  ])
+  ->values([
+    'filename' => 'modules/node/node.module',
+    'name' => 'node',
+    'type' => 'module',
+    'owner' => '',
+    'status' => '1',
+    'bootstrap' => '0',
+    'schema_version' => '7016',
+    'weight' => '0',
+    'info' => 'a:15:{s:4:"name";s:4:"Node";s:11:"description";s:66:"Allows content to be submitted to the site and displayed on pages.";s:7:"package";s:4:"Core";s:7:"version";s:4:"7.82";s:4:"core";s:3:"7.x";s:5:"files";a:2:{i:0;s:11:"node.module";i:1;s:9:"node.test";}s:8:"required";b:1;s:9:"configure";s:21:"admin/structure/types";s:11:"stylesheets";a:1:{s:3:"all";a:1:{s:8:"node.css";s:21:"modules/node/node.css";}}s:7:"project";s:6:"drupal";s:9:"datestamp";s:10:"1626883669";s:5:"mtime";i:1626883669;s:12:"dependencies";a:0:{}s:3:"php";s:5:"5.2.4";s:9:"bootstrap";i:0;}',
+  ])
+  ->values([
+    'filename' => 'modules/system/system.module',
+    'name' => 'system',
+    'type' => 'module',
+    'owner' => '',
+    'status' => '1',
+    'bootstrap' => '0',
+    'schema_version' => '7084',
+    'weight' => '0',
+    'info' => 'a:14:{s:4:"name";s:6:"System";s:11:"description";s:54:"Handles general site configuration for administrators.";s:7:"package";s:4:"Core";s:7:"version";s:4:"7.82";s:4:"core";s:3:"7.x";s:5:"files";a:6:{i:0;s:19:"system.archiver.inc";i:1;s:15:"system.mail.inc";i:2;s:16:"system.queue.inc";i:3;s:14:"system.tar.inc";i:4;s:18:"system.updater.inc";i:5;s:11:"system.test";}s:8:"required";b:1;s:9:"configure";s:19:"admin/config/system";s:7:"project";s:6:"drupal";s:9:"datestamp";s:10:"1626883669";s:5:"mtime";i:1626883669;s:12:"dependencies";a:0:{}s:3:"php";s:5:"5.2.4";s:9:"bootstrap";i:0;}',
+  ])
+  ->values([
+    'filename' => 'modules/user/user.module',
+    'name' => 'user',
+    'type' => 'module',
+    'owner' => '',
+    'status' => '1',
+    'bootstrap' => '0',
+    'schema_version' => '7019',
+    'weight' => '0',
+    'info' => 'a:15:{s:4:"name";s:4:"User";s:11:"description";s:47:"Manages the user registration and login system.";s:7:"package";s:4:"Core";s:7:"version";s:4:"7.82";s:4:"core";s:3:"7.x";s:5:"files";a:2:{i:0;s:11:"user.module";i:1;s:9:"user.test";}s:8:"required";b:1;s:9:"configure";s:19:"admin/config/people";s:11:"stylesheets";a:1:{s:3:"all";a:1:{s:8:"user.css";s:21:"modules/user/user.css";}}s:7:"project";s:6:"drupal";s:9:"datestamp";s:10:"1626883669";s:5:"mtime";i:1626883669;s:12:"dependencies";a:0:{}s:3:"php";s:5:"5.2.4";s:9:"bootstrap";i:0;}',
+  ])
+  ->values([
+    'filename' => 'sites/all/modules/contrib/scheduler/scheduler.module',
+    'name' => 'scheduler',
+    'type' => 'module',
+    'owner' => '',
+    'status' => '1',
+    'bootstrap' => '0',
+    'schema_version' => '7103',
+    'weight' => '0',
+    'info' => 'a:12:{s:4:"name";s:9:"Scheduler";s:11:"description";s:85:"This module allows nodes to be published and unpublished on specified dates and time.";s:4:"core";s:3:"7.x";s:9:"configure";s:30:"admin/config/content/scheduler";s:5:"files";a:3:{i:0;s:47:"scheduler_handler_field_scheduler_countdown.inc";i:1;s:20:"tests/scheduler.test";i:2;s:24:"tests/scheduler_api.test";}s:17:"test_dependencies";a:2:{i:0;s:4:"date";i:1;s:5:"rules";}s:5:"mtime";i:1636363545;s:12:"dependencies";a:0:{}s:7:"package";s:5:"Other";s:7:"version";N;s:3:"php";s:5:"5.2.4";s:9:"bootstrap";i:0;}',
+  ])
+  ->execute();
diff --git a/web/modules/scheduler/tests/fixtures/scheduler_data/users.php b/web/modules/scheduler/tests/fixtures/scheduler_data/users.php
new file mode 100644
index 0000000000000000000000000000000000000000..bd3049a07402089df2e4bef00076ef8eb6d0ac18
--- /dev/null
+++ b/web/modules/scheduler/tests/fixtures/scheduler_data/users.php
@@ -0,0 +1,134 @@
+<?php
+
+/**
+ * @file
+ * A database agnostic dump for testing purposes.
+ *
+ * This file was generated by the Drupal 9.2.6 db-tools.php script.
+ */
+
+use Drupal\Core\Database\Database;
+
+$connection = Database::getConnection();
+
+$connection->schema()->createTable('users', [
+  'fields' => [
+    'uid' => [
+      'type' => 'int',
+      'not null' => TRUE,
+      'size' => 'normal',
+      'default' => '0',
+      'unsigned' => TRUE,
+    ],
+    'name' => [
+      'type' => 'varchar',
+      'not null' => TRUE,
+      'length' => '60',
+      'default' => '',
+    ],
+    'pass' => [
+      'type' => 'varchar',
+      'not null' => TRUE,
+      'length' => '128',
+      'default' => '',
+    ],
+    'mail' => [
+      'type' => 'varchar',
+      'not null' => FALSE,
+      'length' => '254',
+      'default' => '',
+    ],
+    'theme' => [
+      'type' => 'varchar',
+      'not null' => TRUE,
+      'length' => '255',
+      'default' => '',
+    ],
+    'signature' => [
+      'type' => 'varchar',
+      'not null' => TRUE,
+      'length' => '255',
+      'default' => '',
+    ],
+    'signature_format' => [
+      'type' => 'varchar',
+      'not null' => FALSE,
+      'length' => '255',
+    ],
+    'created' => [
+      'type' => 'int',
+      'not null' => TRUE,
+      'size' => 'normal',
+      'default' => '0',
+    ],
+    'access' => [
+      'type' => 'int',
+      'not null' => TRUE,
+      'size' => 'normal',
+      'default' => '0',
+    ],
+    'login' => [
+      'type' => 'int',
+      'not null' => TRUE,
+      'size' => 'normal',
+      'default' => '0',
+    ],
+    'status' => [
+      'type' => 'int',
+      'not null' => TRUE,
+      'size' => 'tiny',
+      'default' => '0',
+    ],
+    'timezone' => [
+      'type' => 'varchar',
+      'not null' => FALSE,
+      'length' => '32',
+    ],
+    'language' => [
+      'type' => 'varchar',
+      'not null' => TRUE,
+      'length' => '12',
+      'default' => '',
+    ],
+    'picture' => [
+      'type' => 'int',
+      'not null' => TRUE,
+      'size' => 'normal',
+      'default' => '0',
+    ],
+    'init' => [
+      'type' => 'varchar',
+      'not null' => FALSE,
+      'length' => '254',
+      'default' => '',
+    ],
+    'data' => [
+      'type' => 'blob',
+      'not null' => FALSE,
+      'size' => 'big',
+    ],
+  ],
+  'primary key' => [
+    'uid',
+  ],
+  'unique keys' => [
+    'name' => [
+      'name',
+    ],
+  ],
+  'indexes' => [
+    'access' => [
+      'access',
+    ],
+    'created' => [
+      'created',
+    ],
+    'mail' => [
+      'mail',
+    ],
+    'picture' => [
+      'picture',
+    ],
+  ],
+  'mysql_character_set' => 'utf8mb3',
+]);
diff --git a/web/modules/scheduler/tests/fixtures/scheduler_data/variable.php b/web/modules/scheduler/tests/fixtures/scheduler_data/variable.php
new file mode 100644
index 0000000000000000000000000000000000000000..59ca281846ded1a5418f4e2e4c2426bdc48c6536
--- /dev/null
+++ b/web/modules/scheduler/tests/fixtures/scheduler_data/variable.php
@@ -0,0 +1,32 @@
+<?php
+
+/**
+ * @file
+ * A database agnostic dump for testing purposes.
+ *
+ * This file was generated by the Drupal 9.2.6 db-tools.php script.
+ */
+
+use Drupal\Core\Database\Database;
+
+$connection = Database::getConnection();
+
+$connection->schema()->createTable('variable', [
+  'fields' => [
+    'name' => [
+      'type' => 'varchar',
+      'not null' => TRUE,
+      'length' => '128',
+      'default' => '',
+    ],
+    'value' => [
+      'type' => 'blob',
+      'not null' => TRUE,
+      'size' => 'big',
+    ],
+  ],
+  'primary key' => [
+    'name',
+  ],
+  'mysql_character_set' => 'utf8mb3',
+]);
diff --git a/web/modules/scheduler/tests/modules/scheduler_access_test/scheduler_access_test.info.yml b/web/modules/scheduler/tests/modules/scheduler_access_test/scheduler_access_test.info.yml
index aa592d2de494edeea5f9071d275e9d2c22bd8f56..e9cc2547a4ad52fdb30ffa827f6c409e66319725 100644
--- a/web/modules/scheduler/tests/modules/scheduler_access_test/scheduler_access_test.info.yml
+++ b/web/modules/scheduler/tests/modules/scheduler_access_test/scheduler_access_test.info.yml
@@ -1,13 +1,11 @@
-name: 'Scheduler Node Access Test'
+name: 'Scheduler Entity Access Test'
 type: module
-description: 'Support module for Scheduler restricted node access testing.'
+description: 'Support module for Scheduler testing with restricted entity access.'
 package: Testing
-core: 8.x
-core_version_requirement: ^8 || ^9
 dependencies:
   - scheduler:scheduler
 
-# Information added by Drupal.org packaging script on 2020-06-06
-version: '8.x-1.3'
+# Information added by Drupal.org packaging script on 2022-11-20
+version: '2.0.0-rc8'
 project: 'scheduler'
-datestamp: 1591431340
+datestamp: 1668951020
diff --git a/web/modules/scheduler/tests/modules/scheduler_access_test/scheduler_access_test.module b/web/modules/scheduler/tests/modules/scheduler_access_test/scheduler_access_test.module
index 2caafbb261ef4a7cfcdf5a46619dd980114fb801..df95ee9bc7521262bc349e230677f964d270c17d 100644
--- a/web/modules/scheduler/tests/modules/scheduler_access_test/scheduler_access_test.module
+++ b/web/modules/scheduler/tests/modules/scheduler_access_test/scheduler_access_test.module
@@ -2,7 +2,12 @@
 
 /**
  * @file
- * Installation file for Scheduler Access Test module.
+ * Scheduler Access Test module.
+ *
+ * This module is used in SchedulerEntityAccessTest and removes access to all
+ * published nodes. The Media module does not provide any corresponding hooks to
+ * restrict Media access. This module, and the tests, can be expanded when a
+ * suitable access restriction method becomes available for Media entities.
  */
 
 use Drupal\Core\Session\AccountInterface;
@@ -13,7 +18,8 @@
  */
 function scheduler_access_test_node_access_records(NodeInterface $node) {
   // For the purpose of this test we deny access to every node regardless of
-  // its published status.
+  // its published status. However, users with 'View own unpublished {type}'
+  // permission will still be able to view their unpublished nodes.
   $grants = [[
     'realm' => 'scheduler',
     'gid' => 1,
diff --git a/web/modules/scheduler/tests/modules/scheduler_api_test/config/install/commerce_product.commerce_product_type.scheduler_api_product_test.yml b/web/modules/scheduler/tests/modules/scheduler_api_test/config/install/commerce_product.commerce_product_type.scheduler_api_product_test.yml
new file mode 100644
index 0000000000000000000000000000000000000000..ac258170d4142232d2d346c71dc641b89e3cfbcb
--- /dev/null
+++ b/web/modules/scheduler/tests/modules/scheduler_api_test/config/install/commerce_product.commerce_product_type.scheduler_api_product_test.yml
@@ -0,0 +1,30 @@
+langcode: en
+status: true
+dependencies:
+  module:
+    - scheduler
+  enforced:
+    module:
+      - scheduler_api_test
+third_party_settings:
+  scheduler:
+    expand_fieldset: always
+    fields_display_mode: fieldset
+    publish_enable: true
+    publish_past_date: schedule
+    publish_past_date_created: false
+    publish_required: false
+    publish_revision: false
+    publish_touch: false
+    show_message_after_update: true
+    unpublish_enable: true
+    unpublish_required: false
+    unpublish_revision: false
+id: scheduler_api_product_test
+label: 'Scheduler API Product Test'
+description: 'Commerce Product type used in Scheduler API testing'
+variationType: default
+multipleVariations: true
+injectVariationFields: true
+traits: {  }
+locked: false
diff --git a/web/modules/scheduler/tests/modules/scheduler_api_test/config/install/core.entity_form_display.commerce_product.scheduler_api_product_test.default.yml b/web/modules/scheduler/tests/modules/scheduler_api_test/config/install/core.entity_form_display.commerce_product.scheduler_api_product_test.default.yml
new file mode 100644
index 0000000000000000000000000000000000000000..ee919a896ca71f583b1c2c23270e586e7936a01c
--- /dev/null
+++ b/web/modules/scheduler/tests/modules/scheduler_api_test/config/install/core.entity_form_display.commerce_product.scheduler_api_product_test.default.yml
@@ -0,0 +1,97 @@
+uuid: fdf40551-08a6-4b2c-83eb-44bc868be4ac
+langcode: en
+status: true
+dependencies:
+  config:
+    - commerce_product.commerce_product_type.scheduler_api_product_test
+    - field.field.commerce_product.scheduler_api_product_test.field_approved_publishing
+    - field.field.commerce_product.scheduler_api_product_test.field_approved_unpublishing
+  module:
+    - commerce
+    - path
+    - scheduler
+id: commerce_product.scheduler_api_product_test.default
+targetEntityType: commerce_product
+bundle: scheduler_api_product_test
+mode: default
+content:
+  created:
+    type: datetime_timestamp
+    weight: 8
+    region: content
+    settings: {  }
+    third_party_settings: {  }
+  field_approved_publishing:
+    type: boolean_checkbox
+    weight: 1
+    region: content
+    settings:
+      display_label: true
+    third_party_settings: {  }
+  field_approved_unpublishing:
+    type: boolean_checkbox
+    weight: 2
+    region: content
+    settings:
+      display_label: true
+    third_party_settings: {  }
+  path:
+    type: path
+    weight: 9
+    region: content
+    settings: {  }
+    third_party_settings: {  }
+  publish_on:
+    type: datetime_timestamp_no_default
+    weight: 4
+    region: content
+    settings: {  }
+    third_party_settings: {  }
+  scheduler_settings:
+    weight: 3
+    region: content
+    settings: {  }
+    third_party_settings: {  }
+  status:
+    type: boolean_checkbox
+    settings:
+      display_label: true
+    weight: 10
+    region: content
+    third_party_settings: {  }
+  stores:
+    type: commerce_entity_select
+    weight: 6
+    region: content
+    settings:
+      hide_single_entity: true
+      autocomplete_threshold: 7
+      autocomplete_size: 60
+      autocomplete_placeholder: ''
+    third_party_settings: {  }
+  title:
+    type: string_textfield
+    weight: 0
+    region: content
+    settings:
+      size: 60
+      placeholder: ''
+    third_party_settings: {  }
+  uid:
+    type: entity_reference_autocomplete
+    weight: 7
+    region: content
+    settings:
+      match_operator: CONTAINS
+      match_limit: 10
+      size: 60
+      placeholder: ''
+    third_party_settings: {  }
+  unpublish_on:
+    type: datetime_timestamp_no_default
+    weight: 5
+    region: content
+    settings: {  }
+    third_party_settings: {  }
+hidden:
+  variations: true
diff --git a/web/modules/scheduler/tests/modules/scheduler_api_test/config/install/core.entity_form_display.media.scheduler_api_media_test.default.yml b/web/modules/scheduler/tests/modules/scheduler_api_test/config/install/core.entity_form_display.media.scheduler_api_media_test.default.yml
new file mode 100644
index 0000000000000000000000000000000000000000..a226a64a3799b746cd8c684c4b78c4f50fd892d2
--- /dev/null
+++ b/web/modules/scheduler/tests/modules/scheduler_api_test/config/install/core.entity_form_display.media.scheduler_api_media_test.default.yml
@@ -0,0 +1,95 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - field.field.media.scheduler_api_media_test.field_approved_publishing
+    - field.field.media.scheduler_api_media_test.field_approved_unpublishing
+    - field.field.media.scheduler_api_media_test.field_media_image_api
+    - image.style.thumbnail
+    - media.type.scheduler_api_media_test
+  module:
+    - image
+    - path
+    - scheduler
+id: media.scheduler_api_media_test.default
+targetEntityType: media
+bundle: scheduler_api_media_test
+mode: default
+content:
+  created:
+    type: datetime_timestamp
+    weight: 8
+    region: content
+    settings: {  }
+    third_party_settings: {  }
+  field_approved_publishing:
+    weight: 2
+    settings:
+      display_label: true
+    third_party_settings: {  }
+    type: boolean_checkbox
+    region: content
+  field_approved_unpublishing:
+    weight: 3
+    settings:
+      display_label: true
+    third_party_settings: {  }
+    type: boolean_checkbox
+    region: content
+  field_media_image_api:
+    weight: 1
+    settings:
+      progress_indicator: throbber
+      preview_image_style: thumbnail
+    third_party_settings: {  }
+    type: image_image
+    region: content
+  name:
+    type: string_textfield
+    weight: 0
+    region: content
+    settings:
+      size: 60
+      placeholder: ''
+    third_party_settings: {  }
+  path:
+    type: path
+    weight: 9
+    region: content
+    settings: {  }
+    third_party_settings: {  }
+  publish_on:
+    type: datetime_timestamp_no_default
+    weight: 5
+    region: content
+    settings: {  }
+    third_party_settings: {  }
+  scheduler_settings:
+    weight: 4
+    region: content
+    settings: {  }
+    third_party_settings: {  }
+  status:
+    type: boolean_checkbox
+    settings:
+      display_label: true
+    weight: 10
+    region: content
+    third_party_settings: {  }
+  uid:
+    type: entity_reference_autocomplete
+    weight: 7
+    settings:
+      match_operator: CONTAINS
+      size: 60
+      placeholder: ''
+      match_limit: 10
+    region: content
+    third_party_settings: {  }
+  unpublish_on:
+    type: datetime_timestamp_no_default
+    weight: 6
+    region: content
+    settings: {  }
+    third_party_settings: {  }
+hidden: {  }
diff --git a/web/modules/scheduler/tests/modules/scheduler_api_test/config/install/core.entity_form_display.node.scheduler_api_test.default.yml b/web/modules/scheduler/tests/modules/scheduler_api_test/config/install/core.entity_form_display.node.scheduler_api_node_test.default.yml
similarity index 66%
rename from web/modules/scheduler/tests/modules/scheduler_api_test/config/install/core.entity_form_display.node.scheduler_api_test.default.yml
rename to web/modules/scheduler/tests/modules/scheduler_api_test/config/install/core.entity_form_display.node.scheduler_api_node_test.default.yml
index e4bb485a91da4e1100fd21119f7ff30e7a8e6216..9f2db1471c1e951359db9a7f54a460b8efefee21 100644
--- a/web/modules/scheduler/tests/modules/scheduler_api_test/config/install/core.entity_form_display.node.scheduler_api_test.default.yml
+++ b/web/modules/scheduler/tests/modules/scheduler_api_test/config/install/core.entity_form_display.node.scheduler_api_node_test.default.yml
@@ -2,76 +2,93 @@ langcode: en
 status: true
 dependencies:
   config:
-    - field.field.node.scheduler_api_test.field_approved_publishing
-    - field.field.node.scheduler_api_test.field_approved_unpublishing
-    - node.type.scheduler_api_test
+    - field.field.node.scheduler_api_node_test.field_approved_publishing
+    - field.field.node.scheduler_api_node_test.field_approved_unpublishing
+    - node.type.scheduler_api_node_test
   module:
     - path
     - scheduler
-id: node.scheduler_api_test.default
+id: node.scheduler_api_node_test.default
 targetEntityType: node
-bundle: scheduler_api_test
+bundle: scheduler_api_node_test
 mode: default
 content:
-  title:
-    type: string_textfield
-    weight: 0
-    settings:
-      size: 60
-      placeholder: ''
-    third_party_settings: {  }
-  langcode:
-    type: language_select
-    weight: 1
-    settings: {  }
-    third_party_settings: {  }
-  uid:
-    type: number
-    weight: 2
-    settings: { }
-    third_party_settings: {  }
   created:
     type: datetime_timestamp
-    weight: 3
+    weight: 7
     settings: {  }
     third_party_settings: {  }
-  promote:
+    region: content
+  field_approved_publishing:
     type: boolean_checkbox
+    weight: 1
     settings:
       display_label: true
-    weight: 4
     third_party_settings: {  }
-  sticky:
+    region: content
+  field_approved_unpublishing:
     type: boolean_checkbox
+    weight: 2
     settings:
       display_label: true
-    weight: 5
     third_party_settings: {  }
+    region: content
   path:
     type: path
-    weight: 6
+    weight: 10
     settings: {  }
     third_party_settings: {  }
-  field_approved_publishing:
+    region: content
+  promote:
     type: boolean_checkbox
-    weight: 7
     settings:
       display_label: true
+    weight: 8
     third_party_settings: {  }
-  field_approved_unpublishing:
+    region: content
+  publish_on:
+    type: datetime_timestamp_no_default
+    weight: 4
+    settings: {  }
+    third_party_settings: {  }
+    region: content
+  scheduler_settings:
+    weight: 3
+    region: content
+    settings: {  }
+    third_party_settings: {  }
+  status:
     type: boolean_checkbox
-    weight: 8
     settings:
       display_label: true
+    weight: 11
+    region: content
     third_party_settings: {  }
-  publish_on:
-    type: datetime_timestamp_no_default
+  sticky:
+    type: boolean_checkbox
+    settings:
+      display_label: true
     weight: 9
+    third_party_settings: {  }
+    region: content
+  title:
+    type: string_textfield
+    weight: 0
+    settings:
+      size: 60
+      placeholder: ''
+    third_party_settings: {  }
+    region: content
+  uid:
+    type: options_select
+    weight: 6
     settings: {  }
     third_party_settings: {  }
+    region: content
   unpublish_on:
     type: datetime_timestamp_no_default
-    weight: 10
+    weight: 5
     settings: {  }
     third_party_settings: {  }
+    region: content
 hidden: {  }
diff --git a/web/modules/scheduler/tests/modules/scheduler_api_test/config/install/core.entity_view_display.commerce_product.scheduler_api_product_test.default.yml b/web/modules/scheduler/tests/modules/scheduler_api_test/config/install/core.entity_view_display.commerce_product.scheduler_api_product_test.default.yml
new file mode 100644
index 0000000000000000000000000000000000000000..2fe808d7e44d0c992364a4b3c7a4393e4bebf793
--- /dev/null
+++ b/web/modules/scheduler/tests/modules/scheduler_api_test/config/install/core.entity_view_display.commerce_product.scheduler_api_product_test.default.yml
@@ -0,0 +1,54 @@
+uuid: a46cedee-917e-4576-9b67-ecbd2fc01501
+langcode: en
+status: true
+dependencies:
+  config:
+    - commerce_product.commerce_product_type.scheduler_api_product_test
+    - field.field.commerce_product.scheduler_api_product_test.field_approved_publishing
+    - field.field.commerce_product.scheduler_api_product_test.field_approved_unpublishing
+id: commerce_product.scheduler_api_product_test.default
+targetEntityType: commerce_product
+bundle: scheduler_api_product_test
+mode: default
+content:
+  field_approved_publishing:
+    type: boolean
+    weight: 2
+    region: content
+    label: inline
+    settings:
+      format: default
+      format_custom_false: ''
+      format_custom_true: ''
+    third_party_settings: {  }
+  field_approved_unpublishing:
+    type: boolean
+    weight: 3
+    region: content
+    label: inline
+    settings:
+      format: default
+      format_custom_false: ''
+      format_custom_true: ''
+    third_party_settings: {  }
+  title:
+    label: hidden
+    type: string
+    weight: 0
+    region: content
+    settings:
+      link_to_entity: false
+    third_party_settings: {  }
+  variations:
+    type: entity_reference_entity_view
+    weight: 1
+    region: content
+    label: above
+    settings:
+      view_mode: default
+      link: false
+    third_party_settings: {  }
+hidden:
+  created: true
+  stores: true
+  uid: true
diff --git a/web/modules/scheduler/tests/modules/scheduler_api_test/config/install/core.entity_view_display.media.scheduler_api_media_test.default.yml b/web/modules/scheduler/tests/modules/scheduler_api_test/config/install/core.entity_view_display.media.scheduler_api_media_test.default.yml
new file mode 100644
index 0000000000000000000000000000000000000000..00e691f8cc63211ed4a897d0c4e1783e578092b5
--- /dev/null
+++ b/web/modules/scheduler/tests/modules/scheduler_api_test/config/install/core.entity_view_display.media.scheduler_api_media_test.default.yml
@@ -0,0 +1,57 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - field.field.media.scheduler_api_media_test.field_approved_publishing
+    - field.field.media.scheduler_api_media_test.field_approved_unpublishing
+    - field.field.media.scheduler_api_media_test.field_media_image_api
+    - image.style.large
+    - media.type.scheduler_api_media_test
+  module:
+    - image
+id: media.scheduler_api_media_test.default
+targetEntityType: media
+bundle: scheduler_api_media_test
+mode: default
+content:
+  field_approved_publishing:
+    weight: 2
+    label: inline
+    settings:
+      format: default
+      format_custom_false: ''
+      format_custom_true: ''
+    third_party_settings: {  }
+    type: boolean
+    region: content
+  field_approved_unpublishing:
+    weight: 3
+    label: inline
+    settings:
+      format: default
+      format_custom_false: ''
+      format_custom_true: ''
+    third_party_settings: {  }
+    type: boolean
+    region: content
+  field_media_image_api:
+    label: visually_hidden
+    weight: 1
+    settings:
+      image_style: large
+      image_link: ''
+    third_party_settings: {  }
+    type: image
+    region: content
+  name:
+    type: string
+    weight: 0
+    region: content
+    label: above
+    settings:
+      link_to_entity: false
+    third_party_settings: {  }
+hidden:
+  created: true
+  thumbnail: true
+  uid: true
diff --git a/web/modules/scheduler/tests/modules/scheduler_api_test/config/install/core.entity_view_display.node.scheduler_api_test.default.yml b/web/modules/scheduler/tests/modules/scheduler_api_test/config/install/core.entity_view_display.node.scheduler_api_node_test.default.yml
similarity index 62%
rename from web/modules/scheduler/tests/modules/scheduler_api_test/config/install/core.entity_view_display.node.scheduler_api_test.default.yml
rename to web/modules/scheduler/tests/modules/scheduler_api_test/config/install/core.entity_view_display.node.scheduler_api_node_test.default.yml
index 34011b144f4d1d0ad006d0c09f25f80fe87b52f3..f502c32bb5310aa4098a57ff809972fbd5c261ff 100644
--- a/web/modules/scheduler/tests/modules/scheduler_api_test/config/install/core.entity_view_display.node.scheduler_api_test.default.yml
+++ b/web/modules/scheduler/tests/modules/scheduler_api_test/config/install/core.entity_view_display.node.scheduler_api_node_test.default.yml
@@ -2,14 +2,14 @@ langcode: en
 status: true
 dependencies:
   config:
-    - field.field.node.scheduler_api_test.field_approved_publishing
-    - field.field.node.scheduler_api_test.field_approved_unpublishing
-    - node.type.scheduler_api_test
+    - field.field.node.scheduler_api_node_test.field_approved_publishing
+    - field.field.node.scheduler_api_node_test.field_approved_unpublishing
+    - node.type.scheduler_api_node_test
   module:
     - user
-id: node.scheduler_api_test.default
+id: node.scheduler_api_node_test.default
 targetEntityType: node
-bundle: scheduler_api_test
+bundle: scheduler_api_node_test
 mode: default
 content:
   field_approved_publishing:
@@ -21,18 +21,20 @@ content:
       format_custom_false: ''
       format_custom_true: ''
     third_party_settings: {  }
+    region: content
   field_approved_unpublishing:
     type: boolean
-    weight: 1
+    weight: 2
     label: inline
     settings:
       format: default
       format_custom_false: ''
       format_custom_true: ''
     third_party_settings: {  }
+    region: content
   links:
     weight: 0
+    region: content
     settings: {  }
     third_party_settings: {  }
-hidden:
-  langcode: true
+hidden: {  }
diff --git a/web/modules/scheduler/tests/modules/scheduler_api_test/config/install/field.field.commerce_product.scheduler_api_product_test.field_approved_publishing.yml b/web/modules/scheduler/tests/modules/scheduler_api_test/config/install/field.field.commerce_product.scheduler_api_product_test.field_approved_publishing.yml
new file mode 100644
index 0000000000000000000000000000000000000000..02d5f1a02e759d40c38bfcbf37efdfe98c297904
--- /dev/null
+++ b/web/modules/scheduler/tests/modules/scheduler_api_test/config/install/field.field.commerce_product.scheduler_api_product_test.field_approved_publishing.yml
@@ -0,0 +1,22 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - commerce_product.commerce_product_type.scheduler_api_product_test
+    - field.storage.commerce_product.field_approved_publishing
+id: commerce_product.scheduler_api_product_test.field_approved_publishing
+field_name: field_approved_publishing
+entity_type: commerce_product
+bundle: scheduler_api_product_test
+label: 'Product Approved for Publishing'
+description: ''
+required: false
+translatable: false
+default_value:
+  -
+    value: 0
+default_value_callback: ''
+settings:
+  on_label: 'Yes'
+  off_label: 'No'
+field_type: boolean
diff --git a/web/modules/scheduler/tests/modules/scheduler_api_test/config/install/field.field.commerce_product.scheduler_api_product_test.field_approved_unpublishing.yml b/web/modules/scheduler/tests/modules/scheduler_api_test/config/install/field.field.commerce_product.scheduler_api_product_test.field_approved_unpublishing.yml
new file mode 100644
index 0000000000000000000000000000000000000000..ef0dca2c54cccd936892ab6abf0f356ee415c18e
--- /dev/null
+++ b/web/modules/scheduler/tests/modules/scheduler_api_test/config/install/field.field.commerce_product.scheduler_api_product_test.field_approved_unpublishing.yml
@@ -0,0 +1,22 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - commerce_product.commerce_product_type.scheduler_api_product_test
+    - field.storage.commerce_product.field_approved_unpublishing
+id: commerce_product.scheduler_api_product_test.field_approved_unpublishing
+field_name: field_approved_unpublishing
+entity_type: commerce_product
+bundle: scheduler_api_product_test
+label: 'Product Approved for Unpublishing'
+description: ''
+required: false
+translatable: false
+default_value:
+  -
+    value: 0
+default_value_callback: ''
+settings:
+  on_label: 'Yes'
+  off_label: 'No'
+field_type: boolean
diff --git a/web/modules/scheduler/tests/modules/scheduler_api_test/config/install/field.field.media.scheduler_api_media_test.field_approved_publishing.yml b/web/modules/scheduler/tests/modules/scheduler_api_test/config/install/field.field.media.scheduler_api_media_test.field_approved_publishing.yml
new file mode 100644
index 0000000000000000000000000000000000000000..6acad3e7c985fda694eb2d5c15043148ad100ec4
--- /dev/null
+++ b/web/modules/scheduler/tests/modules/scheduler_api_test/config/install/field.field.media.scheduler_api_media_test.field_approved_publishing.yml
@@ -0,0 +1,22 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - field.storage.media.field_approved_publishing
+    - media.type.scheduler_api_media_test
+id: media.scheduler_api_media_test.field_approved_publishing
+field_name: field_approved_publishing
+entity_type: media
+bundle: scheduler_api_media_test
+label: 'Media Approved for Publishing'
+description: ''
+required: false
+translatable: false
+default_value:
+  -
+    value: 0
+default_value_callback: ''
+settings:
+  on_label: 'Yes'
+  off_label: 'No'
+field_type: boolean
diff --git a/web/modules/scheduler/tests/modules/scheduler_api_test/config/install/field.field.media.scheduler_api_media_test.field_approved_unpublishing.yml b/web/modules/scheduler/tests/modules/scheduler_api_test/config/install/field.field.media.scheduler_api_media_test.field_approved_unpublishing.yml
new file mode 100644
index 0000000000000000000000000000000000000000..ea52fe4912b35f50e9d4e788e1563e7f01e727cd
--- /dev/null
+++ b/web/modules/scheduler/tests/modules/scheduler_api_test/config/install/field.field.media.scheduler_api_media_test.field_approved_unpublishing.yml
@@ -0,0 +1,22 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - field.storage.media.field_approved_unpublishing
+    - media.type.scheduler_api_media_test
+id: media.scheduler_api_media_test.field_approved_unpublishing
+field_name: field_approved_unpublishing
+entity_type: media
+bundle: scheduler_api_media_test
+label: 'Media Approved for Unpublishing'
+description: ''
+required: false
+translatable: false
+default_value:
+  -
+    value: 0
+default_value_callback: ''
+settings:
+  on_label: 'Yes'
+  off_label: 'No'
+field_type: boolean
diff --git a/web/modules/scheduler/tests/modules/scheduler_api_test/config/install/field.field.media.scheduler_api_media_test.field_media_image_api.yml b/web/modules/scheduler/tests/modules/scheduler_api_test/config/install/field.field.media.scheduler_api_media_test.field_media_image_api.yml
new file mode 100644
index 0000000000000000000000000000000000000000..8d59c3cab210ebd2f2d42a85b43b7f71623b9d8e
--- /dev/null
+++ b/web/modules/scheduler/tests/modules/scheduler_api_test/config/install/field.field.media.scheduler_api_media_test.field_media_image_api.yml
@@ -0,0 +1,37 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - field.storage.media.field_media_image_api
+    - media.type.scheduler_api_media_test
+  module:
+    - image
+id: media.scheduler_api_media_test.field_media_image_api
+field_name: field_media_image_api
+entity_type: media
+bundle: scheduler_api_media_test
+label: 'Image for API test'
+description: ''
+required: false
+translatable: true
+default_value: {  }
+default_value_callback: ''
+settings:
+  file_directory: '[date:custom:Y]-[date:custom:m]'
+  file_extensions: 'png gif jpg jpeg'
+  max_filesize: ''
+  max_resolution: ''
+  min_resolution: ''
+  alt_field: true
+  alt_field_required: false
+  title_field: false
+  title_field_required: false
+  default_image:
+    uuid: ''
+    alt: ''
+    title: ''
+    width: null
+    height: null
+  handler: 'default:file'
+  handler_settings: {  }
+field_type: image
diff --git a/web/modules/scheduler/tests/modules/scheduler_api_test/config/install/field.field.node.scheduler_api_test.field_approved_publishing.yml b/web/modules/scheduler/tests/modules/scheduler_api_test/config/install/field.field.node.scheduler_api_node_test.field_approved_publishing.yml
similarity index 76%
rename from web/modules/scheduler/tests/modules/scheduler_api_test/config/install/field.field.node.scheduler_api_test.field_approved_publishing.yml
rename to web/modules/scheduler/tests/modules/scheduler_api_test/config/install/field.field.node.scheduler_api_node_test.field_approved_publishing.yml
index 9d1a167c06261c67de97a69d2a8e77f10c599bed..e2b2a852bf8eb44b2d946566c348f9a572dd6261 100644
--- a/web/modules/scheduler/tests/modules/scheduler_api_test/config/install/field.field.node.scheduler_api_test.field_approved_publishing.yml
+++ b/web/modules/scheduler/tests/modules/scheduler_api_test/config/install/field.field.node.scheduler_api_node_test.field_approved_publishing.yml
@@ -3,14 +3,14 @@ status: true
 dependencies:
   config:
     - field.storage.node.field_approved_publishing
-    - node.type.scheduler_api_test
+    - node.type.scheduler_api_node_test
   enforced:
     module:
       - scheduler_api_test
-id: node.scheduler_api_test.field_approved_publishing
+id: node.scheduler_api_node_test.field_approved_publishing
 field_name: field_approved_publishing
 entity_type: node
-bundle: scheduler_api_test
+bundle: scheduler_api_node_test
 label: 'Approved for Publishing'
 description: ''
 required: false
diff --git a/web/modules/scheduler/tests/modules/scheduler_api_test/config/install/field.field.node.scheduler_api_test.field_approved_unpublishing.yml b/web/modules/scheduler/tests/modules/scheduler_api_test/config/install/field.field.node.scheduler_api_node_test.field_approved_unpublishing.yml
similarity index 76%
rename from web/modules/scheduler/tests/modules/scheduler_api_test/config/install/field.field.node.scheduler_api_test.field_approved_unpublishing.yml
rename to web/modules/scheduler/tests/modules/scheduler_api_test/config/install/field.field.node.scheduler_api_node_test.field_approved_unpublishing.yml
index 1902663667955d163cda769be3b66b57fd61ea06..c8d837af7c3cf95f6eb2876d6a656f6ff6eda5ef 100644
--- a/web/modules/scheduler/tests/modules/scheduler_api_test/config/install/field.field.node.scheduler_api_test.field_approved_unpublishing.yml
+++ b/web/modules/scheduler/tests/modules/scheduler_api_test/config/install/field.field.node.scheduler_api_node_test.field_approved_unpublishing.yml
@@ -3,14 +3,14 @@ status: true
 dependencies:
   config:
     - field.storage.node.field_approved_unpublishing
-    - node.type.scheduler_api_test
+    - node.type.scheduler_api_node_test
   enforced:
     module:
       - scheduler_api_test
-id: node.scheduler_api_test.field_approved_unpublishing
+id: node.scheduler_api_node_test.field_approved_unpublishing
 field_name: field_approved_unpublishing
 entity_type: node
-bundle: scheduler_api_test
+bundle: scheduler_api_node_test
 label: 'Approved for Unpublishing'
 description: ''
 required: false
diff --git a/web/modules/scheduler/tests/modules/scheduler_api_test/config/install/field.storage.commerce_product.field_approved_publishing.yml b/web/modules/scheduler/tests/modules/scheduler_api_test/config/install/field.storage.commerce_product.field_approved_publishing.yml
new file mode 100644
index 0000000000000000000000000000000000000000..8221204b3a2c37520ef87eb515e3c5befac5d636
--- /dev/null
+++ b/web/modules/scheduler/tests/modules/scheduler_api_test/config/install/field.storage.commerce_product.field_approved_publishing.yml
@@ -0,0 +1,20 @@
+langcode: en
+status: true
+dependencies:
+  module:
+    - commerce_product
+  enforced:
+    module:
+      - scheduler_api_test
+id: commerce_product.field_approved_publishing
+field_name: field_approved_publishing
+entity_type: commerce_product
+type: boolean
+settings: {  }
+module: core
+locked: false
+cardinality: 1
+translatable: true
+indexes: {  }
+persist_with_no_fields: false
+custom_storage: false
diff --git a/web/modules/scheduler/tests/modules/scheduler_api_test/config/install/field.storage.commerce_product.field_approved_unpublishing.yml b/web/modules/scheduler/tests/modules/scheduler_api_test/config/install/field.storage.commerce_product.field_approved_unpublishing.yml
new file mode 100644
index 0000000000000000000000000000000000000000..477fc606c9339221120e5bd44b96904807ea9392
--- /dev/null
+++ b/web/modules/scheduler/tests/modules/scheduler_api_test/config/install/field.storage.commerce_product.field_approved_unpublishing.yml
@@ -0,0 +1,20 @@
+langcode: en
+status: true
+dependencies:
+  module:
+    - commerce_product
+  enforced:
+    module:
+      - scheduler_api_test
+id: commerce_product.field_approved_unpublishing
+field_name: field_approved_unpublishing
+entity_type: commerce_product
+type: boolean
+settings: {  }
+module: core
+locked: false
+cardinality: 1
+translatable: true
+indexes: {  }
+persist_with_no_fields: false
+custom_storage: false
diff --git a/web/modules/scheduler/tests/modules/scheduler_api_test/config/install/field.storage.media.field_approved_publishing.yml b/web/modules/scheduler/tests/modules/scheduler_api_test/config/install/field.storage.media.field_approved_publishing.yml
new file mode 100644
index 0000000000000000000000000000000000000000..478e5595b753eebdb5fea7deecdf4c63afeebee3
--- /dev/null
+++ b/web/modules/scheduler/tests/modules/scheduler_api_test/config/install/field.storage.media.field_approved_publishing.yml
@@ -0,0 +1,20 @@
+langcode: en
+status: true
+dependencies:
+  module:
+    - media
+  enforced:
+    module:
+      - scheduler_api_test
+id: media.field_approved_publishing
+field_name: field_approved_publishing
+entity_type: media
+type: boolean
+settings: {  }
+module: core
+locked: false
+cardinality: 1
+translatable: true
+indexes: {  }
+persist_with_no_fields: false
+custom_storage: false
diff --git a/web/modules/scheduler/tests/modules/scheduler_api_test/config/install/field.storage.media.field_approved_unpublishing.yml b/web/modules/scheduler/tests/modules/scheduler_api_test/config/install/field.storage.media.field_approved_unpublishing.yml
new file mode 100644
index 0000000000000000000000000000000000000000..5b054d31da10eeff1aed77bfecbfe939dd4f404e
--- /dev/null
+++ b/web/modules/scheduler/tests/modules/scheduler_api_test/config/install/field.storage.media.field_approved_unpublishing.yml
@@ -0,0 +1,20 @@
+langcode: en
+status: true
+dependencies:
+  module:
+    - media
+  enforced:
+    module:
+      - scheduler_api_test
+id: media.field_approved_unpublishing
+field_name: field_approved_unpublishing
+entity_type: media
+type: boolean
+settings: {  }
+module: core
+locked: false
+cardinality: 1
+translatable: true
+indexes: {  }
+persist_with_no_fields: false
+custom_storage: false
diff --git a/web/modules/scheduler/tests/modules/scheduler_api_test/config/install/field.storage.media.field_media_image_api.yml b/web/modules/scheduler/tests/modules/scheduler_api_test/config/install/field.storage.media.field_media_image_api.yml
new file mode 100644
index 0000000000000000000000000000000000000000..28daadc5866e8d7b048b7383b739aa5b06b9648f
--- /dev/null
+++ b/web/modules/scheduler/tests/modules/scheduler_api_test/config/install/field.storage.media.field_media_image_api.yml
@@ -0,0 +1,32 @@
+langcode: en
+status: true
+dependencies:
+  module:
+    - file
+    - image
+    - media
+  enforced:
+    module:
+      - scheduler_api_test
+id: media.field_media_image_api
+field_name: field_media_image_api
+entity_type: media
+type: image
+settings:
+  default_image:
+    uuid: null
+    alt: ''
+    title: ''
+    width: null
+    height: null
+  target_type: file
+  display_field: false
+  display_default: false
+  uri_scheme: public
+module: image
+locked: false
+cardinality: 1
+translatable: true
+indexes: {  }
+persist_with_no_fields: false
+custom_storage: false
diff --git a/web/modules/scheduler/tests/modules/scheduler_api_test/config/install/media.type.scheduler_api_media_test.yml b/web/modules/scheduler/tests/modules/scheduler_api_test/config/install/media.type.scheduler_api_media_test.yml
new file mode 100644
index 0000000000000000000000000000000000000000..a451b7a300a0b7d1a77b625b41397e8e274e2084
--- /dev/null
+++ b/web/modules/scheduler/tests/modules/scheduler_api_test/config/install/media.type.scheduler_api_media_test.yml
@@ -0,0 +1,32 @@
+langcode: en
+status: true
+dependencies:
+  module:
+    - scheduler
+  enforced:
+    module:
+      - scheduler_api_test
+third_party_settings:
+  scheduler:
+    expand_fieldset: always
+    fields_display_mode: fieldset
+    publish_enable: true
+    publish_past_date: schedule
+    publish_past_date_created: false
+    publish_required: false
+    publish_revision: false
+    publish_touch: false
+    show_message_after_update: true
+    unpublish_enable: true
+    unpublish_required: false
+    unpublish_revision: false
+id: scheduler_api_media_test
+label: 'Scheduler API Media Test'
+description: 'Image media type used in Scheduler API testing'
+source: image
+queue_thumbnail_downloads: false
+new_revision: false
+source_configuration:
+  source_field: field_media_image_api
+field_map:
+  name: name
diff --git a/web/modules/scheduler/tests/modules/scheduler_api_test/config/install/node.type.scheduler_api_test.yml b/web/modules/scheduler/tests/modules/scheduler_api_test/config/install/node.type.scheduler_api_node_test.yml
similarity index 71%
rename from web/modules/scheduler/tests/modules/scheduler_api_test/config/install/node.type.scheduler_api_test.yml
rename to web/modules/scheduler/tests/modules/scheduler_api_test/config/install/node.type.scheduler_api_node_test.yml
index 4e75ce1d9ce939f7184002ebf6e6d70ef41820ac..28d667c7c1ab5c4da9d2212d5e6c381a0d27f766 100644
--- a/web/modules/scheduler/tests/modules/scheduler_api_test/config/install/node.type.scheduler_api_test.yml
+++ b/web/modules/scheduler/tests/modules/scheduler_api_test/config/install/node.type.scheduler_api_node_test.yml
@@ -13,19 +13,21 @@ third_party_settings:
       - main
     parent: 'main:'
   scheduler:
-    expand_fieldset: when_required
+    expand_fieldset: always
     fields_display_mode: fieldset
     publish_enable: true
     publish_past_date: schedule
+    publish_past_date_created: false
     publish_required: false
     publish_revision: false
     publish_touch: false
+    show_message_after_update: true
     unpublish_enable: true
     unpublish_required: false
     unpublish_revision: false
-name: 'Scheduler API test type'
-type: scheduler_api_test
-description: 'Used for Scheduler API testing'
+type: scheduler_api_node_test
+name: 'Scheduler API Node Test'
+description: 'Node content type used in Scheduler API testing'
 help: ''
 new_revision: false
 preview_mode: 1
diff --git a/web/modules/scheduler/tests/modules/scheduler_api_test/scheduler_api_legacy_test/scheduler_api_legacy_test.info.yml b/web/modules/scheduler/tests/modules/scheduler_api_test/scheduler_api_legacy_test/scheduler_api_legacy_test.info.yml
new file mode 100644
index 0000000000000000000000000000000000000000..603c3864137f57b34712c53ddec13facce8afbf4
--- /dev/null
+++ b/web/modules/scheduler/tests/modules/scheduler_api_test/scheduler_api_legacy_test/scheduler_api_legacy_test.info.yml
@@ -0,0 +1,12 @@
+name: 'Scheduler API Legacy Test'
+type: module
+description: 'Sub-module containing the legacy hook implementations.'
+package: Testing
+dependencies:
+  - scheduler:scheduler
+  - scheduler_api_test:scheduler_api_test
+
+# Information added by Drupal.org packaging script on 2022-11-20
+version: '2.0.0-rc8'
+project: 'scheduler'
+datestamp: 1668951020
diff --git a/web/modules/scheduler/tests/modules/scheduler_api_test/scheduler_api_legacy_test/scheduler_api_legacy_test.module b/web/modules/scheduler/tests/modules/scheduler_api_test/scheduler_api_legacy_test/scheduler_api_legacy_test.module
new file mode 100644
index 0000000000000000000000000000000000000000..acf7a0c66ec30b8b4aeb5064157e835ef677259f
--- /dev/null
+++ b/web/modules/scheduler/tests/modules/scheduler_api_test/scheduler_api_legacy_test/scheduler_api_legacy_test.module
@@ -0,0 +1,197 @@
+<?php
+
+/**
+ * @file
+ * Legacy hook implementations for the Scheduler API Test module.
+ *
+ * Scheduler provides eight hook functions. As part of the enhancements to cater
+ * for non-node entities the hook functions had to be renamed to allow generic
+ * and specific variants and to maintain predictable expansion for any future
+ * entity type. For backwards-compatibility the original hook function names are
+ * maintined for node entities only, and this file provides test coverage.
+ */
+
+use Drupal\node\NodeInterface;
+
+/**
+ * Implements hook_scheduler_nid_list().
+ */
+function scheduler_api_legacy_test_scheduler_nid_list($action) {
+  $nids = [];
+  $request_time = \Drupal::time()->getRequestTime();
+  // Check to see what test nodes exist.
+  $results = _scheduler_api_test_get_entities('node');
+  foreach ($results as $nid => $node) {
+    // If publishing and this is the publish test node, set a date and add
+    // the node id to the list.
+    if ($action == 'publish' && $node->title->value == 'API TEST nid_list publish me') {
+      $node->set('publish_on', $request_time)->save();
+      $nids[] = $nid;
+    }
+    // If unpublishing and this is the unpublish test node, set a date and add
+    // the node id to the list.
+    if ($action == 'unpublish' && $node->title->value == 'API TEST nid_list unpublish me') {
+      $node->set('unpublish_on', $request_time)->save();
+      $nids[] = $nid;
+    }
+  }
+  return $nids;
+}
+
+/**
+ * Implements hook_scheduler_nid_list_alter().
+ */
+function scheduler_api_legacy_test_scheduler_nid_list_alter(&$nids, $action) {
+  $request_time = \Drupal::time()->getRequestTime();
+  $results = _scheduler_api_test_get_entities('node');
+  foreach ($results as $nid => $node) {
+    if ($action == 'publish' && $node->title->value == 'API TEST nid_list_alter do not publish me') {
+      // Remove the node id.
+      $nids = array_diff($nids, [$nid]);
+    }
+    if ($action == 'publish' && $node->title->value == 'API TEST nid_list_alter publish me') {
+      // Set a publish_on date and add the node id.
+      $node->set('publish_on', $request_time)->save();
+      $nids[] = $nid;
+    }
+    if ($action == 'unpublish' && $node->title->value == 'API TEST nid_list_alter do not unpublish me') {
+      // Remove the node id.
+      $nids = array_diff($nids, [$nid]);
+    }
+    if ($action == 'unpublish' && $node->title->value == 'API TEST nid_list_alter unpublish me') {
+      // Set an unpublish_on date and add the node id.
+      $node->set('unpublish_on', $request_time)->save();
+      $nids[] = $nid;
+    }
+  }
+}
+
+/**
+ * Implements hook_scheduler_allow_publishing().
+ */
+function scheduler_api_legacy_test_scheduler_allow_publishing(NodeInterface $node) {
+  // If there is no 'Approved for Publishing' field then allow publishing.
+  if (!isset($node->field_approved_publishing)) {
+    $allowed = TRUE;
+  }
+  else {
+    // Only publish nodes that have 'Approved for Publishing' set.
+    $allowed = $node->field_approved_publishing->value;
+    // If publication is denied then inform the user why.
+    if (!$allowed) {
+      \Drupal::messenger()->addMessage(t('%title is scheduled for publishing, but will not be published until approved.', ['%title' => $node->title->value]), 'status', FALSE);
+      // If the time is in the past it means that the action has been prevented.
+      // Write a dblog message to show this. Give a link to view the node but
+      // cater for no nid as the node may be new and not yet saved.
+      if ($node->publish_on->value <= \Drupal::time()->getRequestTime()) {
+        \Drupal::logger('scheduler_api_test')->warning('Publishing of "%title" is prevented until approved.', [
+          '%title' => $node->title->value,
+          'link' => $node->id() ? $node->toLink(t('View node'))->toString() : '',
+        ]);
+      }
+    }
+  }
+  return $allowed;
+}
+
+/**
+ * Implements hook_scheduler_allow_unpublishing().
+ */
+function scheduler_api_legacy_test_scheduler_allow_unpublishing(NodeInterface $node) {
+  // If there is no 'Approved for Unpublishing' field then allow unpublishing.
+  if (!isset($node->field_approved_unpublishing)) {
+    $allowed = TRUE;
+  }
+  else {
+    // Only unpublish nodes that have 'Approved for Unpublishing' set.
+    $allowed = $node->field_approved_unpublishing->value;
+    // If unpublication is denied then inform the user why.
+    if (!$allowed) {
+      \Drupal::messenger()->addMessage(t('%title is scheduled for unpublishing, but will not be unpublished until approved.', ['%title' => $node->title->value]), 'status', FALSE);
+      // If the time is in the past it means that the action has been prevented.
+      // Write a dblog message to show this. Give a link to view the node but
+      // cater for no nid as the node may be new and not yet saved.
+      if ($node->unpublish_on->value <= \Drupal::time()->getRequestTime()) {
+        \Drupal::logger('scheduler_api_test')->warning('Unpublishing of "%title" is prevented until approved.', [
+          '%title' => $node->title->value,
+          'link' => $node->id() ? $node->toLink(t('View node'))->toString() : '',
+        ]);
+      }
+    }
+  }
+  return $allowed;
+}
+
+/**
+ * Implements hook_scheduler_hide_publish_on_field().
+ */
+function scheduler_api_legacy_test_scheduler_hide_publish_on_field($form, $form_state, $node) {
+  // Hide the publish_on field if the node title contains orange or green.
+  $title = $node->title->value ?? '';
+  if (stristr($title, 'orange legacy') || stristr($title, 'green legacy')) {
+    \Drupal::messenger()->addMessage(t('Scheduler_Api_Test: The publish_on field is hidden for orange or green node titles.'), 'status', FALSE);
+    return TRUE;
+  }
+  else {
+    return FALSE;
+  }
+}
+
+/**
+ * Implements hook_scheduler_hide_unpublish_on_field().
+ */
+function scheduler_api_legacy_test_scheduler_hide_unpublish_on_field($form, $form_state, $node) {
+  // Hide the unpublish_on field if the node title contains yellow or green.
+  $title = $node->title->value ?? '';
+  if (stristr($title, 'yellow legacy') || stristr($title, 'green legacy')) {
+    \Drupal::messenger()->addMessage(t('Scheduler_Api_Test: The unpublish_on field is hidden for yellow or green node titles.'), 'status', FALSE);
+    return TRUE;
+  }
+  else {
+    return FALSE;
+  }
+}
+
+/**
+ * Implements hook_scheduler_publish_action().
+ */
+function scheduler_api_legacy_test_scheduler_publish_action($node) {
+  if (stristr($node->title->value, 'red legacy')) {
+    // Nodes with red in the title are simulated to cause a failure and should
+    // then be skipped by Scheduler.
+    $node->set('title', $node->title->value . ' - publishing failed in API test module');
+    \Drupal::messenger()->addMessage(t('Scheduler_Api_Test: Red nodes should cause Scheduler to abandon publishing.'), 'status', FALSE);
+    return -1;
+  }
+  elseif (stristr($node->title->value, 'yellow legacy')) {
+    // Nodes with yellow in the title are simulated to be processed by this
+    // hook, and will not be published by Scheduler.
+    $node->set('title', $node->title->value . ' - publishing processed by API test module');
+    $node->setPublished();
+    \Drupal::messenger()->addMessage(t('Scheduler_Api_Test: Yellow nodes should not have publishing processed by Scheduler.'), 'status', FALSE);
+    return 1;
+  }
+  return 0;
+}
+
+/**
+ * Implements hook_scheduler_unpublish_action().
+ */
+function scheduler_api_legacy_test_scheduler_unpublish_action($node) {
+  if (stristr($node->title->value, 'blue legacy')) {
+    // Nodes with blue in the title are simulated to cause a failure and should
+    // then be skipped by Scheduler.
+    $node->set('title', $node->title->value . ' - unpublishing failed in API test module');
+    \Drupal::messenger()->addMessage(t('Scheduler_Api_Test: Blue nodes should cause Scheduler to abandon unpublishing.'), 'status', FALSE);
+    return -1;
+  }
+  if (stristr($node->title->value, 'orange legacy')) {
+    // Nodes with orange in the title are simulated to be processed by this
+    // hook, and will not be published by Scheduler.
+    $node->set('title', $node->title->value . ' - unpublishing processed by API test module');
+    $node->setUnpublished();
+    \Drupal::messenger()->addMessage(t('Scheduler_Api_Test: Orange nodes should not have unpublishing processed by Scheduler.'), 'status', FALSE);
+    return 1;
+  }
+  return 0;
+}
diff --git a/web/modules/scheduler/tests/modules/scheduler_api_test/scheduler_api_test.info.yml b/web/modules/scheduler/tests/modules/scheduler_api_test/scheduler_api_test.info.yml
index 2695130f93f6948df1fa4900a757eede2c89fbe2..5972cfe9f0e31a744394c5a8e6697f97b21845e9 100644
--- a/web/modules/scheduler/tests/modules/scheduler_api_test/scheduler_api_test.info.yml
+++ b/web/modules/scheduler/tests/modules/scheduler_api_test/scheduler_api_test.info.yml
@@ -2,12 +2,11 @@ name: 'Scheduler API Test'
 type: module
 description: 'Support module for Scheduler API-related testing.'
 package: Testing
-core: 8.x
-core_version_requirement: ^8 || ^9
 dependencies:
   - scheduler:scheduler
+  - drupal:media
 
-# Information added by Drupal.org packaging script on 2020-06-06
-version: '8.x-1.3'
+# Information added by Drupal.org packaging script on 2022-11-20
+version: '2.0.0-rc8'
 project: 'scheduler'
-datestamp: 1591431340
+datestamp: 1668951020
diff --git a/web/modules/scheduler/tests/modules/scheduler_api_test/scheduler_api_test.install b/web/modules/scheduler/tests/modules/scheduler_api_test/scheduler_api_test.install
index 0c2c7b45c6155ec36671331a2b4bb58e05b138c7..3dd61222b7ae797ee2beec085af91aa93ffe4d4d 100644
--- a/web/modules/scheduler/tests/modules/scheduler_api_test/scheduler_api_test.install
+++ b/web/modules/scheduler/tests/modules/scheduler_api_test/scheduler_api_test.install
@@ -12,25 +12,33 @@
  * module config and content on uninstalling. Plus, when developing this module
  * and enabling it manually as a real module, this code is needed to clean up,
  * otherwise a re-install fails.
+ *
+ * The entity types, custom fields and storage are deleted automatically by
+ * having 'enforced: module: - scheduler_api_test' in the config/install/*.yml
+ * files. However, we have to delete the actual entity content here.
  */
 function scheduler_api_test_uninstall() {
 
-  // Delete any content that may have been created for the custom node type.
-  $nids_query = \Drupal::database()->select('node', 'n')
-    ->fields('n', ['nid'])
-    ->condition('n.type', ['scheduler_api_test'], 'IN')
-    ->execute();
-  if ($nids = $nids_query->fetchCol()) {
-    $storage = \Drupal::entityTypeManager()->getStorage('node');
-    $entities = $storage->loadMultiple($nids);
-    $storage->delete($entities);
-    \Drupal::messenger()->addMessage(t('@number %type node(s) have been deleted.', [
-      '@number' => count($nids),
-      '%type' => 'scheduler_api_test',
-    ]));
+  // Delete all content for the custom api types.
+  $api_data = [
+    'node' => ['db_id' => 'nid', 'type' => 'scheduler_api_node_test', 'bundle_field' => 'type'],
+    'media' => ['db_id' => 'mid', 'type' => 'scheduler_api_media_test', 'bundle_field' => 'bundle'],
+    'commerce_product' => ['db_id' => 'product_id', 'type' => 'scheduler_api_product_test', 'bundle_field' => 'type'],
+  ];
+  // @todo Re-write this using proper entity queries not database select().
+  foreach ($api_data as $entityTypeId => $values) {
+    $ids_query = \Drupal::database()->select($entityTypeId, 'a')
+      ->fields('a', ["{$values['db_id']}"])
+      ->condition($values['bundle_field'], ["{$values['type']}"], 'IN')
+      ->execute();
+    if ($ids = $ids_query->fetchCol()) {
+      $storage = \Drupal::entityTypeManager()->getStorage($entityTypeId);
+      $entities = $storage->loadMultiple($ids);
+      $entity = reset($entities);
+      $storage->delete($entities);
+      \Drupal::messenger()->addMessage(\Drupal::translation()->formatPlural(count($ids), '1 %bundle has been deleted.', '@count %bundle have been deleted.', [
+        '%bundle' => $entity->{$values['bundle_field']}->entity->label(),
+      ]));
+    }
   }
-
-  // The content type, custom fields and storage are deleted automatically by
-  // having 'enforced: module: - scheduler_api_test' in the
-  // config/install/*.yml files.
 }
diff --git a/web/modules/scheduler/tests/modules/scheduler_api_test/scheduler_api_test.module b/web/modules/scheduler/tests/modules/scheduler_api_test/scheduler_api_test.module
index 30e035ab10dc39b41deff701cc28e6db9a70133a..874598d3279bb060b60628d842a4134dfa30da8b 100644
--- a/web/modules/scheduler/tests/modules/scheduler_api_test/scheduler_api_test.module
+++ b/web/modules/scheduler/tests/modules/scheduler_api_test/scheduler_api_test.module
@@ -3,89 +3,240 @@
 /**
  * @file
  * Hook implementations of the Scheduler API Test module.
+ *
+ * Scheduler provides eight hook functions. Each has a non-specific version with
+ * no _{type}_ in the name, which is invoked for all entity types, and a version
+ * with _{type}_ in the name, which is invoked only when that entity types is
+ * being processed. Hence for complete test coverage this module has eight plain
+ * implementations, and eight implementations for Nodes, Media, Products and
+ * Taxonomy Terms.
  */
 
+use Drupal\commerce_product\Entity\Product;
+use Drupal\commerce_product\Entity\ProductInterface;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\media\Entity\Media;
+use Drupal\media\MediaInterface;
 use Drupal\node\Entity\Node;
 use Drupal\node\NodeInterface;
+use Drupal\taxonomy\Entity\Term;
+use Drupal\taxonomy\TermInterface;
 
 /**
- * Implements hook_scheduler_nid_list().
+ * Helper function to return all entities of a given type.
  */
-function scheduler_api_test_scheduler_nid_list($action) {
-  $nids = [];
+function _scheduler_api_test_get_entities($entityTypeId) {
+  switch ($entityTypeId) {
+    case 'node':
+      $results = Node::loadMultiple(\Drupal::entityQuery('node')->accessCheck(FALSE)->execute());
+      break;
 
-  // Check to see what test nodes exist.
-  $query = \Drupal::entityQuery('node');
-  $nodes = Node::loadMultiple($query->execute());
+    case 'media':
+      $results = Media::loadMultiple(\Drupal::entityQuery('media')->accessCheck(FALSE)->execute());
+      break;
+
+    case 'commerce_product':
+      $results = Product::loadMultiple(\Drupal::entityQuery('commerce_product')->accessCheck(FALSE)->execute());
+      break;
+
+    case 'taxonomy_term':
+      $results = Term::loadMultiple(\Drupal::entityQuery('taxonomy_term')->accessCheck(FALSE)->execute());
+      break;
+
+    default:
+      throw new \Exception("Entity type id '{$entityTypeId}' is unrecognised in _scheduler_api_test_get_entities().");
+  }
+  return $results;
+}
+
+/**
+ * Implements hook_scheduler_list().
+ */
+function scheduler_api_test_scheduler_list($process, $entityTypeId) {
+  $ids = [];
+  $request_time = \Drupal::time()->getRequestTime();
+  $results = _scheduler_api_test_get_entities($entityTypeId);
+  foreach ($results as $id => $entity) {
+    // If publishing and this is the 'publish me' test entity, set the date and
+    // add the id to the list.
+    if ($process == 'publish' && !$entity->isPublished() && $entity->label() == "Pink $entityTypeId list publish me") {
+      $entity->set('publish_on', $request_time)->save();
+      $ids[] = $id;
+    }
+    // If unpublishing and this is the 'unpublish me' test entity, set the date
+    // and add the id to the list.
+    if ($process == 'unpublish' && $entity->isPublished() && $entity->label() == "Pink $entityTypeId list unpublish me") {
+      $entity->set('unpublish_on', $request_time)->save();
+      $ids[] = $id;
+    }
+  }
+  return $ids;
+}
+
+/**
+ * Implements hook_scheduler_node_list().
+ */
+function scheduler_api_test_scheduler_node_list($process, $entityTypeId) {
+  $ids = [];
+  $request_time = \Drupal::time()->getRequestTime();
+  $results = _scheduler_api_test_get_entities($entityTypeId);
+  foreach ($results as $id => $entity) {
+    // If publishing and this is the 'publish me' test entity, set the date and
+    // add the id to the list.
+    if ($process == 'publish' && !$entity->isPublished() && $entity->label() == "Purple $entityTypeId list publish me") {
+      $entity->set('publish_on', $request_time)->save();
+      $ids[] = $id;
+    }
+    // If unpublishing and this is the 'unpublish me' test entity, set the date
+    // and add the id to the list.
+    if ($process == 'unpublish' && $entity->isPublished() && $entity->label() == "Purple $entityTypeId list unpublish me") {
+      $entity->set('unpublish_on', $request_time)->save();
+      $ids[] = $id;
+    }
+  }
+  return $ids;
+}
+
+/**
+ * Implements hook_scheduler_media_list().
+ */
+function scheduler_api_test_scheduler_media_list($process, $entityTypeId) {
+  // This hook does exactly the same as the node version, so re-use that.
+  return scheduler_api_test_scheduler_node_list($process, $entityTypeId);
+}
+
+/**
+ * Implements hook_scheduler_commerce_product_list().
+ */
+function scheduler_api_test_scheduler_commerce_product_list($process, $entityTypeId) {
+  // This hook does exactly the same as the node version, so re-use that.
+  return scheduler_api_test_scheduler_node_list($process, $entityTypeId);
+}
+
+/**
+ * Implements hook_scheduler_taxonomy_term_list().
+ */
+function scheduler_api_test_scheduler_taxonomy_term_list($process, $entityTypeId) {
+  // This hook does exactly the same as the node version, so re-use that.
+  return scheduler_api_test_scheduler_node_list($process, $entityTypeId);
+}
+
+/**
+ * Implements hook_scheduler_list_alter().
+ */
+function scheduler_api_test_scheduler_list_alter(&$ids, $process, $entityTypeId) {
   $request_time = \Drupal::time()->getRequestTime();
-  foreach ($nodes as $nid => $node) {
-    // If publishing and this is the publish test node, set a date and add
-    // the node id to the list.
-    if ($action == 'publish' && $node->title->value == 'API TEST nid_list publish me') {
-      $node->set('publish_on', $request_time)->save();
-      $nids[] = $nid;
+  $results = _scheduler_api_test_get_entities($entityTypeId);
+  foreach ($results as $id => $entity) {
+    if ($process == 'publish' && $entity->label() == "Pink $entityTypeId list_alter do not publish me") {
+      // Remove the id.
+      $ids = array_diff($ids, [$id]);
     }
-    // If unpublishing and this is the unpublish test node, set a date and add
-    // the node id to the list.
-    if ($action == 'unpublish' && $node->title->value == 'API TEST nid_list unpublish me') {
-      $node->set('unpublish_on', $request_time)->save();
-      $nids[] = $nid;
+    if ($process == 'publish' && $entity->label() == "Pink $entityTypeId list_alter publish me") {
+      // Set a publish_on date and add the id.
+      $entity->set('publish_on', $request_time)->save();
+      $ids[] = $id;
+    }
+    if ($process == 'unpublish' && $entity->label() == "Pink $entityTypeId list_alter do not unpublish me") {
+      // Remove the id.
+      $ids = array_diff($ids, [$id]);
+    }
+    if ($process == 'unpublish' && $entity->label() == "Pink $entityTypeId list_alter unpublish me") {
+      // Set an unpublish_on date and add the id.
+      $entity->set('unpublish_on', $request_time)->save();
+      $ids[] = $id;
     }
   }
-  return $nids;
 }
 
 /**
- * Implements hook_scheduler_nid_list_alter().
+ * Implements hook_scheduler_node_list_alter().
  */
-function scheduler_api_test_scheduler_nid_list_alter(&$nids, $action) {
-  $query = \Drupal::entityQuery('node');
-  $nodes = Node::loadMultiple($query->execute());
+function scheduler_api_test_scheduler_node_list_alter(&$ids, $process, $entityTypeId) {
   $request_time = \Drupal::time()->getRequestTime();
-  foreach ($nodes as $nid => $node) {
-    if ($action == 'publish' && $node->title->value == 'API TEST nid_list_alter do not publish me') {
-      // Remove the node id.
-      $nids = array_diff($nids, [$nid]);
+  $results = _scheduler_api_test_get_entities($entityTypeId);
+  foreach ($results as $id => $entity) {
+    if ($process == 'publish' && $entity->label() == "Purple $entityTypeId list_alter do not publish me") {
+      // Remove the id.
+      $ids = array_diff($ids, [$id]);
     }
-    if ($action == 'publish' && $node->title->value == 'API TEST nid_list_alter publish me') {
-      // Set a publish_on date and add the node id.
-      $node->set('publish_on', $request_time)->save();
-      $nids[] = $nid;
+    if ($process == 'publish' && $entity->label() == "Purple $entityTypeId list_alter publish me") {
+      // Set a publish_on date and add the id.
+      $entity->set('publish_on', $request_time)->save();
+      $ids[] = $id;
     }
-    if ($action == 'unpublish' && $node->title->value == 'API TEST nid_list_alter do not unpublish me') {
-      // Remove the node id.
-      $nids = array_diff($nids, [$nid]);
+    if ($process == 'unpublish' && $entity->label() == "Purple $entityTypeId list_alter do not unpublish me") {
+      // Remove the id.
+      $ids = array_diff($ids, [$id]);
     }
-    if ($action == 'unpublish' && $node->title->value == 'API TEST nid_list_alter unpublish me') {
-      // Set an unpublish_on date and add the node id.
-      $node->set('unpublish_on', $request_time)->save();
-      $nids[] = $nid;
+    if ($process == 'unpublish' && $entity->label() == "Purple $entityTypeId list_alter unpublish me") {
+      // Set an unpublish_on date and add the id.
+      $entity->set('unpublish_on', $request_time)->save();
+      $ids[] = $id;
     }
   }
-  return $nids;
 }
 
 /**
- * Implements hook_scheduler_allow_publishing().
+ * Implements hook_scheduler_media_list_alter().
  */
-function scheduler_api_test_scheduler_allow_publishing(NodeInterface $node) {
-  // If there is no 'Approved for Publishing' field then allow publishing.
-  if (!isset($node->field_approved_publishing)) {
+function scheduler_api_test_scheduler_media_list_alter(&$ids, $process, $entityTypeId) {
+  // This hook does exactly the same as the node version, so re-use that.
+  scheduler_api_test_scheduler_node_list_alter($ids, $process, $entityTypeId);
+}
+
+/**
+ * Implements hook_scheduler_commerce_product_list_alter().
+ */
+function scheduler_api_test_scheduler_commerce_product_list_alter(&$ids, $process, $entityTypeId) {
+  // This hook does exactly the same as the node version, so re-use that.
+  scheduler_api_test_scheduler_node_list_alter($ids, $process, $entityTypeId);
+}
+
+/**
+ * Implements hook_scheduler_taxonomy_term_list_alter().
+ */
+function scheduler_api_test_scheduler_taxonomy_term_list_alter(&$ids, $process, $entityTypeId) {
+  // This hook does exactly the same as the node version, so re-use that.
+  scheduler_api_test_scheduler_node_list_alter($ids, $process, $entityTypeId);
+}
+
+/**
+ * Implements hook_scheduler_publishing_allowed().
+ */
+function scheduler_api_test_scheduler_publishing_allowed(EntityInterface $entity) {
+  // @todo Fill in this function and add test coverage.
+}
+
+/**
+ * Generic function to check if the entity is allowed to be published.
+ */
+function _scheduler_api_test_publishing_allowed(EntityInterface $entity) {
+  // If there is no 'Approved for Publishing' field or we are not dealing with
+  // an entity designed for this test then allow publishing.
+  if (!isset($entity->field_approved_publishing) || !stristr($entity->label(), "blue {$entity->getEntityTypeId()}")) {
     $allowed = TRUE;
   }
   else {
-    // Only publish nodes that have 'Approved for Publishing' set.
-    $allowed = $node->field_approved_publishing->value;
-    // If publication is denied then inform the user why.
+    // Only publish entities that have 'Approved for Publishing' set.
+    $allowed = $entity->field_approved_publishing->value;
+    // If publishing is denied then inform the user why.
     if (!$allowed) {
-      \Drupal::messenger()->addMessage(t('%title is scheduled for publishing, but will not be published until approved.', ['%title' => $node->title->value]), 'status', FALSE);
+      // Show a message when the entity is saved.
+      \Drupal::messenger()->addMessage(t('%title is scheduled for publishing @publish_time, but will not be published until approved.', [
+        '%title' => $entity->label(),
+        '@publish_time' => \Drupal::service('date.formatter')->format($entity->publish_on->value, 'long'),
+      ]), 'status', FALSE);
       // If the time is in the past it means that the action has been prevented.
-      // Write a dblog message to show this. Give a link to view the node but
-      // cater for no nid as the node may be new and not yet saved.
-      if ($node->publish_on->value <= \Drupal::time()->getRequestTime()) {
+      // Write a dblog message to show this. Give a link to view the entity but
+      // cater for no id as the entity may be new and not yet saved.
+      if ($entity->publish_on->value <= \Drupal::time()->getRequestTime()) {
+        if ($entity->id() && $entity->hasLinkTemplate('canonical')) {
+          $link = $entity->toLink(t('View'))->toString();
+        }
         \Drupal::logger('scheduler_api_test')->warning('Publishing of "%title" is prevented until approved.', [
-          '%title' => $node->title->value,
-          'link' => $node->id() ? $node->toLink(t('View node'))->toString() : '',
+          '%title' => $entity->label(),
+          'link' => $link ?? NULL,
         ]);
       }
     }
@@ -94,26 +245,65 @@ function scheduler_api_test_scheduler_allow_publishing(NodeInterface $node) {
 }
 
 /**
- * Implements hook_scheduler_allow_unpublishing().
+ * Implements hook_scheduler_node_publishing_allowed().
+ */
+function scheduler_api_test_scheduler_node_publishing_allowed(NodeInterface $node) {
+  // Use the generic publishing_allowed helper function.
+  return _scheduler_api_test_publishing_allowed($node);
+}
+
+/**
+ * Implements hook_scheduler_media_publishing_allowed().
+ */
+function scheduler_api_test_scheduler_media_publishing_allowed(MediaInterface $media) {
+  // Use the generic publishing_allowed helper function.
+  return _scheduler_api_test_publishing_allowed($media);
+}
+
+/**
+ * Implements hook_scheduler_commerce_product_publishing_allowed().
+ */
+function scheduler_api_test_scheduler_commerce_product_publishing_allowed(ProductInterface $product) {
+  // Use the generic publishing_allowed helper function.
+  return _scheduler_api_test_publishing_allowed($product);
+}
+
+/**
+ * Implements hook_scheduler_unpublishing_allowed().
  */
-function scheduler_api_test_scheduler_allow_unpublishing(NodeInterface $node) {
-  // If there is no 'Approved for Unpublishing' field then allow unpublishing.
-  if (!isset($node->field_approved_unpublishing)) {
+function scheduler_api_test_scheduler_unpublishing_allowed(EntityInterface $entity) {
+  // @todo Fill in this function and add test coverage.
+}
+
+/**
+ * Generic function to check if the entity is allowed to be unpublished.
+ */
+function _scheduler_api_test_unpublishing_allowed(EntityInterface $entity) {
+  // If there is no 'Approved for Unpublishing' field or we are not dealing with
+  // an entity designed for this test then allow unpublishing.
+  if (!isset($entity->field_approved_unpublishing) || !stristr($entity->label(), "red {$entity->getEntityTypeId()}")) {
     $allowed = TRUE;
   }
   else {
-    // Only unpublish nodes that have 'Approved for Unpublishing' set.
-    $allowed = $node->field_approved_unpublishing->value;
-    // If unpublication is denied then inform the user why.
+    // Only unpublish entities that have 'Approved for Unpublishing' set.
+    $allowed = $entity->field_approved_unpublishing->value;
+    // If unpublishing is denied then inform the user why.
     if (!$allowed) {
-      \Drupal::messenger()->addMessage(t('%title is scheduled for unpublishing, but will not be unpublished until approved.', ['%title' => $node->title->value]), 'status', FALSE);
+      // Show a message when the entity is saved.
+      \Drupal::messenger()->addMessage(t('%title is scheduled for unpublishing @unpublish_time, but will not be unpublished until approved.', [
+        '%title' => $entity->label(),
+        '@unpublish_time' => \Drupal::service('date.formatter')->format($entity->unpublish_on->value, 'long'),
+      ]), 'status', FALSE);
       // If the time is in the past it means that the action has been prevented.
-      // Write a dblog message to show this. Give a link to view the node but
-      // cater for no nid as the node may be new and not yet saved.
-      if ($node->unpublish_on->value <= \Drupal::time()->getRequestTime()) {
+      // Write a dblog message to show this. Give a link to view the entity but
+      // cater for no id as the entity may be new and not yet saved.
+      if ($entity->unpublish_on->value <= \Drupal::time()->getRequestTime()) {
+        if ($entity->id() && $entity->hasLinkTemplate('canonical')) {
+          $link = $entity->toLink(t('View'))->toString();
+        }
         \Drupal::logger('scheduler_api_test')->warning('Unpublishing of "%title" is prevented until approved.', [
-          '%title' => $node->title->value,
-          'link' => $node->id() ? $node->toLink(t('View node'))->toString() : '',
+          '%title' => $entity->label(),
+          'link' => $link ?? NULL,
         ]);
       }
     }
@@ -122,12 +312,96 @@ function scheduler_api_test_scheduler_allow_unpublishing(NodeInterface $node) {
 }
 
 /**
- * Implements hook_scheduler_hide_publish_on_field().
+ * Implements hook_scheduler_node_unpublishing_allowed().
+ */
+function scheduler_api_test_scheduler_node_unpublishing_allowed(NodeInterface $node) {
+  // Use the generic unpublishing_allowed helper function.
+  return _scheduler_api_test_unpublishing_allowed($node);
+}
+
+/**
+ * Implements hook_scheduler_media_unpublishing_allowed().
+ */
+function scheduler_api_test_scheduler_media_unpublishing_allowed(MediaInterface $media) {
+  // Use the generic unpublishing_allowed helper function.
+  return _scheduler_api_test_unpublishing_allowed($media);
+}
+
+/**
+ * Implements hook_scheduler_commerce_product_unpublishing_allowed().
+ */
+function scheduler_api_test_scheduler_commerce_product_unpublishing_allowed(ProductInterface $product) {
+  // Use the generic unpublishing_allowed helper function.
+  return _scheduler_api_test_unpublishing_allowed($product);
+}
+
+/**
+ * Implements hook_scheduler_hide_publish_date().
+ */
+function scheduler_api_test_scheduler_hide_publish_date($form, $form_state, $entity) {
+  // Hide the publish_on field if the title contains 'orange {type}'.
+  if (stristr($entity->label() ?? '', "orange {$entity->getEntityTypeId()}")) {
+    \Drupal::messenger()->addMessage(t('Scheduler_Api_Test: The publish_on field is hidden for orange.'), 'status', FALSE);
+    return TRUE;
+  }
+  else {
+    return FALSE;
+  }
+}
+
+/**
+ * Generic function to hide the publish_on date field.
+ */
+function _scheduler_api_test_hide_publish_date($form, $form_state, $entity) {
+  // Hide the publish_on field if the title contains 'green {type}'.
+  if (stristr($entity->label() ?? '', "green {$entity->getEntityTypeId()}")) {
+    \Drupal::messenger()->addMessage(t('Scheduler_Api_Test: The publish_on field is hidden for green.'), 'status', FALSE);
+    return TRUE;
+  }
+  else {
+    return FALSE;
+  }
+}
+
+/**
+ * Implements hook_scheduler_node_hide_publish_date().
+ */
+function scheduler_api_test_scheduler_node_hide_publish_date($form, $form_state, $entity) {
+  // Use the generic hide_publish_date helper function.
+  return _scheduler_api_test_hide_publish_date($form, $form_state, $entity);
+}
+
+/**
+ * Implements hook_scheduler_media_hide_publish_date().
+ */
+function scheduler_api_test_scheduler_media_hide_publish_date($form, $form_state, $entity) {
+  // Use the generic hide_publish_date helper function.
+  return _scheduler_api_test_hide_publish_date($form, $form_state, $entity);
+}
+
+/**
+ * Implements hook_scheduler_commerce_product_hide_publish_date().
+ */
+function scheduler_api_test_scheduler_commerce_product_hide_publish_date($form, $form_state, $entity) {
+  // Use the generic hide_publish_date helper function.
+  return _scheduler_api_test_hide_publish_date($form, $form_state, $entity);
+}
+
+/**
+ * Implements hook_scheduler_taxonomy_term_hide_publish_date().
  */
-function scheduler_api_test_scheduler_hide_publish_on_field($form, $form_state, $node) {
-  // Hide the publish_on field if the node title contains orange or green.
-  if (stristr($node->title->value, 'orange') || stristr($node->title->value, 'green')) {
-    \Drupal::messenger()->addMessage(t('Scheduler_Api_Test: The publish_on field is hidden for orange or green node titles.'), 'status', FALSE);
+function scheduler_api_test_scheduler_taxonomy_term_hide_publish_date($form, $form_state, $entity) {
+  // Use the generic hide_publish_date helper function.
+  return _scheduler_api_test_hide_publish_date($form, $form_state, $entity);
+}
+
+/**
+ * Implements hook_scheduler_hide_unpublish_date().
+ */
+function scheduler_api_test_scheduler_hide_unpublish_date($form, $form_state, $entity) {
+  // Hide the unpublish_on field if the title contains 'yellow {type}'.
+  if (stristr($entity->label() ?? '', "yellow {$entity->getEntityTypeId()}")) {
+    \Drupal::messenger()->addMessage(t('Scheduler_Api_Test: The unpublish_on field is hidden for yellow.'), 'status', FALSE);
     return TRUE;
   }
   else {
@@ -136,12 +410,12 @@ function scheduler_api_test_scheduler_hide_publish_on_field($form, $form_state,
 }
 
 /**
- * Implements hook_scheduler_hide_unpublish_on_field().
+ * Generic function to hide the unpublish_on date field.
  */
-function scheduler_api_test_scheduler_hide_unpublish_on_field($form, $form_state, $node) {
-  // Hide the publish_on field if the node title contains yellow or green.
-  if (stristr($node->title->value, 'yellow') || stristr($node->title->value, 'green')) {
-    \Drupal::messenger()->addMessage(t('Scheduler_Api_Test: The unpublish_on field is hidden for yellow or green node titles.'), 'status', FALSE);
+function _scheduler_api_test_hide_unpublish_date($form, $form_state, $entity) {
+  // Hide the unpublish_on field if the title contains 'green {type}'.
+  if (stristr($entity->label() ?? '', "green {$entity->getEntityTypeId()}")) {
+    \Drupal::messenger()->addMessage(t('Scheduler_Api_Test: The unpublish_on field is hidden for green.'), 'status', FALSE);
     return TRUE;
   }
   else {
@@ -150,45 +424,159 @@ function scheduler_api_test_scheduler_hide_unpublish_on_field($form, $form_state
 }
 
 /**
- * Implements hook_scheduler_publish_action().
+ * Implements hook_scheduler_node_hide_unpublish_date().
+ */
+function scheduler_api_test_scheduler_node_hide_unpublish_date($form, $form_state, $entity) {
+  // Use the generic hide_unpublish_date helper function.
+  return _scheduler_api_test_hide_unpublish_date($form, $form_state, $entity);
+}
+
+/**
+ * Implements hook_scheduler_media_hide_unpublish_date().
  */
-function scheduler_api_test_scheduler_publish_action($node) {
-  if (stristr($node->title->value, 'red')) {
-    // Nodes with red in the title are simulated to cause a failure and should
-    // then be skipped by Scheduler.
-    $node->set('title', $node->title->value . ' - publishing failed in API test module');
-    \Drupal::messenger()->addMessage(t('Scheduler_Api_Test: Red nodes should cause Scheduler to abandon publishing.'), 'status', FALSE);
+function scheduler_api_test_scheduler_media_hide_unpublish_date($form, $form_state, $entity) {
+  // Use the generic hide_unpublish_date helper function.
+  return _scheduler_api_test_hide_unpublish_date($form, $form_state, $entity);
+}
+
+/**
+ * Implements hook_scheduler_commerce_product_hide_unpublish_date().
+ */
+function scheduler_api_test_scheduler_commerce_product_hide_unpublish_date($form, $form_state, $entity) {
+  // Use the generic hide_unpublish_date helper function.
+  return _scheduler_api_test_hide_unpublish_date($form, $form_state, $entity);
+}
+
+/**
+ * Implements hook_scheduler_taxonomy_term_hide_unpublish_date().
+ */
+function scheduler_api_test_scheduler_taxonomy_term_hide_unpublish_date($form, $form_state, $entity) {
+  // Use the generic hide_unpublish_date helper function.
+  return _scheduler_api_test_hide_unpublish_date($form, $form_state, $entity);
+}
+
+/**
+ * Implements hook_scheduler_publish_process().
+ */
+function scheduler_api_test_scheduler_publish_process(EntityInterface $entity) {
+  // Any entity with 'red {type}' in the title is simulated to cause a failure
+  // and should then be skipped by Scheduler.
+  if (stristr($entity->label(), "red {$entity->getEntityTypeId()}")) {
+    $label_field = $entity->getEntityType()->getKey('label');
+    $entity->set($label_field, $entity->label() . ' - publishing failed in API test module')->save();
+    \Drupal::messenger()->addMessage(t('Scheduler_Api_Test: Red should cause Scheduler to abandon publishing.'), 'status', FALSE);
     return -1;
   }
-  elseif (stristr($node->title->value, 'yellow')) {
-    // Nodes with yellow in the title are simulated to be processed by this
-    // hook, and will not be published by Scheduler.
-    $node->set('title', $node->title->value . ' - publishing processed by API test module');
-    $node->setPublished();
-    \Drupal::messenger()->addMessage(t('Scheduler_Api_Test: Yellow nodes should not have publishing processed by Scheduler.'), 'status', FALSE);
+  return 0;
+}
+
+/**
+ * Generic function to process third-party publishing.
+ */
+function _scheduler_api_test_publish_process(EntityInterface $entity) {
+  // Entities with 'yellow {type}' in the title are simulated to be processed
+  // by this hook, and will not be published by Scheduler.
+  if (stristr($entity->label(), "yellow {$entity->getEntityTypeId()}")) {
+    $label_field = $entity->getEntityType()->getKey('label');
+    $entity->set($label_field, $entity->label() . ' - publishing processed by API test module');
+    $entity->setPublished()->save();
+    \Drupal::messenger()->addMessage(t('Scheduler_Api_Test: Yellow should not have publishing processed by Scheduler.'), 'status', FALSE);
     return 1;
   }
   return 0;
 }
 
 /**
- * Implements hook_scheduler_unpublish_action().
+ * Implements hook_scheduler_node_publish_process().
+ */
+function scheduler_api_test_scheduler_node_publish_process(NodeInterface $node) {
+  // Use the generic publish_process helper function.
+  return _scheduler_api_test_publish_process($node);
+}
+
+/**
+ * Implements hook_scheduler_media_publish_process().
+ */
+function scheduler_api_test_scheduler_media_publish_process(MediaInterface $media) {
+  // Use the generic publish_process helper function.
+  return _scheduler_api_test_publish_process($media);
+}
+
+/**
+ * Implements hook_scheduler_commerce_product_publish_process().
+ */
+function scheduler_api_test_scheduler_commerce_product_publish_process(ProductInterface $product) {
+  // Use the generic publish_process helper function.
+  return _scheduler_api_test_publish_process($product);
+}
+
+/**
+ * Implements hook_scheduler_taxonomy_term_publish_process().
+ */
+function scheduler_api_test_scheduler_taxonomy_term_publish_process(TermInterface $term) {
+  // Use the generic publish_process helper function.
+  return _scheduler_api_test_publish_process($term);
+}
+
+/**
+ * Implements hook_scheduler_unpublish_process().
  */
-function scheduler_api_test_scheduler_unpublish_action($node) {
-  if (stristr($node->title->value, 'blue')) {
-    // Nodes with blue in the title are simulated to cause a failure and should
-    // then be skipped by Scheduler.
-    $node->set('title', $node->title->value . ' - unpublishing failed in API test module');
-    \Drupal::messenger()->addMessage(t('Scheduler_Api_Test: Blue nodes should cause Scheduler to abandon unpublishing.'), 'status', FALSE);
+function scheduler_api_test_scheduler_unpublish_process(EntityInterface $entity) {
+  // Any entity with 'blue {type}' in the title is simulated to cause a failure
+  // and should then be skipped by Scheduler.
+  if (stristr($entity->label(), "blue {$entity->getEntityTypeId()}")) {
+    $label_field = $entity->getEntityType()->getKey('label');
+    $entity->set($label_field, $entity->label() . ' - unpublishing failed in API test module')->save();
+    \Drupal::messenger()->addMessage(t('Scheduler_Api_Test: Blue should cause Scheduler to abandon unpublishing.'), 'status', FALSE);
     return -1;
   }
-  if (stristr($node->title->value, 'orange')) {
-    // Nodes with orange in the title are simulated to be processed by this
-    // hook, and will not be published by Scheduler.
-    $node->set('title', $node->title->value . ' - unpublishing processed by API test module');
-    $node->setUnpublished();
-    \Drupal::messenger()->addMessage(t('Scheduler_Api_Test: Orange nodes should not have unpublishing processed by Scheduler.'), 'status', FALSE);
+  return 0;
+}
+
+/**
+ * Generic function to process third-party unpublishing.
+ */
+function _scheduler_api_test_unpublish_process(EntityInterface $entity) {
+  // Entities with 'orange {type}' in the title are simulated to be processed by
+  // this hook, and will not be unpublished by Scheduler.
+  if (stristr($entity->label(), "orange {$entity->getEntityTypeId()}")) {
+    $label_field = $entity->getEntityType()->getKey('label');
+    $entity->set($label_field, $entity->label() . ' - unpublishing processed by API test module')->save();
+    $entity->setUnpublished()->save();
+    \Drupal::messenger()->addMessage(t('Scheduler_Api_Test: Orange should not have unpublishing processed by Scheduler.'), 'status', FALSE);
     return 1;
   }
   return 0;
 }
+
+/**
+ * Implements hook_scheduler_node_unpublish_process().
+ */
+function scheduler_api_test_scheduler_node_unpublish_process(NodeInterface $node) {
+  // Use the generic unpublish_process helper function.
+  return _scheduler_api_test_unpublish_process($node);
+}
+
+/**
+ * Implements hook_scheduler_media_unpublish_process().
+ */
+function scheduler_api_test_scheduler_media_unpublish_process(MediaInterface $media) {
+  // Use the generic unpublish_process helper function.
+  return _scheduler_api_test_unpublish_process($media);
+}
+
+/**
+ * Implements hook_scheduler_commerce_product_unpublish_process().
+ */
+function scheduler_api_test_scheduler_commerce_product_unpublish_process(ProductInterface $product) {
+  // Use the generic unpublish_process helper function.
+  return _scheduler_api_test_unpublish_process($product);
+}
+
+/**
+ * Implements hook_scheduler_taxonomy_term_unpublish_process().
+ */
+function scheduler_api_test_scheduler_taxonomy_term_unpublish_process(TermInterface $term) {
+  // Use the generic unpublish_process helper function.
+  return _scheduler_api_test_unpublish_process($term);
+}
diff --git a/web/modules/scheduler/tests/modules/scheduler_api_test/src/EventSubscriber.php b/web/modules/scheduler/tests/modules/scheduler_api_test/src/EventSubscriber.php
index 4efdc6c63c2f47c16f60a6e26be689d678cfe703..1f9101e4241c30e5e3f3a54cb0f471b7ae1fd4bc 100644
--- a/web/modules/scheduler/tests/modules/scheduler_api_test/src/EventSubscriber.php
+++ b/web/modules/scheduler/tests/modules/scheduler_api_test/src/EventSubscriber.php
@@ -2,8 +2,11 @@
 
 namespace Drupal\scheduler_api_test;
 
+use Drupal\scheduler\Event\SchedulerCommerceProductEvents;
+use Drupal\scheduler\Event\SchedulerMediaEvents;
+use Drupal\scheduler\Event\SchedulerNodeEvents;
+use Drupal\scheduler\Event\SchedulerTaxonomyTermEvents;
 use Drupal\scheduler\SchedulerEvent;
-use Drupal\scheduler\SchedulerEvents;
 use Symfony\Component\EventDispatcher\EventSubscriberInterface;
 
 /**
@@ -12,15 +15,19 @@
  * These events allow modules to react to the Scheduler process being performed.
  * They are all triggered during Scheduler cron processing with the exception of
  * 'pre_publish_immediately' and 'publish_immediately' which are triggered from
- * scheduler_node_presave().
+ * scheduler_entity_presave().
  *
- * The tests use the standard 'sticky' and 'promote' fields as a simple way to
- * check the processing. Use extra conditional checks on $node->isPublished() to
+ * The node event tests use the 'sticky' and 'promote' fields as a simple way to
+ * check the processing. There are extra conditional checks on isPublished() to
  * make the tests stronger so they fail if the calls are in the wrong place.
  *
+ * The media tests cannot use 'sticky' and 'promote' as these fields do not
+ * exist, so the media name is altered instead. This is also the case with
+ * products and taxonomy terms.
+ *
  * To allow this API test module to be enabled interactively (for development
  * and testing) we must avoid unwanted side-effects on other non-test nodes.
- * This is done simply by checking that the node title starts with 'API TEST'.
+ * This is done simply by checking that the titles start with 'API TEST'.
  *
  * @group scheduler_api_test
  */
@@ -30,23 +37,53 @@ class EventSubscriber implements EventSubscriberInterface {
    * {@inheritdoc}
    */
   public static function getSubscribedEvents() {
+
+    // Initialise the array to avoid 'variable is undefined' phpcs error.
+    $events = [];
+
     // The values in the arrays give the function names below.
-    $events[SchedulerEvents::PRE_PUBLISH][] = ['apiTestPrePublish'];
-    $events[SchedulerEvents::PUBLISH][] = ['apiTestPublish'];
-    $events[SchedulerEvents::PRE_UNPUBLISH][] = ['apiTestPreUnpublish'];
-    $events[SchedulerEvents::UNPUBLISH][] = ['apiTestUnpublish'];
-    $events[SchedulerEvents::PRE_PUBLISH_IMMEDIATELY][] = ['apiTestPrePublishImmediately'];
-    $events[SchedulerEvents::PUBLISH_IMMEDIATELY][] = ['apiTestPublishImmediately'];
+    // These six events are the originals, dispatched for Nodes.
+    $events[SchedulerNodeEvents::PRE_PUBLISH][] = ['apiTestNodePrePublish'];
+    $events[SchedulerNodeEvents::PUBLISH][] = ['apiTestNodePublish'];
+    $events[SchedulerNodeEvents::PRE_UNPUBLISH][] = ['apiTestNodePreUnpublish'];
+    $events[SchedulerNodeEvents::UNPUBLISH][] = ['apiTestNodeUnpublish'];
+    $events[SchedulerNodeEvents::PRE_PUBLISH_IMMEDIATELY][] = ['apiTestNodePrePublishImmediately'];
+    $events[SchedulerNodeEvents::PUBLISH_IMMEDIATELY][] = ['apiTestNodePublishImmediately'];
+
+    // These six events are dispatched for Media entity types only.
+    $events[SchedulerMediaEvents::PRE_PUBLISH][] = ['apiTestMediaPrePublish'];
+    $events[SchedulerMediaEvents::PUBLISH][] = ['apiTestMediaPublish'];
+    $events[SchedulerMediaEvents::PRE_UNPUBLISH][] = ['apiTestMediaPreUnpublish'];
+    $events[SchedulerMediaEvents::UNPUBLISH][] = ['apiTestMediaUnpublish'];
+    $events[SchedulerMediaEvents::PRE_PUBLISH_IMMEDIATELY][] = ['apiTestMediaPrePublishImmediately'];
+    $events[SchedulerMediaEvents::PUBLISH_IMMEDIATELY][] = ['apiTestMediaPublishImmediately'];
+
+    // These six events are dispatched for Product entity types only.
+    $events[SchedulerCommerceProductEvents::PRE_PUBLISH][] = ['apiTestProductPrePublish'];
+    $events[SchedulerCommerceProductEvents::PUBLISH][] = ['apiTestProductPublish'];
+    $events[SchedulerCommerceProductEvents::PRE_UNPUBLISH][] = ['apiTestProductPreUnpublish'];
+    $events[SchedulerCommerceProductEvents::UNPUBLISH][] = ['apiTestProductUnpublish'];
+    $events[SchedulerCommerceProductEvents::PRE_PUBLISH_IMMEDIATELY][] = ['apiTestProductPrePublishImmediately'];
+    $events[SchedulerCommerceProductEvents::PUBLISH_IMMEDIATELY][] = ['apiTestProductPublishImmediately'];
+
+    // These six events are dispatched for Taxomony Term entity types only.
+    $events[SchedulerTaxonomyTermEvents::PRE_PUBLISH][] = ['apiTestTaxonomyTermPrePublish'];
+    $events[SchedulerTaxonomyTermEvents::PUBLISH][] = ['apiTestTaxonomyTermPublish'];
+    $events[SchedulerTaxonomyTermEvents::PRE_UNPUBLISH][] = ['apiTestTaxonomyTermPreUnpublish'];
+    $events[SchedulerTaxonomyTermEvents::UNPUBLISH][] = ['apiTestTaxonomyTermUnpublish'];
+    $events[SchedulerTaxonomyTermEvents::PRE_PUBLISH_IMMEDIATELY][] = ['apiTestTaxonomyTermPrePublishImmediately'];
+    $events[SchedulerTaxonomyTermEvents::PUBLISH_IMMEDIATELY][] = ['apiTestTaxonomyTermPublishImmediately'];
+
     return $events;
   }
 
   /**
    * Operations to perform before Scheduler publishes a node.
    *
-   * @param \Drupal\scheduler\SchedulerEvent $event
+   * @param \Drupal\scheduler\Event\SchedulerEvent $event
    *   The scheduler event.
    */
-  public function apiTestPrePublish(SchedulerEvent $event) {
+  public function apiTestNodePrePublish(SchedulerEvent $event) {
     /** @var \Drupal\node\Entity\Node $node */
     $node = $event->getNode();
     // Before publishing a node make it sticky.
@@ -57,65 +94,65 @@ public function apiTestPrePublish(SchedulerEvent $event) {
   }
 
   /**
-   * Operations to perform before Scheduler unpublishes a node.
+   * Operations to perform after Scheduler publishes a node.
    *
-   * @param \Drupal\scheduler\SchedulerEvent $event
+   * @param \Drupal\scheduler\Event\SchedulerEvent $event
    *   The scheduler event.
    */
-  public function apiTestPreUnpublish(SchedulerEvent $event) {
+  public function apiTestNodePublish(SchedulerEvent $event) {
     /** @var \Drupal\node\Entity\Node $node */
     $node = $event->getNode();
+    // After publishing a node promote it to the front page.
     if ($node->isPublished() && strpos($node->title->value, 'API TEST') === 0) {
-      // Before unpublishing a node make it unsticky.
-      $node->setSticky(FALSE);
+      $node->setPromoted(TRUE)->save();
       $event->setNode($node);
     }
   }
 
   /**
-   * Operations before Scheduler publishes a node immediately not via cron.
+   * Operations to perform before Scheduler unpublishes a node.
    *
-   * @param \Drupal\scheduler\SchedulerEvent $event
+   * @param \Drupal\scheduler\Event\SchedulerEvent $event
    *   The scheduler event.
    */
-  public function apiTestPrePublishImmediately(SchedulerEvent $event) {
+  public function apiTestNodePreUnpublish(SchedulerEvent $event) {
     /** @var \Drupal\node\Entity\Node $node */
     $node = $event->getNode();
-    // Before publishing immediately set the node to sticky.
-    if (!$node->isPromoted() && strpos($node->title->value, 'API TEST') === 0) {
-      $node->setSticky(TRUE);
+    // Before unpublishing a node make it unsticky.
+    if ($node->isPublished() && strpos($node->title->value, 'API TEST') === 0) {
+      $node->setSticky(FALSE);
       $event->setNode($node);
     }
   }
 
   /**
-   * Operations to perform after Scheduler publishes a node.
+   * Operations to perform after Scheduler unpublishes a node.
    *
-   * @param \Drupal\scheduler\SchedulerEvent $event
+   * @param \Drupal\scheduler\Event\SchedulerEvent $event
    *   The scheduler event.
    */
-  public function apiTestPublish(SchedulerEvent $event) {
+  public function apiTestNodeUnpublish(SchedulerEvent $event) {
     /** @var \Drupal\node\Entity\Node $node */
     $node = $event->getNode();
-    // After publishing a node promote it to the front page.
-    if ($node->isPublished() && strpos($node->title->value, 'API TEST') === 0) {
-      $node->setPromoted(TRUE);
+    // After unpublishing a node remove it from the front page.
+    if (!$node->isPublished() && strpos($node->title->value, 'API TEST') === 0) {
+      $node->setPromoted(FALSE)->save();
       $event->setNode($node);
     }
   }
 
   /**
-   * Operations to perform after Scheduler unpublishes a node.
+   * Operations before Scheduler publishes a node immediately not via cron.
    *
-   * @param \Drupal\scheduler\SchedulerEvent $event
+   * @param \Drupal\scheduler\Event\SchedulerEvent $event
    *   The scheduler event.
    */
-  public function apiTestUnpublish(SchedulerEvent $event) {
+  public function apiTestNodePrePublishImmediately(SchedulerEvent $event) {
     /** @var \Drupal\node\Entity\Node $node */
     $node = $event->getNode();
-    // After unpublishing a node remove it from the front page.
-    if (!$node->isPublished() && strpos($node->title->value, 'API TEST') === 0) {
-      $node->setPromoted(FALSE);
+    // Before publishing immediately set the node to sticky.
+    if (!$node->isPromoted() && strpos($node->title->value, 'API TEST') === 0) {
+      $node->setSticky(TRUE);
       $event->setNode($node);
     }
   }
@@ -123,10 +160,10 @@ public function apiTestUnpublish(SchedulerEvent $event) {
   /**
    * Operations after Scheduler publishes a node immediately not via cron.
    *
-   * @param \Drupal\scheduler\SchedulerEvent $event
+   * @param \Drupal\scheduler\Event\SchedulerEvent $event
    *   The scheduler event.
    */
-  public function apiTestPublishImmediately(SchedulerEvent $event) {
+  public function apiTestNodePublishImmediately(SchedulerEvent $event) {
     /** @var \Drupal\node\Entity\Node $node */
     $node = $event->getNode();
     // After publishing immediately set the node to promoted and change the
@@ -138,4 +175,280 @@ public function apiTestPublishImmediately(SchedulerEvent $event) {
     }
   }
 
+  /**
+   * Generic helper function to do the PrePublish work.
+   *
+   * @param \Drupal\scheduler\Event\SchedulerEvent $event
+   *   The scheduler event.
+   */
+  private function apiTestPrePublish(SchedulerEvent $event) {
+    $entity = $event->getEntity();
+    if (!$entity->isPublished() && strpos($entity->label(), "API TEST {$entity->getEntityTypeId()}") === 0) {
+      $label_field = $entity->getEntityType()->getKey('label');
+      $entity->set($label_field, "API TEST {$entity->getEntityTypeId()} - changed by PRE_PUBLISH event");
+      $event->setEntity($entity);
+    }
+  }
+
+  /**
+   * Generic helper function to do the Publish work.
+   *
+   * @param \Drupal\scheduler\Event\SchedulerEvent $event
+   *   The scheduler event.
+   */
+  private function apiTestPublish(SchedulerEvent $event) {
+    $entity = $event->getEntity();
+    // The label will be changed here only if it has already been changed by the
+    // PRE_PUBLISH event. This will demonstrate that both events worked.
+    if ($entity->isPublished() && $entity->label() == "API TEST {$entity->getEntityTypeId()} - changed by PRE_PUBLISH event") {
+      $label_field = $entity->getEntityType()->getKey('label');
+      $entity->set($label_field, "API TEST {$entity->getEntityTypeId()} - altered a second time by PUBLISH event")->save();
+      $event->setEntity($entity);
+    }
+  }
+
+  /**
+   * Generic helper function to do the PreUnpublish work.
+   *
+   * @param \Drupal\scheduler\Event\SchedulerEvent $event
+   *   The scheduler event.
+   */
+  private function apiTestPreUnpublish(SchedulerEvent $event) {
+    $entity = $event->getEntity();
+    if ($entity->isPublished() && strpos($entity->label(), "API TEST {$entity->getEntityTypeId()}") === 0) {
+      $label_field = $entity->getEntityType()->getKey('label');
+      $entity->set($label_field, "API TEST {$entity->getEntityTypeId()} - changed by PRE_UNPUBLISH event");
+      $event->setEntity($entity);
+    }
+  }
+
+  /**
+   * Generic helper function to do the Unpublish work.
+   *
+   * @param \Drupal\scheduler\Event\SchedulerEvent $event
+   *   The scheduler event.
+   */
+  private function apiTestUnpublish(SchedulerEvent $event) {
+    $entity = $event->getEntity();
+    // The name will be changed here only if it has already been changed by the
+    // PRE_UNPUBLISH event. This will demonstrate that both events worked.
+    if (!$entity->isPublished() && $entity->label() == "API TEST {$entity->getEntityTypeId()} - changed by PRE_UNPUBLISH event") {
+      $label_field = $entity->getEntityType()->getKey('label');
+      $entity->set($label_field, "API TEST {$entity->getEntityTypeId()} - altered a second time by UNPUBLISH event")->save();
+      $event->setEntity($entity);
+    }
+  }
+
+  /**
+   * Generic helper function to do the PrePublishImmediately work.
+   *
+   * @param \Drupal\scheduler\Event\SchedulerEvent $event
+   *   The scheduler event.
+   */
+  public function apiTestPrePublishImmediately(SchedulerEvent $event) {
+    $entity = $event->getEntity();
+    if (!$entity->isPublished() && strpos($entity->label(), "API TEST {$entity->getEntityTypeId()}") === 0) {
+      $label_field = $entity->getEntityType()->getKey('label');
+      $entity->set($label_field, "API TEST {$entity->getEntityTypeId()} - changed by PRE_PUBLISH_IMMEDIATELY event");
+      $event->setEntity($entity);
+    }
+  }
+
+  /**
+   * Generic helper function to do the PublishImmediately work.
+   *
+   * @param \Drupal\scheduler\Event\SchedulerEvent $event
+   *   The scheduler event.
+   */
+  public function apiTestPublishImmediately(SchedulerEvent $event) {
+    $entity = $event->getEntity();
+    // The name will be changed here only if it has already been changed in the
+    // PRE_PUBLISH_IMMEDIATELY event function, to show that both events worked.
+    if ($entity->label() == "API TEST {$entity->getEntityTypeId()} - changed by PRE_PUBLISH_IMMEDIATELY event") {
+      $label_field = $entity->getEntityType()->getKey('label');
+      $entity->set($label_field, "API TEST {$entity->getEntityTypeId()} - altered a second time by PUBLISH_IMMEDIATELY event");
+      $event->setEntity($entity);
+    }
+  }
+
+  /**
+   * Operations to perform before Scheduler publishes a media item.
+   *
+   * @param \Drupal\scheduler\Event\SchedulerEvent $event
+   *   The scheduler event.
+   */
+  public function apiTestMediaPrePublish(SchedulerEvent $event) {
+    $this->apiTestPrePublish($event);
+  }
+
+  /**
+   * Operations to perform after Scheduler publishes a media item.
+   *
+   * @param \Drupal\scheduler\Event\SchedulerEvent $event
+   *   The scheduler event.
+   */
+  public function apiTestMediaPublish(SchedulerEvent $event) {
+    $this->apiTestPublish($event);
+  }
+
+  /**
+   * Operations to perform before Scheduler unpublishes a media item.
+   *
+   * @param \Drupal\scheduler\Event\SchedulerEvent $event
+   *   The scheduler event.
+   */
+  public function apiTestMediaPreUnpublish(SchedulerEvent $event) {
+    $this->apiTestPreUnpublish($event);
+  }
+
+  /**
+   * Operations to perform after Scheduler unpublishes a media item.
+   *
+   * @param \Drupal\scheduler\Event\SchedulerEvent $event
+   *   The scheduler event.
+   */
+  public function apiTestMediaUnpublish(SchedulerEvent $event) {
+    $this->apiTestUnpublish($event);
+  }
+
+  /**
+   * Operations before Scheduler publishes a media immediately not via cron.
+   *
+   * @param \Drupal\scheduler\Event\SchedulerEvent $event
+   *   The scheduler event.
+   */
+  public function apiTestMediaPrePublishImmediately(SchedulerEvent $event) {
+    $this->apiTestPrePublishImmediately($event);
+  }
+
+  /**
+   * Operations after Scheduler publishes a media immediately not via cron.
+   *
+   * @param \Drupal\scheduler\Event\SchedulerEvent $event
+   *   The scheduler event.
+   */
+  public function apiTestMediaPublishImmediately(SchedulerEvent $event) {
+    $this->apiTestPublishImmediately($event);
+  }
+
+  /**
+   * Operations to perform before Scheduler publishes a commerce product.
+   *
+   * @param \Drupal\scheduler\Event\SchedulerEvent $event
+   *   The scheduler event.
+   */
+  public function apiTestProductPrePublish(SchedulerEvent $event) {
+    $this->apiTestPrePublish($event);
+  }
+
+  /**
+   * Operations to perform after Scheduler publishes a commerce product.
+   *
+   * @param \Drupal\scheduler\Event\SchedulerEvent $event
+   *   The scheduler event.
+   */
+  public function apiTestProductPublish(SchedulerEvent $event) {
+    $this->apiTestPublish($event);
+  }
+
+  /**
+   * Operations to perform before Scheduler unpublishes a commerce product.
+   *
+   * @param \Drupal\scheduler\Event\SchedulerEvent $event
+   *   The scheduler event.
+   */
+  public function apiTestProductPreUnpublish(SchedulerEvent $event) {
+    $this->apiTestPreUnpublish($event);
+  }
+
+  /**
+   * Operations to perform after Scheduler unpublishes a commerce product.
+   *
+   * @param \Drupal\scheduler\Event\SchedulerEvent $event
+   *   The scheduler event.
+   */
+  public function apiTestProductUnpublish(SchedulerEvent $event) {
+    $this->apiTestUnpublish($event);
+  }
+
+  /**
+   * Operations before Scheduler publishes a product immediately not via cron.
+   *
+   * @param \Drupal\scheduler\Event\SchedulerEvent $event
+   *   The scheduler event.
+   */
+  public function apiTestProductPrePublishImmediately(SchedulerEvent $event) {
+    $this->apiTestPrePublishImmediately($event);
+  }
+
+  /**
+   * Operations after Scheduler publishes a product immediately not via cron.
+   *
+   * @param \Drupal\scheduler\Event\SchedulerEvent $event
+   *   The scheduler event.
+   */
+  public function apiTestProductPublishImmediately(SchedulerEvent $event) {
+    $this->apiTestPublishImmediately($event);
+  }
+
+  /**
+   * Operations to perform before Scheduler publishes a taxonomy term.
+   *
+   * @param \Drupal\scheduler\Event\SchedulerEvent $event
+   *   The scheduler event.
+   */
+  public function apiTestTaxonomyTermPrePublish(SchedulerEvent $event) {
+    $this->apiTestPrePublish($event);
+  }
+
+  /**
+   * Operations to perform after Scheduler publishes a taxonomy term.
+   *
+   * @param \Drupal\scheduler\Event\SchedulerEvent $event
+   *   The scheduler event.
+   */
+  public function apiTestTaxonomyTermPublish(SchedulerEvent $event) {
+    $this->apiTestPublish($event);
+  }
+
+  /**
+   * Operations to perform before Scheduler unpublishes a taxonomy term.
+   *
+   * @param \Drupal\scheduler\Event\SchedulerEvent $event
+   *   The scheduler event.
+   */
+  public function apiTestTaxonomyTermPreUnpublish(SchedulerEvent $event) {
+    $this->apiTestPreUnpublish($event);
+  }
+
+  /**
+   * Operations to perform after Scheduler unpublishes a taxonomy term.
+   *
+   * @param \Drupal\scheduler\Event\SchedulerEvent $event
+   *   The scheduler event.
+   */
+  public function apiTestTaxonomyTermUnpublish(SchedulerEvent $event) {
+    $this->apiTestUnpublish($event);
+  }
+
+  /**
+   * Operations before Scheduler publishes a term immediately not via cron.
+   *
+   * @param \Drupal\scheduler\Event\SchedulerEvent $event
+   *   The scheduler event.
+   */
+  public function apiTestTaxonomyTermPrePublishImmediately(SchedulerEvent $event) {
+    $this->apiTestPrePublishImmediately($event);
+  }
+
+  /**
+   * Operations after Scheduler publishes a term immediately not via cron.
+   *
+   * @param \Drupal\scheduler\Event\SchedulerEvent $event
+   *   The scheduler event.
+   */
+  public function apiTestTaxonomyTermPublishImmediately(SchedulerEvent $event) {
+    $this->apiTestPublishImmediately($event);
+  }
+
 }
diff --git a/web/modules/scheduler/tests/modules/scheduler_extras/scheduler_extras.info.yml b/web/modules/scheduler/tests/modules/scheduler_extras/scheduler_extras.info.yml
new file mode 100644
index 0000000000000000000000000000000000000000..17adef8c456e1295f762599149d841faa1d0e307
--- /dev/null
+++ b/web/modules/scheduler/tests/modules/scheduler_extras/scheduler_extras.info.yml
@@ -0,0 +1,11 @@
+name: 'Scheduler Extras'
+type: module
+description: 'Support module for general Scheduler testing.'
+package: Testing
+dependencies:
+  - scheduler:scheduler
+
+# Information added by Drupal.org packaging script on 2022-11-20
+version: '2.0.0-rc8'
+project: 'scheduler'
+datestamp: 1668951020
diff --git a/web/modules/scheduler/tests/modules/scheduler_extras/scheduler_extras.module b/web/modules/scheduler/tests/modules/scheduler_extras/scheduler_extras.module
new file mode 100644
index 0000000000000000000000000000000000000000..761f7833f478955527baa9d5eba26208dc010695
--- /dev/null
+++ b/web/modules/scheduler/tests/modules/scheduler_extras/scheduler_extras.module
@@ -0,0 +1,33 @@
+<?php
+
+/**
+ * @file
+ * Hook implementations for the Scheduler Extras test module.
+ *
+ * This module is used in SchedulerDefaultTimeTest to check that the default
+ * time is set correctly when the time element of the datetime input is hidden.
+ */
+
+use Drupal\Core\Form\FormStateInterface;
+
+/**
+ * Implements hook_form_alter().
+ */
+function scheduler_extras_form_alter(&$form, FormStateInterface $form_state, $form_id) {
+  // Only continue if the form is for adding the standard test entity types.
+  if (!in_array($form_id, [
+    'node_testpage_form',
+    'media_test_video_add_form',
+    'commerce_product_test_product_add_form',
+    'taxonomy_term_test_vocab_form',
+  ])) {
+    return;
+  }
+  // Hide the time element when the scheduler field exists.
+  if (isset($form['publish_on'])) {
+    $form['publish_on']['widget'][0]['value']['#date_time_element'] = 'none';
+  }
+  if (isset($form['unpublish_on'])) {
+    $form['unpublish_on']['widget'][0]['value']['#date_time_element'] = 'none';
+  }
+}
diff --git a/web/modules/scheduler/tests/src/Functional/SchedulerAdminSettingsTest.php b/web/modules/scheduler/tests/src/Functional/SchedulerAdminSettingsTest.php
index c117a8e6f7a366a858f79b8984e0e41fb66dd62e..82d769ee1d51afc15d24be01a4bcb2e097f35c99 100644
--- a/web/modules/scheduler/tests/src/Functional/SchedulerAdminSettingsTest.php
+++ b/web/modules/scheduler/tests/src/Functional/SchedulerAdminSettingsTest.php
@@ -5,6 +5,10 @@
 /**
  * Tests the admin settings page of Scheduler.
  *
+ * These tests only check that the admin page functions correctly. Other test
+ * classes should be used to verify that the settings have the expected effects
+ * on the scheduling operations and functionality.
+ *
  * @group scheduler
  */
 class SchedulerAdminSettingsTest extends SchedulerBrowserTestBase {
@@ -15,36 +19,79 @@ class SchedulerAdminSettingsTest extends SchedulerBrowserTestBase {
   public function testAdminSettings() {
     $this->drupalLogin($this->adminUser);
 
-    // Check that the correct default time is added to the scheduled date.
-    // For testing we use an offset of 6 hours 30 minutes (23400 seconds).
-    $this->seconds = 23400;
-    // If the test happens to be run at a time when '+1 day' puts the calculated
-    // publishing date into a different daylight-saving period then formatted
-    // time can be an hour different. To avoid these failures we use a fixed
-    // string when asserting the message and looking for field values.
-    // @see https://www.drupal.org/node/2809627
-    $this->seconds_formatted = '06:30:00';
-    // In $edit use '6:30' not '06:30:00' to test flexibility.
+    // Check that menu links exists for the node entity types, and that we are
+    // informed that no media types or taxonomy vocabularies exist.
+    $this->drupalGet('admin/config/content/scheduler');
+    $this->assertSession()->linkExists("{$this->typeName} (publishing, unpublishing)");
+    $this->assertSession()->linkExists("{$this->nonSchedulerTypeName}");
+    $this->assertSession()->pageTextContains('-- Media types -- (no entity types defined)');
+    $this->assertSession()->pageTextContains('-- Taxonomy -- (no entity types defined)');
+
+    // Call the setUp functions for all entity types.
+    $this->schedulerMediaSetUp();
+    $this->SchedulerCommerceProductSetUp();
+    $this->SchedulerTaxonomyTermSetUp();
+
+    // Check that the drop-down information has been updated.
+    $this->drupalGet('admin/config/content/scheduler');
+    $this->assertSession()->pageTextNotContains('-- Media types -- (no entity types defined)');
+    $this->assertSession()->linkExists("{$this->mediaTypeLabel} (publishing, unpublishing)");
+    $this->assertSession()->linkExists("{$this->nonSchedulerMediaTypeLabel}");
+    $this->assertSession()->pageTextNotContains('-- Taxonomy -- (no entity types defined)');
+    $this->assertSession()->pageTextContains("{$this->vocabularyName} (publishing, unpublishing)");
+    $this->assertSession()->linkExists("{$this->nonSchedulerVocabularyName}");
+
+    // Verify that the default values are as expected.
+    $this->assertFalse($this->config('scheduler.settings')->get('allow_date_only'), 'The default setting for allow_date_only is False.');
+    $this->assertEquals('00:00:00', $this->config('scheduler.settings')->get('default_time'), 'The default config setting for default_time is 00:00:00');
+    $this->assertFalse($this->config('scheduler.settings')->get('hide_seconds'), 'The default setting for hide_seconds is False.');
+
+    // Check that a default time can be stored, and that the option is saved.
+    // In $settings use '6:30' not '06:30:00' to test flexibility.
     $settings = [
       'allow_date_only' => TRUE,
       'default_time' => '6:30',
     ];
-    $this->drupalPostForm('admin/config/content/scheduler', $settings, 'Save configuration');
+    $this->drupalGet('admin/config/content/scheduler');
+    $this->submitForm($settings, 'Save configuration');
 
     // Verify that the values have been saved correctly.
     $this->assertTrue($this->config('scheduler.settings')->get('allow_date_only'), 'The config setting for allow_date_only is stored correctly.');
-    $this->assertEquals($this->seconds_formatted, $this->config('scheduler.settings')->get('default_time'), 'The config setting for default_time is stored correctly.');
+    $this->assertEquals('06:30:00', $this->config('scheduler.settings')->get('default_time'), 'The config setting for default_time is stored correctly.');
 
-    // Try to save an invalid time value.
+    // Try to save an invalid default time value.
     $settings = [
       'allow_date_only' => TRUE,
       'default_time' => '123',
     ];
-    $this->drupalPostForm('admin/config/content/scheduler', $settings, 'Save configuration');
+    $this->drupalGet('admin/config/content/scheduler');
+    $this->submitForm($settings, 'Save configuration');
     // Verify that the value has not been saved and an error is displayed.
-    $this->assertEquals($this->seconds_formatted, $this->config('scheduler.settings')->get('default_time'), 'The config setting for default_time has not changed.');
+    $this->assertEquals('06:30:00', $this->config('scheduler.settings')->get('default_time'), 'The config setting for default_time has not changed.');
     $this->assertSession()->pageTextContains('The default time should be in the format HH:MM:SS');
 
+    // Select the option to hide seconds on time input.
+    $settings = [
+      'hide_seconds' => TRUE,
+    ];
+    $this->drupalGet('admin/config/content/scheduler');
+    $this->submitForm($settings, 'Save configuration');
+    // Verify that the hide seconds option is saved and the default time is
+    // stored in HH:MM format with no seconds.
+    $this->assertTrue($this->config('scheduler.settings')->get('hide_seconds'), 'The config setting for hide_seconds is stored correctly.');
+    $this->assertEquals('06:30', $this->config('scheduler.settings')->get('default_time'), 'The config setting for default_time is stored correctly.');
+
+    // Try to save an invalid default time value.
+    $settings = [
+      'default_time' => '456',
+    ];
+    $this->drupalGet('admin/config/content/scheduler');
+    $this->submitForm($settings, 'Save configuration');
+    // Verify that the value has not been saved, and that an error message is
+    // displayed showing the correct format HH:MM not HH:MM:SS.
+    $this->assertEquals('06:30', $this->config('scheduler.settings')->get('default_time'), 'The config setting for default_time has not changed.');
+    $this->assertSession()->pageTextMatches('/The default time should be in the format HH:MM[^:S]/');
+
     // Show the status report, which includes the Scheduler timecheck.
     $this->drupalGet('admin/reports/status');
   }
diff --git a/web/modules/scheduler/tests/src/Functional/SchedulerBasicMediaTest.php b/web/modules/scheduler/tests/src/Functional/SchedulerBasicMediaTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..5ceb50b7e3d3722af8230b3a8141df42f29edd05
--- /dev/null
+++ b/web/modules/scheduler/tests/src/Functional/SchedulerBasicMediaTest.php
@@ -0,0 +1,95 @@
+<?php
+
+namespace Drupal\Tests\scheduler\Functional;
+
+/**
+ * Tests the modules primary functions with a Media entity type.
+ *
+ * @group scheduler
+ */
+class SchedulerBasicMediaTest extends SchedulerBrowserTestBase {
+
+  /**
+   * Tests scheduled publishing of a media entity.
+   *
+   * Covers scheduler_entity_presave(), scheduler_cron(),
+   * schedulerManager::publish.
+   */
+  public function testMediaPublishing() {
+    // Specify values for the entity.
+    $values = [
+      'name' => 'Publish This Media',
+      'publish_on' => $this->requestTime + 3600,
+    ];
+    // Create a media entity with the scheduler fields populated as required.
+    $entity = $this->createMediaItem($values);
+    $this->assertNotEmpty($entity, 'The entity was created sucessfully.');
+
+    // Assert that the entity has a publish_on date.
+    $this->assertNotEmpty($entity->publish_on, 'The entity has a publish_on date');
+
+    // Assert that the entity is not published before cron.
+    $this->assertFalse($entity->isPublished(), 'The entity is unpublished before cron run');
+
+    // Modify the scheduler field to a time in the past, then run cron.
+    $entity->publish_on = $this->requestTime - 1;
+    $entity->save();
+    $this->cronRun();
+
+    // Refresh the cache, reload the entity and check the entity is published.
+    $this->mediaStorage->resetCache([$entity->id()]);
+    $entity = $this->mediaStorage->load($entity->id());
+    $this->assertTrue($entity->isPublished(), 'The entity is published after cron run');
+  }
+
+  /**
+   * Tests scheduled publishing of a media entity when action is missing.
+   */
+  public function testMissingActionMediaPublishing() {
+    $this->deleteAction('media_scheduler', 'publish');
+    $this->testMediaPublishing();
+  }
+
+  /**
+   * Tests scheduled unpublishing of a media entity.
+   *
+   * Covers scheduler_entity_presave(), scheduler_cron(),
+   * schedulerManager::unpublish.
+   */
+  public function testMediaUnpublishing() {
+    // Specify values for the entity.
+    $values = [
+      'name' => 'Unpublish This Media',
+      'unpublish_on' => $this->requestTime + 3600,
+    ];
+    // Create a media entity with the scheduler fields populated as required.
+    $entity = $this->createMediaItem($values);
+    $this->assertNotEmpty($entity, 'The entity was created sucessfully.');
+
+    // Assert that the entity has an unpublish_on date.
+    $this->assertNotEmpty($entity->unpublish_on, 'The entity has an unpublish_on date');
+
+    // Assert that the entity is published before cron.
+    $this->assertTrue($entity->isPublished(), 'The entity is published before cron run');
+
+    // Modify the scheduler field to a time in the past, then run cron.
+    $entity->unpublish_on = $this->requestTime - 1;
+    $entity->save();
+    $this->cronRun();
+
+    // Refresh the cache, reload the entity and check the entity is unpublished.
+    $this->mediaStorage->resetCache([$entity->id()]);
+    $entity = $this->mediaStorage->load($entity->id());
+    $this->assertFalse($entity->isPublished(), 'The entity is unpublished after cron run');
+
+  }
+
+  /**
+   * Tests scheduled unpublishing of a media entity when action is missing.
+   */
+  public function testMissingActionMediaUnpublishing() {
+    $this->deleteAction('media_scheduler', 'unpublish');
+    $this->testMediaUnpublishing();
+  }
+
+}
diff --git a/web/modules/scheduler/tests/src/Functional/SchedulerBasicTest.php b/web/modules/scheduler/tests/src/Functional/SchedulerBasicNodeTest.php
similarity index 81%
rename from web/modules/scheduler/tests/src/Functional/SchedulerBasicTest.php
rename to web/modules/scheduler/tests/src/Functional/SchedulerBasicNodeTest.php
index 998a1be2573829b206378ac9ed54037da57dc6f2..3c4bd0f78b7f38e8dfb8ba3ffab794ce9dead706 100644
--- a/web/modules/scheduler/tests/src/Functional/SchedulerBasicTest.php
+++ b/web/modules/scheduler/tests/src/Functional/SchedulerBasicNodeTest.php
@@ -7,12 +7,12 @@
  *
  * @group scheduler
  */
-class SchedulerBasicTest extends SchedulerBrowserTestBase {
+class SchedulerBasicNodeTest extends SchedulerBrowserTestBase {
 
   /**
    * Tests basic scheduling of content.
    */
-  public function testPublishingAndUnpublishing() {
+  public function testNodePublishingAndUnpublishing() {
     // Login is required here before creating the publish_on date and time
     // values so that $this->dateFormatter can utilise the current users
     // timezone. The constraints receive values which have been converted using
@@ -37,18 +37,28 @@ public function testPublishingAndUnpublishing() {
     $this->helpTestScheduler($edit);
   }
 
+  /**
+   * Tests scheduled publishing/unpublishing of a node when actions are missing.
+   */
+  public function testMissingActionNodePublishingAndUnpublishing() {
+    $this->deleteAction('node_scheduler', 'publish');
+    $this->deleteAction('node_scheduler', 'unpublish');
+    $this->testNodePublishingAndUnpublishing();
+  }
+
   /**
    * Helper function for testPublishingAndUnpublishing().
    *
    * Schedules content, runs cron and asserts status.
    */
   protected function helpTestScheduler($edit) {
-    $this->drupalPostForm('node/add/' . $this->type, $edit, 'Save');
+    $this->drupalGet("node/add/{$this->type}");
+    $this->submitForm($edit, 'Save');
     // Verify that the node was created.
     $node = $this->drupalGetNodeByTitle($edit['title[0][value]']);
     $this->assertNotEmpty($node, sprintf('"%s" was created sucessfully.', $edit['title[0][value]']));
     if (empty($node)) {
-      $this->assert(FALSE, 'Test halted because node was not created.');
+      $this->assertTrue(FALSE, 'Test halted because node was not created.');
       return;
     }
 
diff --git a/web/modules/scheduler/tests/src/Functional/SchedulerBasicProductTest.php b/web/modules/scheduler/tests/src/Functional/SchedulerBasicProductTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..a147dc82b31303f3886118d7dd011fa168655af4
--- /dev/null
+++ b/web/modules/scheduler/tests/src/Functional/SchedulerBasicProductTest.php
@@ -0,0 +1,95 @@
+<?php
+
+namespace Drupal\Tests\scheduler\Functional;
+
+/**
+ * Tests the modules primary functions with a Commerce Product entity type.
+ *
+ * @group scheduler
+ */
+class SchedulerBasicProductTest extends SchedulerBrowserTestBase {
+
+  /**
+   * Tests scheduled publishing of a commerce product entity.
+   *
+   * Covers scheduler_entity_presave(), scheduler_cron(),
+   * schedulerManager::publish.
+   */
+  public function testProductPublishing() {
+    // Specify values for the entity.
+    $values = [
+      'title' => 'Publish This Product',
+      'publish_on' => $this->requestTime + 3600,
+    ];
+    // Create a product entity with the scheduler fields populated as required.
+    $entity = $this->createProduct($values);
+    $this->assertNotEmpty($entity, 'The entity was created sucessfully.');
+
+    // Assert that the entity has a publish_on date.
+    $this->assertNotEmpty($entity->publish_on, 'The entity has a publish_on date');
+
+    // Assert that the entity is not published before cron.
+    $this->assertFalse($entity->isPublished(), 'The entity is unpublished before cron run');
+
+    // Modify the scheduler field to a time in the past, then run cron.
+    $entity->publish_on = $this->requestTime - 1;
+    $entity->save();
+    $this->cronRun();
+
+    // Refresh the cache, reload the entity and check the entity is published.
+    $this->productStorage->resetCache([$entity->id()]);
+    $entity = $this->productStorage->load($entity->id());
+    $this->assertTrue($entity->isPublished(), 'The entity is published after cron run');
+  }
+
+  /**
+   * Tests scheduled publishing of a product when action is missing.
+   */
+  public function testMissingActionProductPublishing() {
+    $this->deleteAction('commerce_product_scheduler', 'publish');
+    $this->testProductPublishing();
+  }
+
+  /**
+   * Tests scheduled unpublishing of a commerce product entity.
+   *
+   * Covers scheduler_entity_presave(), scheduler_cron(),
+   * schedulerManager::unpublish.
+   */
+  public function testProductUnpublishing() {
+    // Specify values for the entity.
+    $values = [
+      'title' => 'Unpublish This Product',
+      'unpublish_on' => $this->requestTime + 3600,
+    ];
+    // Create a product with the scheduler fields populated as required.
+    $entity = $this->createProduct($values);
+    $this->assertNotEmpty($entity, 'The entity was created sucessfully.');
+
+    // Assert that the entity has an unpublish_on date.
+    $this->assertNotEmpty($entity->unpublish_on, 'The entity has an unpublish_on date');
+
+    // Assert that the entity is published before cron.
+    $this->assertTrue($entity->isPublished(), 'The entity is published before cron run');
+
+    // Modify the scheduler field to a time in the past, then run cron.
+    $entity->unpublish_on = $this->requestTime - 1;
+    $entity->save();
+    $this->cronRun();
+
+    // Refresh the cache, reload the entity and check the entity is unpublished.
+    $this->productStorage->resetCache([$entity->id()]);
+    $entity = $this->productStorage->load($entity->id());
+    $this->assertFalse($entity->isPublished(), 'The entity is unpublished after cron run');
+
+  }
+
+  /**
+   * Tests scheduled unpublishing of a product when action is missing.
+   */
+  public function testMissingActionProductUnpublishing() {
+    $this->deleteAction('commerce_product_scheduler', 'unpublish');
+    $this->testProductUnpublishing();
+  }
+
+}
diff --git a/web/modules/scheduler/tests/src/Functional/SchedulerBasicTaxonomyTermTest.php b/web/modules/scheduler/tests/src/Functional/SchedulerBasicTaxonomyTermTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..0d660e99b81b534fd068da38c7d850b186b3e898
--- /dev/null
+++ b/web/modules/scheduler/tests/src/Functional/SchedulerBasicTaxonomyTermTest.php
@@ -0,0 +1,95 @@
+<?php
+
+namespace Drupal\Tests\scheduler\Functional;
+
+/**
+ * Tests the modules primary functions with a Taxonomy Term entity type.
+ *
+ * @group scheduler
+ */
+class SchedulerBasicTaxonomyTermTest extends SchedulerBrowserTestBase {
+
+  /**
+   * Tests scheduled publishing of a taxonomy term entity.
+   *
+   * Covers scheduler_entity_presave(), scheduler_cron(),
+   * schedulerManager::publish.
+   */
+  public function testTaxonomyTermPublishing() {
+    // Specify values for the entity.
+    $values = [
+      'name' => 'Publish This Taxonomy Term',
+      'publish_on' => $this->requestTime + 3600,
+    ];
+    // Create a taxonomy term with the scheduler fields populated as required.
+    $entity = $this->createTaxonomyTerm($values);
+    $this->assertNotEmpty($entity, 'The entity was created sucessfully.');
+
+    // Assert that the entity has a publish_on date.
+    $this->assertNotEmpty($entity->publish_on, 'The entity has a publish_on date');
+
+    // Assert that the entity is not published before cron.
+    $this->assertFalse($entity->isPublished(), 'The entity is unpublished before cron run');
+
+    // Modify the scheduler field to a time in the past, then run cron.
+    $entity->publish_on = $this->requestTime - 1;
+    $entity->save();
+    $this->cronRun();
+
+    // Refresh the cache, reload the entity and check the entity is published.
+    $this->taxonomyTermStorage->resetCache([$entity->id()]);
+    $entity = $this->taxonomyTermStorage->load($entity->id());
+    $this->assertTrue($entity->isPublished(), 'The entity is published after cron run');
+  }
+
+  /**
+   * Tests scheduled publishing of a taxonomy term when action is missing.
+   */
+  public function testMissingActionTaxonomyTermPublishing() {
+    $this->deleteAction('taxonomy_term_scheduler', 'publish');
+    $this->testTaxonomyTermPublishing();
+  }
+
+  /**
+   * Tests scheduled unpublishing of a taxonomy term.
+   *
+   * Covers scheduler_entity_presave(), scheduler_cron(),
+   * schedulerManager::unpublish.
+   */
+  public function testTaxonomyTermUnpublishing() {
+    // Specify values for the entity.
+    $values = [
+      'name' => 'Unpublish This Taxonomy Term',
+      'unpublish_on' => $this->requestTime + 3600,
+    ];
+    // Create a taxonomy term with the scheduler fields populated as required.
+    $entity = $this->createTaxonomyTerm($values);
+    $this->assertNotEmpty($entity, 'The entity was created sucessfully.');
+
+    // Assert that the entity has an unpublish_on date.
+    $this->assertNotEmpty($entity->unpublish_on, 'The entity has an unpublish_on date');
+
+    // Assert that the entity is published before cron.
+    $this->assertTrue($entity->isPublished(), 'The entity is published before cron run');
+
+    // Modify the scheduler field to a time in the past, then run cron.
+    $entity->unpublish_on = $this->requestTime - 1;
+    $entity->save();
+    $this->cronRun();
+
+    // Refresh the cache, reload the entity and check the entity is unpublished.
+    $this->taxonomyTermStorage->resetCache([$entity->id()]);
+    $entity = $this->taxonomyTermStorage->load($entity->id());
+    $this->assertFalse($entity->isPublished(), 'The entity is unpublished after cron run');
+
+  }
+
+  /**
+   * Tests scheduled unpublishing of a taxonomy term when action is missing.
+   */
+  public function testMissingActionTaxonomyTermUnpublishing() {
+    $this->deleteAction('taxonomy_term_scheduler', 'unpublish');
+    $this->testTaxonomyTermUnpublishing();
+  }
+
+}
diff --git a/web/modules/scheduler/tests/src/Functional/SchedulerBrowserTestBase.php b/web/modules/scheduler/tests/src/Functional/SchedulerBrowserTestBase.php
index fc4cf2f417bea050f466a60608ddf9884b06e0c3..b0a4104b5fbe5aa8053e810ab66f1ebcb56a7726 100644
--- a/web/modules/scheduler/tests/src/Functional/SchedulerBrowserTestBase.php
+++ b/web/modules/scheduler/tests/src/Functional/SchedulerBrowserTestBase.php
@@ -3,14 +3,20 @@
 namespace Drupal\Tests\scheduler\Functional;
 
 use Drupal\Tests\BrowserTestBase;
+use Drupal\Tests\scheduler\Traits\SchedulerCommerceProductSetupTrait;
+use Drupal\Tests\scheduler\Traits\SchedulerMediaSetupTrait;
 use Drupal\Tests\scheduler\Traits\SchedulerSetupTrait;
+use Drupal\Tests\scheduler\Traits\SchedulerTaxonomyTermSetupTrait;
 
 /**
  * Base class to provide common browser test setup.
  */
 abstract class SchedulerBrowserTestBase extends BrowserTestBase {
 
+  use SchedulerCommerceProductSetupTrait;
+  use SchedulerMediaSetupTrait;
   use SchedulerSetupTrait;
+  use SchedulerTaxonomyTermSetupTrait;
 
   /**
    * The standard modules to load for all browser tests.
@@ -19,7 +25,13 @@ abstract class SchedulerBrowserTestBase extends BrowserTestBase {
    *
    * @var array
    */
-  protected static $modules = ['scheduler', 'dblog'];
+  protected static $modules = [
+    'scheduler',
+    'dblog',
+    'media',
+    'commerce_product',
+    'taxonomy',
+  ];
 
   /**
    * The profile to install as a basis for testing.
@@ -36,11 +48,24 @@ abstract class SchedulerBrowserTestBase extends BrowserTestBase {
   /**
    * {@inheritdoc}
    */
-  public function setUp() {
+  protected function setUp(): void {
     parent::setUp();
-
+    // Call the common set-up functions defined in the traits.
     $this->schedulerSetUp();
-
+    // $this->getName() includes the test class and the dataProvider key. We can
+    // use this to save time and resources by avoiding calls to the media and
+    // product setup functions when they are not needed. The exception is the
+    // permissions tests, which use all entities for all tests.
+    $testName = $this->getName();
+    if (stristr($testName, 'media') || stristr($testName, 'permission')) {
+      $this->schedulerMediaSetUp();
+    }
+    if (stristr($this->getName(), 'product') || stristr($testName, 'permission')) {
+      $this->SchedulerCommerceProductSetUp();
+    }
+    if (stristr($this->getName(), 'taxonomy') || stristr($testName, 'permission')) {
+      $this->SchedulerTaxonomyTermSetup();
+    }
   }
 
 }
diff --git a/web/modules/scheduler/tests/src/Functional/SchedulerDefaultTimeTest.php b/web/modules/scheduler/tests/src/Functional/SchedulerDefaultTimeTest.php
index ca13d0f7e1ebb706455e853e05540b932d9f8957..91506f6794a902f7ef049652cc65ad23413e8586 100644
--- a/web/modules/scheduler/tests/src/Functional/SchedulerDefaultTimeTest.php
+++ b/web/modules/scheduler/tests/src/Functional/SchedulerDefaultTimeTest.php
@@ -2,39 +2,73 @@
 
 namespace Drupal\Tests\scheduler\Functional;
 
-use DateTime;
-use DateInterval;
-
 /**
  * Tests the default time functionality.
  *
+ * The test helper module scheduler_extras is used in testDefaultWithHiddenTime.
+ * To reduce complexity, and avoid having to create custom entity types, it acts
+ * on the standard test entity types. Hence the module is only enabled in that
+ * test, not via a protected static $modules declaration.
+ *
  * @group scheduler
  */
 class SchedulerDefaultTimeTest extends SchedulerBrowserTestBase {
 
   /**
-   * Test the default time functionality during content creation and edit.
+   * The default time.
    *
-   * This test covers the default scenario where the dates are optional and not
-   * required. A javascript test covers the cases where the dates are required.
+   * @var string
    */
-  public function testDefaultTime() {
-    $this->drupalLogin($this->schedulerUser);
-    $config = $this->config('scheduler.settings');
+  protected $defaultTime;
 
-    // For this test we use a default time of 6:30am.
-    $default_time = '06:30:00';
-    $config->set('default_time', $default_time)->save();
+  /**
+   * The Publish On datetime derived using the default time.
+   *
+   * @var \DateTime
+   */
+  protected $publishTime;
+
+  /**
+   * The Unpublish On datetime derived using the default time.
+   *
+   * @var \DateTime
+   */
+  protected $unpublishTime;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+
+    // For this test we use a default time of 6:30:15am.
+    $this->defaultTime = '06:30:15';
+    $config = $this->config('scheduler.settings');
+    $config->set('default_time', $this->defaultTime)->save();
 
     // Create DateTime objects to hold the two scheduling dates. This is better
     // than using raw unix timestamps because it caters for daylight-saving
     // shifts properly.
     // @see https://www.drupal.org/project/scheduler/issues/2957490
-    $publish_time = new DateTime();
-    $publish_time->add(new DateInterval('P1D'))->setTime(6, 30);
+    $this->publishTime = new \DateTime();
+    $this->publishTime->add(new \DateInterval('P1D'))->setTime(6, 30, 15);
+
+    $this->unpublishTime = new \DateTime();
+    $this->unpublishTime->add(new \DateInterval('P2D'))->setTime(6, 30, 15);
+  }
 
-    $unpublish_time = new DateTime();
-    $unpublish_time->add(new DateInterval('P2D'))->setTime(6, 30);
+  /**
+   * Test the default time functionality during content creation and edit.
+   *
+   * This test covers the default scenario where the dates are optional and not
+   * required. A javascript test covers the cases where the dates are required.
+   *
+   * @dataProvider dataStandardEntityTypes()
+   */
+  public function testDefaultTime($entityTypeId, $bundle) {
+    $this->drupalLogin($this->schedulerUser);
+    $config = $this->config('scheduler.settings');
+    $titleField = $this->titleField($entityTypeId);
 
     // We cannot easily test the full validation message as they contain the
     // current time which can be one or two seconds in the past. The best we can
@@ -47,23 +81,28 @@ public function testDefaultTime() {
     $config->set('allow_date_only', FALSE)->save();
 
     // Test that entering a time is required.
+    $title = 'No time ' . $this->randomMachineName(8);
     $edit = [
-      'title[0][value]' => 'No time ' . $this->randomString(15),
-      'publish_on[0][value][date]' => $publish_time->format('Y-m-d'),
-      'unpublish_on[0][value][date]' => $unpublish_time->format('Y-m-d'),
+      "{$titleField}[0][value]" => $title,
+      'publish_on[0][value][date]' => $this->publishTime->format('Y-m-d'),
+      'unpublish_on[0][value][date]' => $this->unpublishTime->format('Y-m-d'),
     ];
-    // Create a node and check that the expected error messages are shown.
-    $this->drupalPostForm('node/add/' . $this->type, $edit, 'Save');
+    // Create an entity and check that the expected error messages are shown.
+    $add_url = $this->entityAddUrl($entityTypeId, $bundle);
+    $this->drupalGet($add_url);
+    $this->submitForm($edit, 'Save');
     // By default it is required to enter a time when scheduling content for
     // publishing and for unpublishing.
+    $this->assertSession()->pageTextNotMatches('/' . $title . ' is scheduled to be published .* and unpublished .*/');
     $this->assertSession()->pageTextContains($publish_validation_message);
     $this->assertSession()->pageTextContains($unpublish_validation_message);
 
     // Allow the user to enter only a date with no time.
     $config->set('allow_date_only', TRUE)->save();
 
-    // Create a node and check that the validation messages are not shown.
-    $this->drupalPostForm('node/add/' . $this->type, $edit, 'Save');
+    // Create an entity and check that the validation messages are not shown.
+    $this->drupalGet($add_url);
+    $this->submitForm($edit, 'Save');
     $this->assertSession()->pageTextNotContains($publish_validation_message);
     $this->assertSession()->pageTextNotContains($unpublish_validation_message);
 
@@ -71,24 +110,73 @@ public function testDefaultTime() {
     $date_format_storage = $this->container->get('entity_type.manager')->getStorage('date_format');
     $long_pattern = $date_format_storage->load('long')->getPattern();
 
-    // Check that the scheduled information is shown after saving.
+    // Check that the scheduled information is shown after saving and that the
+    // time is correct.
     $this->assertSession()->pageTextContains(sprintf('%s is scheduled to be published %s and unpublished %s',
-      $edit['title[0][value]'], $publish_time->format($long_pattern), $unpublish_time->format($long_pattern)));
+      $title, $this->publishTime->format($long_pattern), $this->unpublishTime->format($long_pattern)));
 
-    // Protect this section in case the node was not created.
-    if ($node = $this->drupalGetNodeByTitle($edit['title[0][value]'])) {
-      // Check that the correct scheduled dates are stored in the node.
-      $this->assertEquals($publish_time->getTimestamp(), (int) $node->publish_on->value, 'The node publish_on value is stored correctly.');
-      $this->assertEquals($unpublish_time->getTimestamp(), (int) $node->unpublish_on->value, 'The node unpublish_on value is stored correctly.');
+    if ($entity = $this->getEntityByTitle($entityTypeId, $title)) {
+      // Check that the correct scheduled dates are stored in the entity.
+      $this->assertEquals($this->publishTime->getTimestamp(), (int) $entity->publish_on->value, 'The publish_on value is stored correctly.');
+      $this->assertEquals($this->unpublishTime->getTimestamp(), (int) $entity->unpublish_on->value, 'The unpublish_on value is stored correctly.');
 
       // Check that the default time has been added to the form on edit.
-      $this->drupalGet('node/' . $node->id() . '/edit');
-      $this->assertFieldByName('publish_on[0][value][time]', $default_time, 'The default time offset has been added to the date field when scheduling content for publication.');
-      $this->assertFieldByName('unpublish_on[0][value][time]', $default_time, 'The default time offset has been added to the date field when scheduling content for unpublication.');
+      $this->drupalGet($entity->toUrl('edit-form'));
+      $this->assertSession()->FieldValueEquals('publish_on[0][value][time]', $this->defaultTime);
+      $this->assertSession()->FieldValueEquals('unpublish_on[0][value][time]', $this->defaultTime);
+    }
+    else {
+      $this->fail('The expected entity was not found.');
+    }
+  }
+
+  /**
+   * Test that the default times are set if the form time elements are hidden.
+   *
+   * This test uses the 'scheduler_extras' helper module, which hides the time
+   * elements of both of the scheduler date input fields.
+   *
+   * @dataProvider dataStandardEntityTypes()
+   */
+  public function testDefaultWithHiddenTime($entityTypeId, $bundle) {
+    \Drupal::service('module_installer')->install(['scheduler_extras']);
+    $titleField = $this->titleField($entityTypeId);
+    $this->drupalLogin($this->schedulerUser);
 
+    // Allow the user to enter only a date with no time.
+    $this->config('scheduler.settings')->set('allow_date_only', TRUE)->save();
+
+    // Define date values but no time values.
+    $title = 'Hidden Time Elements ' . $this->randomMachineName(8);
+    $edit = [
+      "{$titleField}[0][value]" => $title,
+      'publish_on[0][value][date]' => $this->publishTime->format('Y-m-d'),
+      'unpublish_on[0][value][date]' => $this->unpublishTime->format('Y-m-d'),
+    ];
+
+    // Create an entity and check that the time fields are hidden.
+    $this->drupalGet($this->entityAddUrl($entityTypeId, $bundle));
+    $this->assertSession()->FieldExists('publish_on[0][value][date]');
+    $this->assertSession()->FieldExists('unpublish_on[0][value][date]');
+    $this->assertSession()->FieldNotExists('publish_on[0][value][time]');
+    $this->assertSession()->FieldNotExists('unpublish_on[0][value][time]');
+    $this->submitForm($edit, 'Save');
+
+    // Get the pattern of the 'long' default date format.
+    $date_format_storage = $this->container->get('entity_type.manager')->getStorage('date_format');
+    $long_pattern = $date_format_storage->load('long')->getPattern();
+
+    // Check that the message has the correct default time after saving.
+    $this->assertSession()->pageTextContains(sprintf('%s is scheduled to be published %s and unpublished %s',
+      $title, $this->publishTime->format($long_pattern), $this->unpublishTime->format($long_pattern)));
+
+    if ($entity = $this->getEntityByTitle($entityTypeId, $title)) {
+      // Check that the correct scheduled dates are stored in the node.
+      $this->assertEquals($this->publishTime->getTimestamp(), (int) $entity->publish_on->value, 'The publish_on value is stored correctly.');
+      $this->assertEquals($this->unpublishTime->getTimestamp(), (int) $entity->unpublish_on->value, 'The unpublish_on value is stored correctly.');
     }
     else {
-      $this->fail('The expected node was not found.');
+      $this->fail('The expected entity was not found.');
     }
   }
 
diff --git a/web/modules/scheduler/tests/src/Functional/SchedulerDeleteEntityTest.php b/web/modules/scheduler/tests/src/Functional/SchedulerDeleteEntityTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..f2ad0e208375f53b448fb5c1184a3ecf4fe043f9
--- /dev/null
+++ b/web/modules/scheduler/tests/src/Functional/SchedulerDeleteEntityTest.php
@@ -0,0 +1,100 @@
+<?php
+
+namespace Drupal\Tests\scheduler\Functional;
+
+/**
+ * Tests deletion of entities enabled for Scheduler.
+ *
+ * This checks how the deletion of an entity interacts with the Scheduler
+ * 'required' options and scheduled dates in the past.
+ *
+ * @group scheduler
+ */
+class SchedulerDeleteEntityTest extends SchedulerBrowserTestBase {
+
+  /**
+   * Tests the deletion of an entity when the scheduler dates are required.
+   *
+   * Check that it is possible to delete an entity that does not have a
+   * publishing date set, when scheduled publishing is required.
+   * Likewise for unpublishing.
+   *
+   * @see https://www.drupal.org/project/scheduler/issues/1614880
+   *
+   * @dataProvider dataStandardEntityTypes()
+   */
+  public function testDeleteEntityWhenSchedulingIsRequired($entityTypeId, $bundle) {
+    // Log in.
+    $this->drupalLogin($this->adminUser);
+
+    // Create a published and an unpublished entity, with no scheduled dates.
+    $published_entity = $this->createEntity($entityTypeId, $bundle, [
+      'status' => TRUE,
+    ]);
+    $unpublished_entity = $this->createEntity($entityTypeId, $bundle, [
+      'status' => FALSE,
+    ]);
+
+    // Make scheduled publishing and unpublishing required.
+    $bundle_field_name = $published_entity->getEntityType()->get('entity_keys')['bundle'];
+    $published_entity->$bundle_field_name->entity->setThirdPartySetting('scheduler', 'publish_required', TRUE)
+      ->setThirdPartySetting('scheduler', 'unpublish_required', TRUE)
+      ->save();
+    $entity_type_label = $published_entity->getEntityType()->getSingularLabel();
+
+    // Check that deleting the entity does not throw form validation errors.
+    $this->drupalGet($published_entity->toUrl('edit-form'));
+    $this->clickLink('Delete');
+    // The text 'error message' is used in a header h2 html tag which is
+    // normally made hidden from browsers but will be in the page source.
+    // It is also good when testing for the absense of something to also test
+    // for the presence of text, hence the second assertion for each check.
+    $this->assertSession()->pageTextNotContains('Error message');
+    $this->assertSession()->pageTextContains("Are you sure you want to delete the $entity_type_label {$published_entity->label()}");
+
+    // Do the same test for the unpublished entity.
+    $this->drupalGet($unpublished_entity->toUrl('edit-form'));
+    $this->clickLink('Delete');
+    $this->assertSession()->pageTextNotContains('Error message');
+    $this->assertSession()->pageTextContains("Are you sure you want to delete the $entity_type_label {$unpublished_entity->label()}");
+  }
+
+  /**
+   * Tests the deletion of scheduled entities.
+   *
+   * Check that entities can be deleted with no validation errors even if the
+   * dates are in the past.
+   *
+   * @see https://www.drupal.org/project/scheduler/issues/2627370
+   *
+   * @dataProvider dataStandardEntityTypes()
+   */
+  public function testDeleteEntityWithPastDates($entityTypeId, $bundle) {
+    // Log in.
+    $this->drupalLogin($this->adminUser);
+
+    // Create entities with publish_on and unpublish_on dates in the past.
+    $published_entity = $this->createEntity($entityTypeId, $bundle, [
+      'status' => TRUE,
+      'unpublish_on' => strtotime('- 2 day'),
+    ]);
+    $unpublished_entity = $this->createEntity($entityTypeId, $bundle, [
+      'status' => FALSE,
+      'publish_on' => strtotime('- 2 day'),
+    ]);
+    $entity_type_label = $published_entity->getEntityType()->getSingularLabel();
+
+    // Attempt to delete the published entity and check for no validation error.
+    $this->drupalGet($published_entity->toUrl('edit-form'));
+    $this->clickLink('Delete');
+    $this->assertSession()->pageTextNotContains('Error message');
+    $this->assertSession()->pageTextContains("Are you sure you want to delete the $entity_type_label {$published_entity->label()}");
+
+    // Attempt to delete the unpublished entity and check no validation error.
+    $this->drupalGet($unpublished_entity->toUrl('edit-form'));
+    $this->clickLink('Delete');
+    $this->assertSession()->pageTextNotContains('Error message');
+    $this->assertSession()->pageTextContains("Are you sure you want to delete the $entity_type_label {$unpublished_entity->label()}");
+  }
+
+}
diff --git a/web/modules/scheduler/tests/src/Functional/SchedulerDeleteNodeTest.php b/web/modules/scheduler/tests/src/Functional/SchedulerDeleteNodeTest.php
deleted file mode 100644
index bd7a16f1744a7f8fc5f8f997e5ff868a06bac5be..0000000000000000000000000000000000000000
--- a/web/modules/scheduler/tests/src/Functional/SchedulerDeleteNodeTest.php
+++ /dev/null
@@ -1,96 +0,0 @@
-<?php
-
-namespace Drupal\Tests\scheduler\Functional;
-
-/**
- * Tests deletion of nodes enabled for Scheduler.
- *
- * This checks how the deletion of a node interacts with the Scheduler
- * 'required' options and scheduled dates in the past.
- *
- * @group scheduler
- */
-class SchedulerDeleteNodeTest extends SchedulerBrowserTestBase {
-
-  /**
-   * Tests the deletion of a scheduled node.
-   *
-   * Check that it is possible to delete a node that does not have a publishing
-   * date set, when scheduled publishing is required. Likewise for unpublishing.
-   *
-   * @see https://drupal.org/node/1614880
-   */
-  public function testDeleteNodeWhenSchedulingIsRequired() {
-    // Log in.
-    $this->drupalLogin($this->adminUser);
-
-    // Create a published and an unpublished node, both without scheduled dates.
-    $published_node = $this->drupalCreateNode([
-      'type' => $this->type,
-      'status' => 1,
-    ]);
-    $unpublished_node = $this->drupalCreateNode([
-      'type' => $this->type,
-      'status' => 0,
-    ]);
-
-    // Make scheduled publishing and unpublishing required.
-    $this->nodetype->setThirdPartySetting('scheduler', 'publish_required', TRUE)
-      ->setThirdPartySetting('scheduler', 'unpublish_required', TRUE)
-      ->save();
-
-    // Check that deleting the nodes does not throw form validation errors.
-    $this->drupalGet('node/' . $published_node->id() . '/edit');
-    $this->clickLink('Delete');
-    // The text 'error message' is used in a header h2 html tag which is
-    // normally made hidden from browsers but will be in the page source.
-    // It is also good when testing for the absense of something to also test
-    // for the presence of text, hence the second assertion for each check.
-    $this->assertSession()->pageTextNotContains('Error message');
-    $this->assertSession()->pageTextContains('Are you sure you want to delete the content');
-
-    // Do the same test for the unpublished node.
-    $this->drupalGet('node/' . $unpublished_node->id() . '/edit');
-    $this->clickLink('Delete');
-    $this->assertSession()->pageTextNotContains('Error message');
-    $this->assertSession()->pageTextContains('Are you sure you want to delete the content');
-  }
-
-  /**
-   * Tests the deletion of a scheduled node.
-   *
-   * Check that nodes can be deleted with no validation errors if the dates are
-   * in the past.
-   *
-   * @see http://drupal.org/node/2627370
-   */
-  public function testDeleteNodeWithPastDates() {
-    // Log in.
-    $this->drupalLogin($this->adminUser);
-
-    // Create nodes with publish_on and unpublish_on dates in the past.
-    $published_node = $this->drupalCreateNode([
-      'type' => $this->type,
-      'status' => TRUE,
-      'unpublish_on' => strtotime('- 2 day'),
-    ]);
-    $unpublished_node = $this->drupalCreateNode([
-      'type' => $this->type,
-      'status' => FALSE,
-      'publish_on' => strtotime('- 2 day'),
-    ]);
-
-    // Attempt to delete the published node and check for no validation error.
-    $this->drupalGet('node/' . $published_node->id() . '/edit');
-    $this->clickLink('Delete');
-    $this->assertSession()->pageTextNotContains('Error message');
-    $this->assertSession()->pageTextContains('Are you sure you want to delete the content');
-
-    // Attempt to delete the unpublished node and check for no validation error.
-    $this->drupalGet('node/' . $unpublished_node->id() . '/edit');
-    $this->clickLink('Delete');
-    $this->assertSession()->pageTextNotContains('Error message');
-    $this->assertSession()->pageTextContains('Are you sure you want to delete the content');
-  }
-
-}
diff --git a/web/modules/scheduler/tests/src/Functional/SchedulerDevelGenerateTest.php b/web/modules/scheduler/tests/src/Functional/SchedulerDevelGenerateTest.php
index 0b95f76c9a67012c60ce173bac08281a50d68735..63d1cfa19dcb335911210aa4c9c8b14c938708f8 100644
--- a/web/modules/scheduler/tests/src/Functional/SchedulerDevelGenerateTest.php
+++ b/web/modules/scheduler/tests/src/Functional/SchedulerDevelGenerateTest.php
@@ -6,10 +6,6 @@
  * Tests the Scheduler interaction with Devel Generate module.
  *
  * @group scheduler
- * @group legacy
- * @todo Remove the 'legacy' tag when Devel no longer uses the deprecated
- * $published parameter for setPublished(), and does not use functions
- * drupal_set_message(), format_date() and db_query_range().
  */
 class SchedulerDevelGenerateTest extends SchedulerBrowserTestBase {
 
@@ -23,54 +19,56 @@ class SchedulerDevelGenerateTest extends SchedulerBrowserTestBase {
   /**
    * {@inheritdoc}
    */
-  public function setUp() {
+  protected function setUp(): void {
     parent::setUp();
 
-    // Create a user with devel permission. Only 'administer devel_generate' is
-    // actually required for these tests, but the others are useful.
-    // 'access content overview' is needed for /admin/content  (but it is empty)
-    // 'access content' is required to actually see the content list data.
-    // 'view scheduled content' is required for /admin/content/scheduled.
-    $this->develUser = $this->drupalCreateUser([
+    // Add Devel Generate permission to the admin user.
+    $this->addPermissionsToUser($this->adminUser, [
       'administer devel_generate',
-      'view scheduled content',
-      'access content',
-      'access content overview',
     ]);
+
   }
 
   /**
-   * Helper function to count scheduled nodes and assert the expected number.
+   * Helper function to count scheduled entities and assert the expected number.
    *
    * @param string $type
-   *   The machine-name for the content type to be checked.
-   * @param string $field
+   *   The machine-name for the entity type to be checked.
+   * @param string $bundle_field
+   *   The name of the field which contains the bundle.
+   * @param string $bundle
+   *   The machine-name for the bundle/content type to be checked.
+   * @param string $scheduler_field
    *   The field name to count, either 'publish_on' or 'unpublish_on'.
-   * @param int $num_nodes
-   *   The total number of nodes that should exist.
+   * @param int $num_total
+   *   The total number of entities that should exist.
    * @param int $num_scheduled
-   *   The number of those nodes which should be scheduled with a $field.
+   *   The number of entities which should have a value in $scheduler_field.
    * @param int $time_range
    *   Optional time range from the devel form. The generated scheduler dates
    *   should be in a range of +/- this value from the current time.
    */
-  protected function countScheduledNodes($type, $field, $num_nodes, $num_scheduled, $time_range = NULL) {
-    // Check that the expected number of nodes have been created.
-    $count = $this->nodeStorage->getQuery()
-      ->condition('type', $type)
+  protected function countScheduledEntities($type, $bundle_field, $bundle, $scheduler_field, $num_total, $num_scheduled, $time_range = NULL) {
+    $storage = $this->entityStorageObject($type);
+
+    // Check that the expected number of entities have been created.
+    $count = $storage->getQuery()
+      ->accessCheck(FALSE)
+      ->condition($bundle_field, $bundle)
       ->count()
       ->execute();
-    $this->assertEquals($num_nodes, $count, sprintf('The expected number of %s is %s, found %s', $type, $num_nodes, $count));
+    $this->assertEquals($num_total, $count, sprintf('The expected number of %s %s is %s, found %s', $bundle, $type, $num_total, $count));
 
-    // Check that the expected number of nodes have been scheduled.
-    $count = $this->nodeStorage->getQuery()
-      ->condition('type', $type)
-      ->exists($field)
+    // Check that the expected number of entities have been scheduled.
+    $count = $storage->getQuery()
+      ->accessCheck(FALSE)
+      ->condition($bundle_field, $bundle)
+      ->exists($scheduler_field)
       ->count()
       ->execute();
-    $this->assertEquals($num_scheduled, $count, sprintf('The expected number of scheduled %s is %s, found %s', $field, $num_scheduled, $count));
+    $this->assertEquals($num_scheduled, $count, sprintf('The expected number of %s %s with scheduled %s is %s, found %s', $bundle, $type, $scheduler_field, $num_total, $count));
 
-    if (isset($time_range)) {
+    if (isset($time_range) && $num_scheduled > 0) {
       // Define the minimum and maximum times that we expect the scheduled dates
       // to be within. REQUEST_TIME remains static for the duration of this test
       // but even though devel_generate also uses uses REQUEST_TIME this will
@@ -80,98 +78,118 @@ protected function countScheduledNodes($type, $field, $num_nodes, $num_scheduled
       $min = $this->requestTime - $time_range;
       $max = time() + $time_range;
 
-      $query = $this->nodeStorage->getAggregateQuery();
+      $query = $storage->getAggregateQuery();
       $result = $query
-        ->condition('type', $type)
-        ->aggregate($field, 'min')
-        ->aggregate($field, 'max')
+        ->accessCheck(FALSE)
+        ->condition($bundle_field, $bundle)
+        ->aggregate($scheduler_field, 'min')
+        ->aggregate($scheduler_field, 'max')
         ->execute();
-      $min_found = $result[0]["{$field}_min"];
-      $max_found = $result[0]["{$field}_max"];
+      $min_found = $result[0]["{$scheduler_field}_min"];
+      $max_found = $result[0]["{$scheduler_field}_max"];
 
-      // Assert that the found values are within the expcted range.
-      $this->assertGreaterThanOrEqual($min, $min_found, sprintf('The minimum value for %s is %s, smaller than the expected %s', $field, $this->dateFormatter->format($min_found, 'custom', 'j M, H:i:s'), $this->dateFormatter->format($min, 'custom', 'j M, H:i:s')));
-      $this->assertLessThanOrEqual($max, $max_found, sprintf('The maximum value for %s is %s which is larger than expected %s', $field, $this->dateFormatter->format($max_found, 'custom', 'j M, H:i:s'), $this->dateFormatter->format($max, 'custom', 'j M, H:i:s')));
+      // Assert that the found values are within the expected range.
+      $this->assertGreaterThanOrEqual($min, $min_found, sprintf('The minimum value found for %s is %s, earlier than the expected %s', $scheduler_field, $this->dateFormatter->format($min_found, 'custom', 'j M, H:i:s'), $this->dateFormatter->format($min, 'custom', 'j M, H:i:s')));
+      $this->assertLessThanOrEqual($max, $max_found, sprintf('The maximum value found for %s is %s, later than the expected %s', $scheduler_field, $this->dateFormatter->format($max_found, 'custom', 'j M, H:i:s'), $this->dateFormatter->format($max, 'custom', 'j M, H:i:s')));
     }
   }
 
   /**
-   * Test the functionality that Scheduler adds during content generation.
+   * Test the functionality that Scheduler adds during entity generation.
+   *
+   * @dataProvider dataDevelGenerate()
    */
-  public function testDevelGenerate() {
-    $this->drupalLogin($this->develUser);
+  public function testDevelGenerate($entityTypeId, $enabled) {
+    $this->drupalLogin($this->adminUser);
+    $entityType = $this->entityTypeObject($entityTypeId, $enabled ? NULL : 'non-enabled');
+    $bundle = $entityType->id();
+    $bundle_field = $this->container->get('entity_type.manager')
+      ->getDefinition($entityTypeId)->get('entity_keys')['bundle'];
+
+    // Use just the minimum settings that are required, to see what happens when
+    // everything else is left as default. The devel_generate form has a
+    // selection list of vocabularies when generating terms but has a table of
+    // checkboxes to chose which node and media types to generate.
+    if ($entityTypeId == 'taxonomy_term') {
+      $entity_selection = ['vids[]' => ["$bundle" => "$bundle"]];
+    }
+    else {
+      $entity_selection = ["{$entityTypeId}_types[$bundle]" => TRUE];
+    }
+    $this->drupalGet($this->adminUrl('generate', $entityTypeId, $bundle));
+    $this->submitForm($entity_selection, 'Generate');
 
-    // Use the minimum required settings to see what happens when everything
-    // else is left as default.
-    $generate_settings = [
-      "edit-node-types-$this->type" => TRUE,
-    ];
-    $this->drupalPostForm('admin/config/development/generate/content', $generate_settings, 'Generate');
-    // Display the full content list and the scheduled list. Calls to these
-    // pages are for information and debug only. They could be removed.
-    $this->drupalGet('admin/content');
-    $this->drupalGet('admin/content/scheduled');
+    // Display the full content list and the scheduled list for the entity type
+    // being generated. Calls to these pages are for information and debug only.
+    // The default number of entities to create varies across the different
+    // devel_generate plugins, therefore we do not count any on this first run.
+    $this->drupalGet($this->adminUrl('collection', $entityTypeId, $bundle));
+    $this->drupalGet($this->adminUrl('scheduled', $entityTypeId, $bundle));
 
     // Delete all content for this type and generate new content with only
     // publish-on dates. Use 100% as this is how we can count the expected
-    // number of scheduled nodes. The time range of 3600 is one hour.
-    // The number of nodes has to be lower than 50 until Devel issue with
+    // number of scheduled entities. The time range of 3600 is one hour.
+    // The number of entities has to be lower than 50 until the Devel issue with
     // undefined index 'users' is available and we switch to using 8.x-3.0
     // See https://www.drupal.org/project/devel/issues/3076613
-    $generate_settings = [
-      "edit-node-types-$this->type" => TRUE,
+    $generate_settings = $entity_selection + [
       'num' => 40,
       'kill' => TRUE,
       'time_range' => 3600,
       'scheduler_publishing' => 100,
       'scheduler_unpublishing' => 0,
     ];
-    $this->drupalPostForm('admin/config/development/generate/content', $generate_settings, 'Generate');
-    $this->drupalGet('admin/content');
-    $this->drupalGet('admin/content/scheduled');
+    $this->drupalGet($this->adminUrl('generate', $entityTypeId, $bundle));
+    $this->submitForm($generate_settings, 'Generate');
+    // Display the full content list and the scheduled content list.
+    $this->drupalGet($this->adminUrl('collection', $entityTypeId, $bundle));
+    $this->drupalGet($this->adminUrl('scheduled', $entityTypeId, $bundle));
 
-    // Check we have the expected number of nodes scheduled for publishing only
-    // and verify that that the dates are within the time range specified.
-    $this->countScheduledNodes($this->type, 'publish_on', 40, 40, $generate_settings['time_range']);
-    $this->countScheduledNodes($this->type, 'unpublish_on', 40, 0);
+    // Check we have the expected number of entities scheduled for publishing
+    // only, and verify that that the dates are within the time range specified.
+    $this->countScheduledEntities($entityTypeId, $bundle_field, $bundle, 'publish_on', 40, $enabled ? 40 : 0, $generate_settings['time_range']);
+    $this->countScheduledEntities($entityTypeId, $bundle_field, $bundle, 'unpublish_on', 40, 0);
 
     // Do similar for unpublish_on date. Delete all then generate new content
     // with only unpublish-on dates. Time range 86400 is one day.
-    $generate_settings = [
-      "edit-node-types-$this->type" => TRUE,
+    $generate_settings = $entity_selection + [
       'num' => 30,
       'kill' => TRUE,
       'time_range' => 86400,
       'scheduler_publishing' => 0,
       'scheduler_unpublishing' => 100,
     ];
-    $this->drupalPostForm('admin/config/development/generate/content', $generate_settings, 'Generate');
-    $this->drupalGet('admin/content');
-    $this->drupalGet('admin/content/scheduled');
+    $this->drupalGet($this->adminUrl('generate', $entityTypeId, $bundle));
+    $this->submitForm($generate_settings, 'Generate');
+    // Display the full content list and the scheduled content list.
+    $this->drupalGet($this->adminUrl('collection', $entityTypeId, $bundle));
+    $this->drupalGet($this->adminUrl('scheduled', $entityTypeId, $bundle));
 
-    // Check we have the expected number of nodes scheduled for unpublishing
+    // Check we have the expected number of entities scheduled for unpublishing
     // only, and verify that that the dates are within the time range specified.
-    $this->countScheduledNodes($this->type, 'publish_on', 30, 0);
-    $this->countScheduledNodes($this->type, 'unpublish_on', 30, 30, $generate_settings['time_range']);
-
-    // Generate new content using the type which is not enabled for Scheduler.
-    // The nodes should be created but no dates should be added even though the
-    // scheduler values are set to 100.
-    $non_scheduler_id = $this->nonSchedulerNodeType->id();
-    $generate_settings = [
-      "edit-node-types-$non_scheduler_id" => TRUE,
-      'num' => 20,
-      'kill' => TRUE,
-      'scheduler_publishing' => 100,
-      'scheduler_unpublishing' => 100,
-    ];
-    $this->drupalPostForm('admin/config/development/generate/content', $generate_settings, 'Generate');
-    $this->drupalGet('admin/content');
-    $this->drupalGet('admin/content/scheduled');
+    $this->countScheduledEntities($entityTypeId, $bundle_field, $bundle, 'publish_on', 30, 0);
+    $this->countScheduledEntities($entityTypeId, $bundle_field, $bundle, 'unpublish_on', 30, $enabled ? 30 : 0, $generate_settings['time_range']);
+
+  }
 
-    // Check we have the expected number of nodes but that none are scheduled.
-    $this->countScheduledNodes($non_scheduler_id, 'publish_on', 20, 0);
-    $this->countScheduledNodes($non_scheduler_id, 'unpublish_on', 20, 0);
+  /**
+   * Provides data for testDevelGenerate().
+   *
+   * @return array
+   *   Each array item has the values:
+   *     [entity type id, enable for Scheduler TRUE/FALSE].
+   */
+  public function dataDevelGenerate() {
+    $types = $this->dataStandardEntityTypes();
+    // Remove commerce_product, becuase Devel Generate does not cover products.
+    unset($types['#commerce_product']);
+    $data = [];
+    // For each entity type, add a row for enabled TRUE and enabled FALSE.
+    foreach ($types as $key => $values) {
+      $data["$key-1"] = [$values[0], TRUE];
+      $data["$key-2"] = [$values[0], FALSE];
+    }
+    return $data;
   }
 
 }
diff --git a/web/modules/scheduler/tests/src/Functional/SchedulerDrushTest.php b/web/modules/scheduler/tests/src/Functional/SchedulerDrushTest.php
index 664fcad3b0ddd5334b2089e79225fa972e0bcb6b..04576d3b5e2a3ea8a4502418c34c1ebd33057ee5 100644
--- a/web/modules/scheduler/tests/src/Functional/SchedulerDrushTest.php
+++ b/web/modules/scheduler/tests/src/Functional/SchedulerDrushTest.php
@@ -14,7 +14,7 @@ class SchedulerDrushTest extends SchedulerBrowserTestBase {
   use DrushTestTrait;
 
   /**
-   * Tests the Scheduler Drush messages.
+   * Tests the messages from Scheduler Drush cron.
    */
   public function testDrushCronMessages() {
     // Run the plain command using the full scheduler:cron command name, and
@@ -40,22 +40,22 @@ public function testDrushCronMessages() {
   }
 
   /**
-   * Tests scheduled publishing via Drush command.
+   * Tests scheduled publishing and unpublishing of entities via Drush.
+   *
+   * @dataProvider dataStandardEntityTypes()
    */
-  public function testDrushCronPublishing() {
-    // Create a node which is scheduled for publishing.
-    $title1 = $this->randomMachineName(20);
-    $this->drupalCreateNode([
+  public function testDrushCronPublishing($entityTypeId, $bundle) {
+    // Create an entity which is scheduled for publishing.
+    $title1 = $this->randomMachineName(20) . ' for publishing';
+    $entity = $this->createEntity($entityTypeId, $bundle, [
       'title' => $title1,
-      'type' => $this->type,
       'publish_on' => strtotime('-3 hours'),
     ]);
 
-    // Create a node which is scheduled for unpublishing.
-    $title2 = $this->randomMachineName(20);
-    $this->drupalCreateNode([
+    // Create an entity which is scheduled for unpublishing.
+    $title2 = $this->randomMachineName(20) . ' for unpublishing';
+    $entity = $this->createEntity($entityTypeId, $bundle, [
       'title' => $title2,
-      'type' => $this->type,
       'unpublish_on' => strtotime('-2 hours'),
     ]);
 
@@ -63,8 +63,22 @@ public function testDrushCronPublishing() {
     // and unpublishing messages are found.
     $this->drush('scheduler:cron');
     $messages = $this->getErrorOutput();
-    $this->assertStringContainsString(sprintf('%s: scheduled publishing of %s', $this->typeName, $title1), $messages, 'Scheduled publishing message not found', TRUE);
-    $this->assertStringContainsString(sprintf('%s: scheduled unpublishing of %s', $this->typeName, $title2), $messages, 'Scheduled unpublishing message not found', TRUE);
+    $bundle_field = $entity->getEntityType()->get('entity_keys')['bundle'];
+    $type_label = $entity->$bundle_field->entity->label();
+    $this->assertStringContainsString(sprintf('%s: scheduled publishing of %s', $type_label, $title1), $messages, 'Scheduled publishing message not found', TRUE);
+    $this->assertStringContainsString(sprintf('%s: scheduled unpublishing of %s', $type_label, $title2), $messages, 'Scheduled unpublishing message not found', TRUE);
+  }
+
+  /**
+   * Tests the Entity Update command.
+   */
+  public function testDrushEntityUpdate() {
+    // This test could be expanded to check the full functionality of the
+    // entityUpdate() function. But initially, just call the function to check
+    // that it runs, and produces the 'nothing to update' message.
+    $this->drush('scheduler:entity-update');
+    $messages = $this->getErrorOutput();
+    $this->assertStringContainsString('Scheduler entity update - nothing to update', $messages, 'Error! Entity update message not found', TRUE);
   }
 
 }
diff --git a/web/modules/scheduler/tests/src/Functional/SchedulerEntityAccessTest.php b/web/modules/scheduler/tests/src/Functional/SchedulerEntityAccessTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..44ee683897b23e2b633aa08ef0a4c85c4ead6dd1
--- /dev/null
+++ b/web/modules/scheduler/tests/src/Functional/SchedulerEntityAccessTest.php
@@ -0,0 +1,103 @@
+<?php
+
+namespace Drupal\Tests\scheduler\Functional;
+
+/**
+ * Tests that Scheduler cron has full access to the scheduled entities.
+ *
+ * This test uses a additional test module 'scheduler_access_test' which has a
+ * custom entity access definition to deny viewing of all entities by any user
+ * except user 1.
+ *
+ * The purpose of checking for '403' is only to demonstrate that the helper
+ * module is doing its thing, it is not testing any part of the Scheduler
+ * functionality. If we tested with an anonymous visitor then both the published
+ * and unpublished entities would give 403 but the unpublished entity would
+ * return this regardless of what the helper module was doing. Likewise if we
+ * run the test with a logged in user who does not have 'view own unpublished..'
+ * then the unpublished entity would give 403 regardless. However, if the user
+ * does have 'view own unpublished ..' then due to the design of checkAccess()
+ * within NodeAccessControlHandler this entirely takes precedence and overrides
+ * any prevention of access attempted via contrib hook_node_access_records() and
+ * hook_node_grants(). It is clearer in the dblog output if this test is run
+ * using a logged-in user rather than the anonymous user, and hence create a new
+ * user who does not have the permission 'view own unpublished {type}'.
+ *
+ * @group scheduler
+ */
+class SchedulerEntityAccessTest extends SchedulerBrowserTestBase {
+
+  /**
+   * Additional modules required.
+   *
+   * @var array
+   */
+  protected static $modules = ['scheduler_access_test'];
+
+  /**
+   * Tests Scheduler cron functionality when access to the entity is denied.
+   *
+   * @dataProvider dataEntityAccess()
+   */
+  public function testEntityAccess($entityTypeId, $bundle, $field, $status) {
+    $storage = $this->entityStorageObject($entityTypeId);
+    // scheduler_access_test_install() sets node_access_needs_rebuild(TRUE) and
+    // this works when testing the module interactively, but in a phpunit run
+    // the node access table is not rebuilt. Hence do that explicitly here.
+    node_access_rebuild();
+
+    // Login as a user who is only able to view the published entities.
+    $this->drupalLogin($this->drupalCreateUser());
+
+    // Create an entity with the necessary scheduler date.
+    $process = $status ? 'unpublishing' : 'publishing';
+    $settings = [
+      'status' => $status,
+      'title' => "$entityTypeId $bundle for $process",
+      $field => $this->requestTime + 1,
+    ];
+    $entity = $this->createEntity($entityTypeId, $bundle, $settings);
+    $this->drupalGet($entity->toUrl());
+    // Before running cron, viewing the entity should give "403 Not Authorized"
+    // regardless of whether it is published or unpublished.
+    $this->assertSession()->statusCodeEquals(403);
+
+    // Delay so that the date entered is now in the past, then run cron.
+    sleep(2);
+    $this->cronRun();
+
+    // Reload the entity.
+    $storage->resetCache([$entity->id()]);
+    $entity = $storage->load($entity->id());
+    // Check that the entity has been published or unpublished as required.
+    $this->assertTrue($entity->isPublished() === !$status, "Scheduled $process of $entityTypeId via cron.");
+    // Check that the entity is still not viewable.
+    $this->drupalGet($entity->toUrl());
+    // After cron, viewing the entity should still give "403 Not Authorized".
+    $this->assertSession()->statusCodeEquals(403);
+
+    // Log in as admin and check that the dblog cron message is shown.
+    $this->drupalLogin($this->adminUser);
+    $this->drupalGet('admin/reports/dblog');
+    $this->assertSession()->pageTextContains($this->entityTypeObject($entityTypeId)->label() . ": scheduled $process");
+  }
+
+  /**
+   * Provides data for testEntityAccess.
+   *
+   * @return array
+   *   Each row has values: [entity type id, bundle id, field name, status].
+   */
+  public function dataEntityAccess() {
+    // This test is only applicable to node entity types because the other
+    // entity types do not have a hook access grant system.
+    // @todo Investigate how scheduler_access_test module can be expanded to
+    // deny access to other entity types using a different method.
+    $data = [
+      '#node-1' => ['node', $this->type, 'publish_on', FALSE],
+      '#node-2' => ['node', $this->type, 'unpublish_on', TRUE],
+    ];
+    return $data;
+  }
+
+}
diff --git a/web/modules/scheduler/tests/src/Functional/SchedulerEventsTest.php b/web/modules/scheduler/tests/src/Functional/SchedulerEventsTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..4bf38bad00940e382c601a4e661e3235babc308b
--- /dev/null
+++ b/web/modules/scheduler/tests/src/Functional/SchedulerEventsTest.php
@@ -0,0 +1,161 @@
+<?php
+
+namespace Drupal\Tests\scheduler\Functional;
+
+/**
+ * Tests the six generic events that Scheduler dispatches.
+ *
+ * @group scheduler_api
+ */
+class SchedulerEventsTest extends SchedulerBrowserTestBase {
+
+  /**
+   * Additional modules required.
+   *
+   * @var array
+   *
+   * @todo 'menu_ui' is in the exported node.type definition, and 'path' is in
+   * the entity_form_display. Could these be removed from the config files and
+   * then not needed here?
+   */
+  protected static $modules = ['scheduler_api_test', 'menu_ui', 'path'];
+
+  /**
+   * Covers six events for nodes.
+   *
+   * The events allow other modules to react to the Scheduler process being run.
+   * The API test implementations of the event listeners alter the nodes
+   * 'promote' and 'sticky' settings and changes the title.
+   */
+  public function testNodeEvents() {
+    $this->drupalLogin($this->schedulerUser);
+
+    // Create a test node.
+    $settings = [
+      'publish_on' => strtotime('-1 day'),
+      'type' => $this->type,
+      'promote' => FALSE,
+      'sticky' => FALSE,
+      'title' => 'API TEST node action',
+    ];
+    $node = $this->drupalCreateNode($settings);
+
+    // Check that the 'sticky' and 'promote' fields are off for the new node.
+    $this->assertFalse($node->isSticky(), 'The unpublished node is not sticky.');
+    $this->assertFalse($node->isPromoted(), 'The unpublished node is not promoted.');
+
+    // Run cron and check that the events have been dispatched correctly, by
+    // verifying that the node is now sticky and has been promoted.
+    scheduler_cron();
+    $this->nodeStorage->resetCache([$node->id()]);
+    $node = $this->nodeStorage->load($node->id());
+    $this->assertTrue($node->isSticky(), 'API event "PRE_PUBLISH" has changed the node to sticky.');
+    $this->assertTrue($node->isPromoted(), 'API event "PUBLISH" has changed the node to promoted.');
+
+    // Now set a date for unpublishing the node. Ensure 'sticky' and 'promote'
+    // are set, so that the assertions are not affected by any failures above.
+    $node->set('unpublish_on', strtotime('-1 day'))
+      ->set('sticky', TRUE)->set('promote', TRUE)->save();
+
+    // Run cron and check that the events have been dispatched correctly, by
+    // verifying that the node is no longer sticky and not promoted.
+    scheduler_cron();
+    $this->nodeStorage->resetCache([$node->id()]);
+    $node = $this->nodeStorage->load($node->id());
+    $this->assertFalse($node->isSticky(), 'API event "PRE_UNPUBLISH" has changed the node to not sticky.');
+    $this->assertFalse($node->isPromoted(), 'API event "UNPUBLISH" has changed the node to not promoted.');
+
+    // Turn on immediate publication when a publish date is in the past.
+    $this->nodetype->setThirdPartySetting('scheduler', 'publish_past_date', 'publish')->save();
+
+    // Ensure 'sticky' and 'promote' are not set, so that the assertions are not
+    // affected by any failures above.
+    $node->set('sticky', FALSE)->set('promote', FALSE)->save();
+
+    // Edit the node and set a publish-on date in the past.
+    $edit = [
+      'publish_on[0][value][date]' => date('Y-m-d', strtotime('-2 day', $this->requestTime)),
+      'publish_on[0][value][time]' => date('H:i:s', strtotime('-2 day', $this->requestTime)),
+    ];
+    $this->drupalGet('node/' . $node->id() . '/edit');
+    $this->submitForm($edit, 'Save');
+    // Verify that the values have been altered as expected.
+    $this->nodeStorage->resetCache([$node->id()]);
+    $node = $this->nodeStorage->load($node->id());
+    $this->assertTrue($node->isSticky(), 'API event "PRE_PUBLISH_IMMEDIATELY" has changed the node to sticky.');
+    $this->assertTrue($node->isPromoted(), 'API event "PUBLISH_IMMEDIATELY" has changed the node to promoted.');
+    $this->assertEquals('Published immediately', $node->title->value, 'API action "PUBLISH_IMMEDIATELY" has changed the node title correctly.');
+  }
+
+  /**
+   * Tests six scheduler events for entity types other than node.
+   *
+   * @dataProvider dataSchedulerEvents()
+   */
+  public function testSchedulerEvents($entityTypeId, $bundle) {
+    $this->drupalLogin($this->schedulerUser);
+    $storage = $this->entityStorageObject($entityTypeId);
+    $title_prefix = "API TEST $entityTypeId";
+
+    // Create an entity of the required type, scheduled for publishing.
+    $entity = $this->createEntity($entityTypeId, $bundle, [
+      'title' => $title_prefix,
+      'publish_on' => strtotime('-1 day'),
+    ]);
+    // Run cron and check that the events have been dispatched correctly. The
+    // name is first changed by a PRE_PUBLISH event subscriber, then a second
+    // time by a PUBLISH event watcher. Checking the final value tests both.
+    scheduler_cron();
+    $storage->resetCache([$entity->id()]);
+    $entity = $storage->load($entity->id());
+    $this->assertEquals($title_prefix . ' - altered a second time by PUBLISH event', $entity->label());
+
+    // Create an entity of the required type, scheduled for unpublishing.
+    $entity = $this->createEntity($entityTypeId, $bundle, [
+      'title' => $title_prefix,
+      'unpublish_on' => strtotime('-1 day'),
+    ]);
+    // Run cron and check that the events have been dispatched correctly.
+    scheduler_cron();
+    $storage->resetCache([$entity->id()]);
+    $entity = $storage->load($entity->id());
+    $this->assertEquals($title_prefix . ' - altered a second time by UNPUBLISH event', $entity->label());
+
+    // Turn on immediate publishing when a publish date is in the past.
+    $this->entityTypeObject($entityTypeId, $bundle)
+      ->setThirdPartySetting('scheduler', 'publish_past_date', 'publish')->save();
+
+    // Create an unpublished and unscheduled entity.
+    $entity = $this->createEntity($entityTypeId, $bundle, [
+      'title' => $title_prefix,
+      'status' => FALSE,
+    ]);
+    // Edit the media item, setting a publish-on date in the past.
+    $edit = [
+      'publish_on[0][value][date]' => date('Y-m-d', strtotime('-2 day', $this->requestTime)),
+      'publish_on[0][value][time]' => date('H:i:s', strtotime('-2 day', $this->requestTime)),
+    ];
+    $this->drupalGet($entity->toUrl('edit-form'));
+    $this->submitForm($edit, 'Save');
+    // Verify that the values have been altered as expected, without cron.
+    $storage->resetCache([$entity->id()]);
+    $entity = $storage->load($entity->id());
+    $this->assertEquals($title_prefix . ' - altered a second time by PUBLISH_IMMEDIATELY event', $entity->label());
+  }
+
+  /**
+   * Provides test data for scheduler events test.
+   *
+   * The original node events test is different (and no benefit in re-writing)
+   * so this test excludes the node entity type.
+   *
+   * @return array
+   *   Each array item has the values: [entity type id, bundle id].
+   */
+  public function dataSchedulerEvents() {
+    $data = $this->dataStandardEntityTypes();
+    unset($data['#node']);
+    return $data;
+  }
+
+}
diff --git a/web/modules/scheduler/tests/src/Functional/SchedulerFieldsDisplayTest.php b/web/modules/scheduler/tests/src/Functional/SchedulerFieldsDisplayTest.php
index f7583752f00713dc5f0231f01657818d9a37687f..b2835dc8cac705ef7212c860bf1c82c47bce0cfd 100644
--- a/web/modules/scheduler/tests/src/Functional/SchedulerFieldsDisplayTest.php
+++ b/web/modules/scheduler/tests/src/Functional/SchedulerFieldsDisplayTest.php
@@ -2,178 +2,303 @@
 
 namespace Drupal\Tests\scheduler\Functional;
 
+use Drupal\Core\Url;
+
 /**
- * Tests the display of the date entry fields (vertical tab, fieldset).
+ * Tests the display of date entry fields and form elements.
+ *
+ * @todo Extend these tests to cover form display processing when entity
+ * type is enabled/disabled.
+ * @see https://www.drupal.org/project/scheduler/issues/3320341
  *
  * @group scheduler
  */
 class SchedulerFieldsDisplayTest extends SchedulerBrowserTestBase {
 
   /**
-   * Additional module field_ui is required for the 'manage form display' test.
+   * Additional core module field_ui is required for testManageFormDisplay.
    *
    * @var array
    */
   protected static $modules = ['field_ui'];
 
   /**
-   * {@inheritdoc}
+   * Tests the Scheduler options display on entity type add and edit forms.
+   *
+   * This test covers hook_form_alter() and _scheduler_entity_type_form_alter().
+   *
+   * @dataProvider dataEntityTypeForm()
+   */
+  public function testEntityTypeForm($entityTypeId, $bundle, $operation) {
+    $this->drupalLogin($this->adminUser);
+
+    if ($operation == 'add first') {
+      // Delete all the entity types for this bundle, to check that 'add'
+      // works when it would be the first type being added.
+      $this->entityTypeObject($entityTypeId)->delete();
+      $this->entityTypeObject($entityTypeId, 'non-enabled')->delete();
+    }
+
+    $url = $this->adminUrl($operation == 'edit' ? 'bundle_edit' : 'bundle_add', $entityTypeId, $bundle);
+    $this->drupalGet($url);
+    $this->assertSession()->fieldExists('edit-scheduler-publish-enable');
+    $this->assertSession()->fieldExists('edit-scheduler-unpublish-enable');
+  }
+
+  /**
+   * Provides data for testEntityTypeForm.
+   *
+   * @return array
+   *   Each row has values: [entity type id, bundle id, operation].
+   */
+  public function dataEntityTypeForm() {
+    $types = $this->dataStandardEntityTypes();
+    $data = [];
+    foreach ($types as $key => $values) {
+      $data["$key-1"] = array_merge($values, ['add first']);
+      $data["$key-2"] = array_merge($values, ['add']);
+      $data["$key-3"] = array_merge($values, ['edit']);
+    }
+    return $data;
+  }
+
+  /**
+   * Tests the scheduler fields on the admin entity type form display tab.
+   *
+   * This test covers scheduler_entity_extra_field_info().
+   *
+   * @dataProvider dataStandardEntityTypes()
    */
-  public function setUp() {
-    parent::setUp();
-
-    // Create a custom user with admin permissions but also permission to use
-    // the field_ui module 'node form display' tab.
-    $this->adminUser2 = $this->drupalCreateUser([
-      'access content',
-      'administer content types',
-      'administer node form display',
-      'create ' . $this->type . ' content',
-      'schedule publishing of nodes',
-    ]);
+  public function testManageFormDisplay($entityTypeId, $bundle) {
+    // Give adminUser the permissions to use the field_ui 'manage form display'
+    // tab for the entity type being tested.
+    $this->addPermissionsToUser($this->adminUser, ["administer {$entityTypeId} form display"]);
+    $this->drupalLogin($this->adminUser);
+    $entityType = $this->entityTypeObject($entityTypeId, $bundle);
+
+    // Check that the weight input field is displayed when the entity bundle is
+    // enabled for scheduling. This field still exists even with tabledrag on.
+    $form_display_url = Url::fromRoute("entity.entity_form_display.{$entityTypeId}.default", [$entityType->getEntityTypeId() => $bundle]);
+    $this->drupalGet($form_display_url);
+    $this->assertSession()->fieldExists('edit-fields-scheduler-settings-weight');
+
+    // Check that the weight input field is not displayed when the entity bundle
+    // is not enabled for scheduling.
+    $this->entityTypeObject($entityTypeId, $bundle)
+      ->setThirdPartySetting('scheduler', 'publish_enable', FALSE)
+      ->setThirdPartySetting('scheduler', 'unpublish_enable', FALSE)->save();
+    $this->drupalGet($form_display_url);
+    $this->assertSession()->pageTextContains('Manage form display');
+    $this->assertSession()->FieldNotExists('edit-fields-scheduler-settings-weight');
   }
 
   /**
    * Tests date input is displayed as vertical tab or an expandable fieldset.
    *
-   * This test covers scheduler_form_node_form_alter().
+   * This test covers hook_form_alter() and _scheduler_entity_form_alter().
+   *
+   * @dataProvider dataStandardEntityTypes()
    */
-  public function testVerticalTabOrFieldset() {
+  public function testVerticalTabOrFieldset($entityTypeId, $bundle) {
     $this->drupalLogin($this->adminUser);
+    $entityType = $this->entityTypeObject($entityTypeId, $bundle);
 
     /** @var \Drupal\Tests\WebAssert $assert */
     $assert = $this->assertSession();
 
-    // Check that the dates are shown in a vertical tab by default.
-    $this->drupalGet('node/add/' . $this->type);
-    $assert->elementExists('xpath', '//div[contains(@class, "form-type-vertical-tabs")]//details[@id = "edit-scheduler-settings"]');
+    // For rendering of vertical tabs, node and media entity forms have a div
+    // with class 'js-form-type-vertical-tabs'. However, the Commerce Product
+    // module does things differently and does not have this class, but instead
+    // has a class 'layout-region-product-secondary' (for vertical tabs) and
+    // 'layout-region-product-main' if in the main form not in vertical tabs. So
+    // to cover all entity types we can check for either of these classes as an
+    // ancestor of the 'edit-scheduler-settings' section.
+    $vertical_tab_xpath = '//div[contains(@class, "form-type-vertical-tabs") or contains(@class, "-secondary")]//details[@id = "edit-scheduler-settings"]';
+
+    // The 'open' and 'closed' xpath searches do apply to vertical tabs, even if
+    // the theme does not actually make use of it (such as in Bartik and Stark).
+    $details_open_xpath = '//details[@id = "edit-scheduler-settings" and @open = "open"]';
+    $details_closed_xpath = '//details[@id = "edit-scheduler-settings" and not(@open = "open")]';
+
+    // Check that the dates are shown in a vertical tab by default. The taxonomy
+    // term form does not have a vertical tab section, so cannot check for this.
+    $add_url = $this->entityAddUrl($entityTypeId, $bundle);
+    $this->drupalGet($add_url);
+    if ($check_vertical_tab = ($entityTypeId != 'taxonomy_term')) {
+      $assert->elementExists('xpath', $vertical_tab_xpath);
+    }
+    $assert->elementExists('xpath', $details_closed_xpath);
 
     // Check that the dates are shown as a fieldset when configured to do so,
     // and that fieldset is collapsed by default.
-    $this->nodetype->setThirdPartySetting('scheduler', 'fields_display_mode', 'fieldset')->save();
-    $this->drupalGet('node/add/' . $this->type);
-    $assert->elementNotExists('xpath', '//div[contains(@class, "form-type-vertical-tabs")]//details[@id = "edit-scheduler-settings"]');
-    $assert->elementExists('xpath', '//details[@id = "edit-scheduler-settings" and not(@open = "open")]');
+    $entityType->setThirdPartySetting('scheduler', 'fields_display_mode', 'fieldset')->save();
+    $this->drupalGet($add_url);
+    $assert->elementNotExists('xpath', $vertical_tab_xpath);
+    $assert->elementExists('xpath', $details_closed_xpath);
 
     // Check that the fieldset is expanded if either of the scheduling dates
     // are required.
-    $this->nodetype->setThirdPartySetting('scheduler', 'publish_required', TRUE)->save();
-    $this->drupalGet('node/add/' . $this->type);
-    $assert->elementExists('xpath', '//details[@id = "edit-scheduler-settings" and @open = "open"]');
+    $entityType->setThirdPartySetting('scheduler', 'publish_required', TRUE)->save();
+    $this->drupalGet($add_url);
+    $assert->elementExists('xpath', $details_open_xpath);
 
-    $this->nodetype->setThirdPartySetting('scheduler', 'publish_required', FALSE)
+    $entityType->setThirdPartySetting('scheduler', 'publish_required', FALSE)
       ->setThirdPartySetting('scheduler', 'unpublish_required', TRUE)->save();
-    $this->drupalGet('node/add/' . $this->type);
-    $assert->elementExists('xpath', '//details[@id = "edit-scheduler-settings" and @open = "open"]');
+    $this->drupalGet($add_url);
+    $assert->elementExists('xpath', $details_open_xpath);
 
     // Check that the fieldset is expanded if the 'always' option is set.
-    $this->nodetype->setThirdPartySetting('scheduler', 'publish_required', FALSE)
+    $entityType->setThirdPartySetting('scheduler', 'publish_required', FALSE)
       ->setThirdPartySetting('scheduler', 'unpublish_required', FALSE)
       ->setThirdPartySetting('scheduler', 'expand_fieldset', 'always')->save();
-    $this->drupalGet('node/add/' . $this->type);
-    $assert->elementExists('xpath', '//details[@id = "edit-scheduler-settings" and @open = "open"]');
+    $this->drupalGet($add_url);
+    $assert->elementExists('xpath', $details_open_xpath);
 
-    // Check that the fieldset is expanded if the node already has a publish-on
-    // date. This requires editing an existing scheduled node.
-    $this->nodetype->setThirdPartySetting('scheduler', 'expand_fieldset', 'when_required')->save();
+    // Check that the fieldset is expanded if the entity already has a
+    // publish-on date. This requires editing an existing scheduled entity.
+    $entityType->setThirdPartySetting('scheduler', 'expand_fieldset', 'when_required')->save();
     $options = [
       'title' => 'Contains Publish-on date ' . $this->randomMachineName(10),
-      'type' => $this->type,
       'publish_on' => strtotime('+1 day'),
     ];
-    $node = $this->drupalCreateNode($options);
-    $this->drupalGet('node/' . $node->id() . '/edit');
-    $assert->elementExists('xpath', '//details[@id = "edit-scheduler-settings" and @open = "open"]');
+    $entity = $this->createEntity($entityTypeId, $bundle, $options);
+    $this->drupalGet($entity->toUrl('edit-form'));
+    $assert->elementExists('xpath', $details_open_xpath);
+
+    // Repeat the check with a timestamp value of zero. This is a valid date
+    // so the fieldset should be opened. It will not be used much on real sites
+    // but can occur when testing Rules which fail to set the date correctly and
+    // we get zero. Debugging Rules is easier if the fieldset opens as expected.
+    $options = [
+      'title' => 'Contains Publish-on date with timestamp value zero - ' . $this->randomMachineName(10),
+      'publish_on' => 0,
+    ];
+    $entity = $this->createEntity($entityTypeId, $bundle, $options);
+    $this->drupalGet($entity->toUrl('edit-form'));
+    $assert->elementExists('xpath', $details_open_xpath);
 
-    // Check that the fieldset is expanded if the node has an unpublish-on date.
+    // Check that the fieldset is expanded if there is an unpublish-on date.
     $options = [
       'title' => 'Contains Unpublish-on date ' . $this->randomMachineName(10),
-      'type' => $this->type,
       'unpublish_on' => strtotime('+1 day'),
     ];
-    $node = $this->drupalCreateNode($options);
-    $this->drupalGet('node/' . $node->id() . '/edit');
-    $assert->elementExists('xpath', '//details[@id = "edit-scheduler-settings" and @open = "open"]');
+    $entity = $this->createEntity($entityTypeId, $bundle, $options);
+    $this->drupalGet($entity->toUrl('edit-form'));
+    $assert->elementExists('xpath', $details_open_xpath);
+
+    // Repeat with a timestamp value of zero.
+    $options = [
+      'title' => 'Contains Unpublish-on date with timestamp value zero - ' . $this->randomMachineName(10),
+      'unpublish_on' => 0,
+    ];
+    $entity = $this->createEntity($entityTypeId, $bundle, $options);
+    $this->drupalGet($entity->toUrl('edit-form'));
+    $assert->elementExists('xpath', $details_open_xpath);
 
     // Check that the display reverts to a vertical tab again when specifically
     // configured to do so.
-    $this->nodetype->setThirdPartySetting('scheduler', 'fields_display_mode', 'vertical_tab')->save();
-    $this->drupalGet('node/add/' . $this->type);
-    $assert->elementExists('xpath', '//div[contains(@class, "form-type-vertical-tabs")]//details[@id = "edit-scheduler-settings"]');
+    $entityType->setThirdPartySetting('scheduler', 'fields_display_mode', 'vertical_tab')->save();
+    $this->drupalGet($entity->toUrl('edit-form'));
+    if ($check_vertical_tab) {
+      $assert->elementExists('xpath', $vertical_tab_xpath);
+    }
+    $assert->elementExists('xpath', $details_open_xpath);
   }
 
   /**
-   * Tests the settings entry in the content type form display.
+   * Tests the edit form when scheduler fields have been disabled.
    *
-   * This test covers scheduler_entity_extra_field_info().
+   * This test covers _scheduler_entity_form_alter().
+   *
+   * @dataProvider dataStandardEntityTypes()
    */
-  public function testManageFormDisplay() {
-    $this->drupalLogin($this->adminUser2);
+  public function testDisabledFields($entityTypeId, $bundle) {
+    $this->drupalLogin($this->schedulerUser);
 
-    // Check that the weight input field is displayed when the content type is
-    // enabled for scheduling. This field still exists even with tabledrag on.
-    $this->drupalGet('admin/structure/types/manage/' . $this->type . '/form-display');
-    $this->assertSession()->fieldExists('edit-fields-scheduler-settings-weight');
+    /** @var \Drupal\Tests\WebAssert $assert */
+    $assert = $this->assertSession();
 
-    // Check that the weight input field is not displayed when the content type
-    // is not enabled for scheduling.
-    $this->nodetype->setThirdPartySetting('scheduler', 'publish_enable', FALSE)
-      ->setThirdPartySetting('scheduler', 'unpublish_enable', FALSE)->save();
-    $this->drupalGet('admin/structure/types/manage/' . $this->type . '/form-display');
-    $this->assertNoFieldById('edit-fields-scheduler-settings-weight', NULL, 'The scheduler settings row is not shown when the content type is not enabled for scheduling.');
+    // 1. Set the publish_on field to 'hidden' in the entity edit form.
+    $formDisplay = $this->container->get('entity_display.repository')->getFormDisplay($entityTypeId, $bundle);
+    $publish_on_component = $formDisplay->getComponent('publish_on');
+    $formDisplay->removeComponent('publish_on')->save();
+
+    // Check that the scheduler details element is shown and that the
+    // unpublish_on field is shown, but the publish_on field is not shown.
+    $add_url = $this->entityAddUrl($entityTypeId, $bundle);
+    $this->drupalGet($add_url);
+    $assert->elementExists('xpath', '//details[@id = "edit-scheduler-settings"]');
+    $this->assertSession()->FieldNotExists('publish_on[0][value][date]');
+    $this->assertSession()->FieldExists('unpublish_on[0][value][date]');
+
+    // 2. Set publish_on to be displayed but hide the unpublish_on field.
+    $formDisplay->setComponent('publish_on', $publish_on_component)
+      ->removeComponent('unpublish_on')->save();
+
+    // Check that the scheduler details element is shown and that the
+    // publish_on field is shown, but the unpublish_on field is not shown.
+    $this->drupalGet($add_url);
+    $assert->elementExists('xpath', '//details[@id = "edit-scheduler-settings"]');
+    $this->assertSession()->FieldExists('publish_on[0][value][date]');
+    $this->assertSession()->FieldNotExists('unpublish_on[0][value][date]');
+
+    // 3. Set both fields to be hidden.
+    $formDisplay->removeComponent('publish_on')->save();
+
+    // Check that the scheduler details element is not shown when both of the
+    // date fields are set to be hidden.
+    $this->drupalGet($add_url);
+    $assert->elementNotExists('xpath', '//details[@id = "edit-scheduler-settings"]');
+    $this->assertSession()->FieldNotExists('publish_on[0][value][date]');
+    $this->assertSession()->FieldNotExists('unpublish_on[0][value][date]');
   }
 
   /**
-   * Tests the edit form when scheduler fields have been disabled.
+   * Test the option to hide the seconds on the time input fields.
    *
-   * This test covers scheduler_form_node_form_alter().
+   * @dataProvider dataStandardEntityTypes()
    */
-  public function testDisabledFields() {
-    $this->drupalLogin($this->adminUser2);
+  public function testHideSeconds($entityTypeId, $bundle) {
+    $this->drupalLogin($this->schedulerUser);
+    $config = $this->config('scheduler.settings');
+    $titleField = $this->titleField($entityTypeId);
 
-    /** @var \Drupal\Tests\WebAssert $assert */
-    $assert = $this->assertSession();
+    // Check that the default is to show the seconds on the input fields.
+    $add_url = $this->entityAddUrl($entityTypeId, $bundle);
+    $this->drupalGet($add_url);
+    $publish_time_field = $this->xpath('//input[@id="edit-publish-on-0-value-time"]');
+    $unpublish_time_field = $this->xpath('//input[@id="edit-unpublish-on-0-value-time"]');
+    $this->assertEquals(1, $publish_time_field[0]->getAttribute('step'), 'The input time step for publish-on is 1, so the seconds will be visible and usable.');
+    $this->assertEquals(1, $unpublish_time_field[0]->getAttribute('step'), 'The input time step for unpublish-on is 1, so the seconds will be visible and usable.');
 
-    // 1. Set the publish_on field to 'hidden' in the node edit form.
-    $edit = [
-      'fields[publish_on][region]' => 'hidden',
-    ];
-    $this->drupalPostForm('admin/structure/types/manage/' . $this->type . '/form-display', $edit, 'Save');
+    // Set the config option to hide the seconds and thus set the input fields
+    // to the granularity of one minute.
+    $config->set('hide_seconds', TRUE)->save();
 
-    // Check that a scheduler vertical tab is displayed.
-    $this->drupalGet('node/add/' . $this->type);
-    $assert->elementExists('xpath', '//div[contains(@class, "form-type-vertical-tabs")]//details[@id = "edit-scheduler-settings"]');
-    // Check the publish_on field is not shown, but the unpublish_on field is.
-    $this->assertNoFieldByName('publish_on[0][value][date]', NULL, 'The Publish-on field is not shown - 1');
-    $this->assertFieldByName('unpublish_on[0][value][date]', NULL, 'The Unpublish-on field is shown - 1');
+    // Go to the 'add' url and check the input fields.
+    $this->drupalGet($add_url);
+    $publish_time_field = $this->xpath('//input[@id="edit-publish-on-0-value-time"]');
+    $unpublish_time_field = $this->xpath('//input[@id="edit-unpublish-on-0-value-time"]');
+    $this->assertEquals(60, $publish_time_field[0]->getAttribute('step'), 'The input time step for publish-on is 60, so the seconds will be hidden and not usable.');
+    $this->assertEquals(60, $unpublish_time_field[0]->getAttribute('step'), 'The input time step for unpublish-on is 60, so the seconds will be hidden and not usable.');
+    // @todo How can we check that the seconds element is not shown?
 
-    // 2. Set publish_on to be displayed but hide the unpublish_on field.
+    // Save with both dates entered, including seconds in the times.
     $edit = [
-      'fields[publish_on][region]' => 'content',
-      'fields[unpublish_on][region]' => 'hidden',
+      "{$titleField}[0][value]" => 'Hide the seconds',
+      'publish_on[0][value][date]' => date('Y-m-d', strtotime('+1 day', $this->requestTime)),
+      'publish_on[0][value][time]' => '01:02:03',
+      'unpublish_on[0][value][date]' => date('Y-m-d', strtotime('+1 day', $this->requestTime)),
+      'unpublish_on[0][value][time]' => '04:05:06',
     ];
-    $this->drupalPostForm('admin/structure/types/manage/' . $this->type . '/form-display', $edit, 'Save');
+    $this->submitForm($edit, 'Save');
+    $entity = $this->getEntityByTitle($entityTypeId, 'Hide the seconds');
 
-    // Check that a scheduler vertical tab is displayed.
-    $this->drupalGet('node/add/' . $this->type);
-    $assert->elementExists('xpath', '//div[contains(@class, "form-type-vertical-tabs")]//details[@id = "edit-scheduler-settings"]');
-    // Check the publish_on field is not shown, but the unpublish_on field is.
-    $this->assertFieldByName('publish_on[0][value][date]', NULL, 'The Publish-on field is shown - 2');
-    $this->assertNoFieldByName('unpublish_on[0][value][date]', NULL, 'The Unpublish-on field is not shown - 2');
+    // Edit and check that the seconds have been set to zero.
+    $this->drupalGet($entity->toUrl('edit-form'));
+    $this->assertSession()->FieldValueEquals('publish_on[0][value][time]', '01:02:00');
+    $this->assertSession()->FieldValueEquals('unpublish_on[0][value][time]', '04:05:00');
 
-    // 3. Set both fields to be hidden.
-    $edit = [
-      'fields[publish_on][region]' => 'hidden',
-      'fields[unpublish_on][region]' => 'hidden',
-    ];
-    $this->drupalPostForm('admin/structure/types/manage/' . $this->type . '/form-display', $edit, 'Save');
-
-    // Check that no vertical tab is displayed.
-    $this->drupalGet('node/add/' . $this->type);
-    $assert->elementNotExists('xpath', '//div[contains(@class, "form-type-vertical-tabs")]//details[@id = "edit-scheduler-settings"]');
-    // Check the neither field is displayed.
-    $this->assertNoFieldByName('publish_on[0][value][date]', NULL, 'The Publish-on field is not shown - 3');
-    $this->assertNoFieldByName('unpublish_on[0][value][date]', NULL, 'The Unpublish-on field is not shown - 3');
   }
 
 }
diff --git a/web/modules/scheduler/tests/src/Functional/SchedulerApiTest.php b/web/modules/scheduler/tests/src/Functional/SchedulerHooksLegacyTest.php
similarity index 71%
rename from web/modules/scheduler/tests/src/Functional/SchedulerApiTest.php
rename to web/modules/scheduler/tests/src/Functional/SchedulerHooksLegacyTest.php
index 4a82751e32e708b5ff012ca6855183f34b149ce9..3178195ad123333409b9a5d350c95eef794604ce 100644
--- a/web/modules/scheduler/tests/src/Functional/SchedulerApiTest.php
+++ b/web/modules/scheduler/tests/src/Functional/SchedulerHooksLegacyTest.php
@@ -5,44 +5,161 @@
 use Drupal\node\Entity\NodeType;
 
 /**
- * Tests the API of the Scheduler module.
+ * Tests the legacy API hook functions of the Scheduler module.
  *
- * @group scheduler
+ * This class covers the eight original hook functions for node entity types
+ * only. These are maintained for backwards-compatibility.
+ *
+ * @group scheduler_api
  */
-class SchedulerApiTest extends SchedulerBrowserTestBase {
+class SchedulerHooksLegacyTest extends SchedulerBrowserTestBase {
 
   /**
    * Additional modules required.
    *
    * @var array
-   *
-   * @TODO 'menu_ui' is in the exported node.type definition, and 'path' is in
-   * the entity_form_display. Could these be removed from the config files and
-   * then not needed here?
    */
-  protected static $modules = ['scheduler_api_test', 'menu_ui', 'path'];
+  protected static $modules = [
+    'scheduler_api_test',
+    'scheduler_api_legacy_test',
+    'menu_ui',
+    'path',
+  ];
 
   /**
    * {@inheritdoc}
    */
-  public function setUp() {
+  protected function setUp(): void {
     parent::setUp();
 
     // Load the custom node type. It will be enabled for Scheduler automatically
     // as that is pre-configured in node.type.scheduler_api_test.yml.
-    $this->customName = 'scheduler_api_test';
+    $this->customName = 'scheduler_api_node_test';
     $this->customNodetype = NodeType::load($this->customName);
 
     // Check that the custom node type has loaded OK.
-    $this->assertNotNull($this->customNodetype, 'Custom node type "' . $this->customName . '"  was created during install');
+    $this->assertNotNull($this->customNodetype, 'Custom node type "' . $this->customName . '" was created during install');
 
-    // Create a web user for this content type.
+    // Create a web user that has permission to create and edit and schedule
+    // the custom entity type.
     $this->webUser = $this->drupalCreateUser([
       'create ' . $this->customName . ' content',
       'edit any ' . $this->customName . ' content',
       'schedule publishing of nodes',
     ]);
+    $this->webUser->set('name', 'Wenlock the Web user')->save();
+
+  }
+
+  /**
+   * Covers hook_scheduler_nid_list($action)
+   *
+   * Hook_scheduler_nid_list() allows other modules to add more node ids into
+   * the list to be processed. In real scenarios, the third-party module would
+   * likely have more complex data structures and/or tables from which to
+   * identify nodes to add. In this test, to keep it simple, we identify nodes
+   * by the text of the title.
+   */
+  public function testNidList() {
+    $this->drupalLogin($this->schedulerUser);
+
+    // Create test nodes. Use the ordinary page type for this test, as having
+    // the 'approved' fields here would unnecessarily complicate the processing.
+    // Node 1 is not published and has no publishing date set. The test API
+    // module will add node 1 into the list to be published.
+    $node1 = $this->drupalCreateNode([
+      'type' => $this->type,
+      'status' => FALSE,
+      'title' => 'API TEST nid_list publish me',
+    ]);
+    // Node 2 is published and has no unpublishing date set. The test API module
+    // will add node 2 into the list to be unpublished.
+    $node2 = $this->drupalCreateNode([
+      'type' => $this->type,
+      'status' => TRUE,
+      'title' => 'API TEST nid_list unpublish me',
+    ]);
+
+    // Before cron, check node 1 is unpublished and node 2 is published.
+    $this->assertFalse($node1->isPublished(), 'Before cron, node 1 "' . $node1->title->value . '" is unpublished.');
+    $this->assertTrue($node2->isPublished(), 'Before cron, node 2 "' . $node2->title->value . '" is published.');
+
+    // Run cron and refresh the nodes.
+    scheduler_cron();
+    $this->nodeStorage->resetCache();
+    $node1 = $this->nodeStorage->load($node1->id());
+    $node2 = $this->nodeStorage->load($node2->id());
+
+    // Check node 1 is published and node 2 is unpublished.
+    $this->assertTrue($node1->isPublished(), 'After cron, node 1 "' . $node1->title->value . '" is published.');
+    $this->assertFalse($node2->isPublished(), 'After cron, node 2 "' . $node2->title->value . '" is unpublished.');
+  }
+
+  /**
+   * Covers hook_scheduler_nid_list_alter($action)
+   *
+   * This hook allows other modules to add or remove node ids from the list to
+   * be processed. As in testNidList() we make it simple by using the title text
+   * to identify which nodes to act on.
+   */
+  public function testNidListAlter() {
+    $this->drupalLogin($this->schedulerUser);
+
+    // Create test nodes. Use the ordinary page type for this test, as having
+    // the 'approved' fields here would unnecessarily complicate the processing.
+    // Node 1 is set for scheduled publishing, but will be removed by the test
+    // API hook_nid_list_alter().
+    $node1 = $this->drupalCreateNode([
+      'type' => $this->type,
+      'status' => FALSE,
+      'title' => 'API TEST nid_list_alter do not publish me',
+      'publish_on' => strtotime('-1 day'),
+    ]);
+
+    // Node 2 is not published and has no publishing date set. The test API
+    // module will add node 2 into the list to be published.
+    $node2 = $this->drupalCreateNode([
+      'type' => $this->type,
+      'status' => FALSE,
+      'title' => 'API TEST nid_list_alter publish me',
+    ]);
+
+    // Node 3 is set for scheduled unpublishing, but will be removed by the test
+    // API hook_nid_list_alter().
+    $node3 = $this->drupalCreateNode([
+      'type' => $this->type,
+      'status' => TRUE,
+      'title' => 'API TEST nid_list_alter do not unpublish me',
+      'unpublish_on' => strtotime('-1 day'),
+    ]);
+
+    // Node 4 is published and has no unpublishing date set. The test API module
+    // will add node 4 into the list to be unpublished.
+    $node4 = $this->drupalCreateNode([
+      'type' => $this->type,
+      'status' => TRUE,
+      'title' => 'API TEST nid_list_alter unpublish me',
+    ]);
+
+    // Check node 1 and 2 are unpublished and node 3 and 4 are published.
+    $this->assertFalse($node1->isPublished(), 'Before cron, node 1 "' . $node1->title->value . '" is unpublished.');
+    $this->assertFalse($node2->isPublished(), 'Before cron, node 2 "' . $node2->title->value . '" is unpublished.');
+    $this->assertTrue($node3->isPublished(), 'Before cron, node 3 "' . $node3->title->value . '" is published.');
+    $this->assertTrue($node4->isPublished(), 'Before cron, node 4 "' . $node4->title->value . '" is published.');
+
+    // Run cron and refresh the nodes.
+    scheduler_cron();
+    $this->nodeStorage->resetCache();
+    $node1 = $this->nodeStorage->load($node1->id());
+    $node2 = $this->nodeStorage->load($node2->id());
+    $node3 = $this->nodeStorage->load($node3->id());
+    $node4 = $this->nodeStorage->load($node4->id());
 
+    // Check node 2 and 3 are published and node 1 and 4 are unpublished.
+    $this->assertFalse($node1->isPublished(), 'After cron, node 1 "' . $node1->title->value . '" is still unpublished.');
+    $this->assertTrue($node2->isPublished(), 'After cron, node 2 "' . $node2->title->value . '" is published.');
+    $this->assertTrue($node3->isPublished(), 'After cron, node 3 "' . $node3->title->value . '" is still published.');
+    $this->assertFalse($node4->isPublished(), 'After cron, node 4 "' . $node4->title->value . '" is unpublished.');
   }
 
   /**
@@ -51,12 +168,10 @@ public function setUp() {
    * This hook can allow or deny the publishing of individual nodes. This test
    * uses the customised content type which has checkboxes 'Approved for
    * publication' and 'Approved for unpublication'.
-   *
-   * @todo Create and update the nodes through the interface so we can check if
-   *   the correct messages are displayed.
    */
   public function testAllowedPublishing() {
     $this->drupalLogin($this->webUser);
+
     // Check the 'approved for publishing' field is shown on the node form.
     $this->drupalGet('node/add/' . $this->customName);
     $this->assertSession()->fieldExists('edit-field-approved-publishing-value');
@@ -68,8 +183,9 @@ public function testAllowedPublishing() {
       'publish_on[0][value][date]' => date('Y-m-d', time() + 3),
       'publish_on[0][value][time]' => date('H:i:s', time() + 3),
     ];
-    $this->drupalPostForm('node/add/' . $this->customName, $edit, 'Save');
-    $this->assertSession()->pageTextContains('is scheduled for publishing, but will not be published until approved.');
+    $this->drupalGet("node/add/{$this->customName}");
+    $this->submitForm($edit, 'Save');
+    $this->assertSession()->pageTextMatches('/is scheduled for publishing.* but will not be published until approved/');
 
     // Create a node that is scheduled but not approved for publication. Then
     // simulate a cron run, and check that the node is still not published.
@@ -77,7 +193,7 @@ public function testAllowedPublishing() {
     scheduler_cron();
     $this->nodeStorage->resetCache([$node->id()]);
     $node = $this->nodeStorage->load($node->id());
-    $this->assertFalse($node->isPublished(), 'An unapproved node is not published during cron processing.');
+    $this->assertFalse($node->isPublished(), "Unapproved '{$node->label()}' should not be published during cron processing.");
 
     // Create a node and approve it for publication, simulate a cron run and
     // check that the node is published. This is a stronger test than simply
@@ -85,30 +201,31 @@ public function testAllowedPublishing() {
     // state that may be in after the cron run above.
     $node = $this->createUnapprovedNode('publish_on');
     $this->approveNode($node->id(), 'field_approved_publishing');
-    $this->assertFalse($node->isPublished(), 'A new approved node is initially not published.');
+    $this->assertFalse($node->isPublished(), "New approved '{$node->label()}' should not be initially published.");
     scheduler_cron();
     $this->nodeStorage->resetCache([$node->id()]);
     $node = $this->nodeStorage->load($node->id());
-    $this->assertTrue($node->isPublished(), 'An approved node is published during cron processing.');
+    $this->assertTrue($node->isPublished(), "Approved '{$node->label()}' should be published during cron processing.");
 
     // Turn on immediate publication of nodes with publication dates in the past
     // and repeat the tests. It is not needed to simulate cron runs here.
     $this->customNodetype->setThirdPartySetting('scheduler', 'publish_past_date', 'publish')->save();
     $node = $this->createUnapprovedNode('publish_on');
-    $this->assertFalse($node->isPublished(), 'An unapproved node with a date in the past is not published immediately after saving.');
+    $this->assertFalse($node->isPublished(), "New unapproved '{$node->label()}' with a date in the past should not be published immediately after saving.");
 
     // Check that the node can be approved and published programatically.
     $this->approveNode($node->id(), 'field_approved_publishing');
     $this->nodeStorage->resetCache([$node->id()]);
     $node = $this->nodeStorage->load($node->id());
-    $this->assertTrue($node->isPublished(), 'An approved node with a date in the past is published immediately via $node->set()->save().');
+    $this->assertTrue($node->isPublished(), "New approved '{$node->label()}' with a date in the past should be published immediately when created programatically.");
 
     // Check that a node can be approved and published via edit form.
     $node = $this->createUnapprovedNode('publish_on');
-    $this->drupalPostForm('node/' . $node->id() . '/edit', ['field_approved_publishing[value]' => '1'], 'Save');
+    $this->drupalGet("node/{$node->id()}/edit");
+    $this->submitForm(['field_approved_publishing[value]' => '1'], 'Save');
     $this->nodeStorage->resetCache([$node->id()]);
     $node = $this->nodeStorage->load($node->id());
-    $this->assertTrue($node->isPublished(), 'An approved node with a date in the past is published immediately after saving via edit form.');
+    $this->assertTrue($node->isPublished(), "Approved '{$node->label()}' with a date in the past should be published immediately after saving via edit form.");
 
     // Show the dblog messages.
     $this->drupalLogin($this->adminUser);
@@ -124,6 +241,7 @@ public function testAllowedPublishing() {
    */
   public function testAllowedUnpublishing() {
     $this->drupalLogin($this->webUser);
+
     // Check the 'approved for unpublishing' field is shown on the node form.
     $this->drupalGet('node/add/' . $this->customName);
     $this->assertSession()->fieldExists('edit-field-approved-unpublishing-value');
@@ -135,8 +253,9 @@ public function testAllowedUnpublishing() {
       'unpublish_on[0][value][date]' => date('Y-m-d', time() + 3),
       'unpublish_on[0][value][time]' => date('H:i:s', time() + 3),
     ];
-    $this->drupalPostForm('node/add/' . $this->customName, $edit, 'Save');
-    $this->assertSession()->pageTextContains('is scheduled for unpublishing, but will not be unpublished until approved.');
+    $this->drupalGet("node/add/{$this->customName}");
+    $this->submitForm($edit, 'Save');
+    $this->assertSession()->pageTextMatches('/is scheduled for unpublishing.* but will not be unpublished until approved/');
 
     // Create a node that is scheduled but not approved for unpublication. Then
     // simulate a cron run, and check that the node is still published.
@@ -144,17 +263,17 @@ public function testAllowedUnpublishing() {
     scheduler_cron();
     $this->nodeStorage->resetCache([$node->id()]);
     $node = $this->nodeStorage->load($node->id());
-    $this->assertTrue($node->isPublished(), 'An unapproved node is not unpublished during cron processing.');
+    $this->assertTrue($node->isPublished(), "Unapproved '{$node->label()}' should not be unpublished during cron processing.");
 
     // Create a node, then approve it for unpublishing, simulate a cron run and
     // check that the node is now unpublished.
     $node = $this->createUnapprovedNode('unpublish_on');
     $this->approveNode($node->id(), 'field_approved_unpublishing');
-    $this->assertTrue($node->isPublished(), 'A new approved node is initially published.');
+    $this->assertTrue($node->isPublished(), "New approved '{$node->label()}' should initially remain published.");
     scheduler_cron();
     $this->nodeStorage->resetCache([$node->id()]);
     $node = $this->nodeStorage->load($node->id());
-    $this->assertFalse($node->isPublished(), 'An approved node is unpublished during cron processing.');
+    $this->assertFalse($node->isPublished(), "Approved '{$node->label()}' should be unpublished during cron processing.");
 
     // Show the dblog messages.
     $this->drupalLogin($this->adminUser);
@@ -175,6 +294,7 @@ public function testAllowedUnpublishing() {
    */
   protected function createUnapprovedNode($date_field) {
     $settings = [
+      'title' => (($date_field == 'publish_on') ? 'Blue' : 'Red') . " legacy node {$this->randomMachineName(10)}",
       'status' => ($date_field == 'unpublish_on'),
       $date_field => strtotime('-1 day'),
       'field_approved_publishing' => 0,
@@ -196,185 +316,8 @@ protected function createUnapprovedNode($date_field) {
   protected function approveNode($nid, $field_name) {
     $this->nodeStorage->resetCache([$nid]);
     $node = $this->nodeStorage->load($nid);
-    $node->set($field_name, TRUE)->save();
-  }
-
-  /**
-   * Covers six events.
-   *
-   * The events allow other modules to react to the Scheduler process being run.
-   * The API test implementations of the event listeners alter the nodes
-   * 'promote' and 'sticky' settings and changes the title.
-   */
-  public function testApiNodeAction() {
-    $this->drupalLogin($this->schedulerUser);
-
-    // Create a test node. Having the 'approved' fields here would complicate
-    // the tests, so use the ordinary page type.
-    $settings = [
-      'publish_on' => strtotime('-1 day'),
-      'type' => $this->type,
-      'promote' => FALSE,
-      'sticky' => FALSE,
-      'title' => 'API TEST node action',
-    ];
-    $node = $this->drupalCreateNode($settings);
-
-    // Check that the 'sticky' and 'promote' fields are off for the new node.
-    $this->assertFalse($node->isSticky(), 'The unpublished node is not sticky.');
-    $this->assertFalse($node->isPromoted(), 'The unpublished node is not promoted.');
-
-    // Run cron and check that hook_scheduler_api() has executed correctly, by
-    // verifying that the node has become promoted and is sticky.
-    scheduler_cron();
-    $this->nodeStorage->resetCache([$node->id()]);
-    $node = $this->nodeStorage->load($node->id());
-    $this->assertTrue($node->isSticky(), 'API action "PRE_PUBLISH" has changed the node to sticky.');
-    $this->assertTrue($node->isPromoted(), 'API action "PUBLISH" has changed the node to promoted.');
-
-    // Now set a date for unpublishing the node. Ensure 'sticky' and 'promote'
-    // are set, so that the assertions are not affected by any failures above.
-    $node->set('unpublish_on', strtotime('-1 day'))
-      ->set('sticky', TRUE)->set('promote', TRUE)->save();
-
-    // Run cron and check that hook_scheduler_api() has executed correctly, by
-    // verifying that the node is not promoted and no longer sticky.
-    scheduler_cron();
-    $this->nodeStorage->resetCache([$node->id()]);
-    $node = $this->nodeStorage->load($node->id());
-    $this->assertFalse($node->isSticky(), 'API action "PRE_UNPUBLISH" has changed the node to not sticky.');
-    $this->assertFalse($node->isPromoted(), 'API action "UNPUBLISH" has changed the node to not promoted.');
-
-    // Turn on immediate publication when a publish date is in the past.
-    $this->nodetype->setThirdPartySetting('scheduler', 'publish_past_date', 'publish')->save();
-
-    // Ensure 'sticky' and 'promote' are not set, so that the assertions are not
-    // affected by any failures above.
-    $node->set('sticky', FALSE)->set('promote', FALSE)->save();
-
-    // Edit the node and set a publish-on date in the past.
-    $edit = [
-      'publish_on[0][value][date]' => date('Y-m-d', strtotime('-2 day', $this->requestTime)),
-      'publish_on[0][value][time]' => date('H:i:s', strtotime('-2 day', $this->requestTime)),
-    ];
-    $this->drupalPostForm('node/' . $node->id() . '/edit', $edit, 'Save');
-    // Verify that the values have been altered as expected.
-    $this->nodeStorage->resetCache([$node->id()]);
-    $node = $this->nodeStorage->load($node->id());
-    $this->assertTrue($node->isSticky(), 'API action "PRE_PUBLISH_IMMEDIATELY" has changed the node to sticky.');
-    $this->assertTrue($node->isPromoted(), 'API action "PUBLISH_IMMEDIATELY" has changed the node to promoted.');
-    $this->assertEquals('Published immediately', $node->title->value, 'API action "PUBLISH_IMMEDIATELY" has changed the node title correctly.');
-  }
-
-  /**
-   * Covers hook_scheduler_nid_list($action)
-   *
-   * Hook_scheduler_nid_list() allows other modules to add more node ids into
-   * the list to be processed. In real scenarios, the third-party module would
-   * likely have more complex data structures and/or tables from which to
-   * identify nodes to add. In this test, to keep it simple, we identify nodes
-   * by the text of the title.
-   */
-  public function testNidList() {
-    $this->drupalLogin($this->schedulerUser);
-
-    // Create test nodes. Use the ordinary page type for this test, as having
-    // the 'approved' fields here would unnecessarily complicate the processing.
-    // Node 1 is not published and has no publishing date set. The test API
-    // module will add node 1 into the list to be published.
-    $node1 = $this->drupalCreateNode([
-      'type' => $this->type,
-      'status' => FALSE,
-      'title' => 'API TEST nid_list publish me',
-    ]);
-    // Node 2 is published and has no unpublishing date set. The test API module
-    // will add node 2 into the list to be unpublished.
-    $node2 = $this->drupalCreateNode([
-      'type' => $this->type,
-      'status' => TRUE,
-      'title' => 'API TEST nid_list unpublish me',
-    ]);
-
-    // Before cron, check node 1 is unpublished and node 2 is published.
-    $this->assertFalse($node1->isPublished(), 'Before cron, node 1 "' . $node1->title->value . '" is unpublished.');
-    $this->assertTrue($node2->isPublished(), 'Before cron, node 2 "' . $node2->title->value . '" is published.');
-
-    // Run cron and refresh the nodes.
-    scheduler_cron();
-    $this->nodeStorage->resetCache();
-    $node1 = $this->nodeStorage->load($node1->id());
-    $node2 = $this->nodeStorage->load($node2->id());
-
-    // Check node 1 is published and node 2 is unpublished.
-    $this->assertTrue($node1->isPublished(), 'After cron, node 1 "' . $node1->title->value . '" is published.');
-    $this->assertFalse($node2->isPublished(), 'After cron, node 2 "' . $node2->title->value . '" is unpublished.');
-  }
-
-  /**
-   * Covers hook_scheduler_nid_list_alter($action)
-   *
-   * This hook allows other modules to add or remove node ids from the list to
-   * be processed. As in testNidList() we make it simple by using the title text
-   * to identify which nodes to act on.
-   */
-  public function testNidListAlter() {
-    $this->drupalLogin($this->schedulerUser);
-
-    // Create test nodes. Use the ordinary page type for this test, as having
-    // the 'approved' fields here would unnecessarily complicate the processing.
-    // Node 1 is set for scheduled publishing, but will be removed by the test
-    // API hook_nid_list_alter().
-    $node1 = $this->drupalCreateNode([
-      'type' => $this->type,
-      'status' => FALSE,
-      'title' => 'API TEST nid_list_alter do not publish me',
-      'publish_on' => strtotime('-1 day'),
-    ]);
-
-    // Node 2 is not published and has no publishing date set. The test API
-    // module will add node 2 into the list to be published.
-    $node2 = $this->drupalCreateNode([
-      'type' => $this->type,
-      'status' => FALSE,
-      'title' => 'API TEST nid_list_alter publish me',
-    ]);
-
-    // Node 3 is set for scheduled unpublishing, but will be removed by the test
-    // API hook_nid_list_alter().
-    $node3 = $this->drupalCreateNode([
-      'type' => $this->type,
-      'status' => TRUE,
-      'title' => 'API TEST nid_list_alter do not unpublish me',
-      'unpublish_on' => strtotime('-1 day'),
-    ]);
-
-    // Node 4 is published and has no unpublishing date set. The test API module
-    // will add node 4 into the list to be unpublished.
-    $node4 = $this->drupalCreateNode([
-      'type' => $this->type,
-      'status' => TRUE,
-      'title' => 'API TEST nid_list_alter unpublish me',
-    ]);
-
-    // Check node 1 and 2 are unpublished and node 3 and 4 are published.
-    $this->assertFalse($node1->isPublished(), 'Before cron, node 1 "' . $node1->title->value . '" is unpublished.');
-    $this->assertFalse($node2->isPublished(), 'Before cron, node 2 "' . $node2->title->value . '" is unpublished.');
-    $this->assertTrue($node3->isPublished(), 'Before cron, node 3 "' . $node3->title->value . '" is published.');
-    $this->assertTrue($node4->isPublished(), 'Before cron, node 4 "' . $node4->title->value . '" is published.');
-
-    // Run cron and refresh the nodes.
-    scheduler_cron();
-    $this->nodeStorage->resetCache();
-    $node1 = $this->nodeStorage->load($node1->id());
-    $node2 = $this->nodeStorage->load($node2->id());
-    $node3 = $this->nodeStorage->load($node3->id());
-    $node4 = $this->nodeStorage->load($node4->id());
-
-    // Check node 2 and 3 are published and node 1 and 4 are unpublished.
-    $this->assertFalse($node1->isPublished(), 'After cron, node 1 "' . $node1->title->value . '" is still unpublished.');
-    $this->assertTrue($node2->isPublished(), 'After cron, node 2 "' . $node2->title->value . '" is published.');
-    $this->assertTrue($node3->isPublished(), 'After cron, node 3 "' . $node3->title->value . '" is still published.');
-    $this->assertFalse($node4->isPublished(), 'After cron, node 4 "' . $node4->title->value . '" is unpublished.');
+    $node->set($field_name, TRUE);
+    $node->set('title', $node->label() . " - approved for publishing: {$node->field_approved_publishing->value}, for unpublishing: {$node->field_approved_unpublishing->value}")->save();
   }
 
   /**
@@ -389,21 +332,24 @@ public function testHideField() {
     // Create test nodes.
     $node1 = $this->drupalCreateNode([
       'type' => $this->type,
-      'title' => 'Red will not have either field hidden',
+      'title' => 'Red Legacy will not have either field hidden',
     ]);
     $node2 = $this->drupalCreateNode([
       'type' => $this->type,
-      'title' => 'Orange will have the publish-on field hidden',
+      'title' => 'Orange Legacy will have the publish-on field hidden',
     ]);
     $node3 = $this->drupalCreateNode([
       'type' => $this->type,
-      'title' => 'Yellow will have the unpublish-on field hidden',
+      'title' => 'Yellow Legacy will have the unpublish-on field hidden',
     ]);
     $node4 = $this->drupalCreateNode([
       'type' => $this->type,
-      'title' => 'Green will have both Scheduler fields hidden',
+      'title' => 'Green Legacy will have both Scheduler fields hidden',
     ]);
 
+    // Set the scheduler fieldset to always expand, for ease during development.
+    $this->nodetype->setThirdPartySetting('scheduler', 'expand_fieldset', 'always')->save();
+
     /** @var \Drupal\Tests\WebAssert $assert */
     $assert = $this->assertSession();
 
@@ -434,26 +380,26 @@ public function testHideField() {
    * This covers hook_scheduler_publish_action and
    * hook_scheduler_unpublish_action.
    */
-  public function testHookPublishUnpublishAction() {
+  public function testPublishUnpublishAction() {
     $this->drupalLogin($this->schedulerUser);
 
     // Create test nodes.
     $node1 = $this->drupalCreateNode([
       'type' => $this->type,
       'status' => FALSE,
-      'title' => 'Red will cause a failure on publishing',
+      'title' => 'Red Legacy will cause a failure on publishing',
       'publish_on' => strtotime('-1 day'),
     ]);
     $node2 = $this->drupalCreateNode([
       'type' => $this->type,
       'status' => TRUE,
-      'title' => 'Orange will be unpublished by the API test module not Scheduler',
+      'title' => 'Orange Legacy will be unpublished by the API test module not Scheduler',
       'unpublish_on' => strtotime('-1 day'),
     ]);
     $node3 = $this->drupalCreateNode([
       'type' => $this->type,
       'status' => FALSE,
-      'title' => 'Yellow will be published by the API test module not Scheduler',
+      'title' => 'Yellow Legacy will be published by the API test module not Scheduler',
       'publish_on' => strtotime('-1 day'),
     ]);
     // 'green' nodes will have both fields hidden so is harder to test manually.
@@ -461,7 +407,7 @@ public function testHookPublishUnpublishAction() {
     $node4 = $this->drupalCreateNode([
       'type' => $this->type,
       'status' => TRUE,
-      'title' => 'Blue will cause a failure on unpublishing',
+      'title' => 'Blue Legacy will cause a failure on unpublishing',
       'unpublish_on' => strtotime('-1 day'),
     ]);
 
diff --git a/web/modules/scheduler/tests/src/Functional/SchedulerHooksTest.php b/web/modules/scheduler/tests/src/Functional/SchedulerHooksTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..858c8f8f0e4cc784b3ce5c61b5318b0875a034aa
--- /dev/null
+++ b/web/modules/scheduler/tests/src/Functional/SchedulerHooksTest.php
@@ -0,0 +1,534 @@
+<?php
+
+namespace Drupal\Tests\scheduler\Functional;
+
+use Drupal\commerce_product\Entity\ProductType;
+use Drupal\node\Entity\NodeType;
+use Drupal\media\Entity\MediaType;
+
+/**
+ * Tests the API hook functions of the Scheduler module.
+ *
+ * This class covers the eight hook functions that Scheduler provides, allowing
+ * other modules to interact with editting, scheduling and processing via cron.
+ *
+ * @group scheduler_api
+ */
+class SchedulerHooksTest extends SchedulerBrowserTestBase {
+
+  /**
+   * Additional modules required.
+   *
+   * @var array
+   *
+   * @todo 'menu_ui' is in the exported node.type definition, and 'path' is in
+   * the entity_form_display. Could these be removed from the config files and
+   * then not needed here?
+   */
+  protected static $modules = ['scheduler_api_test', 'menu_ui', 'path'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+
+    // Load the custom node type and check that it loaded OK. These entity types
+    // are enabled for Scheduler automatically because that is pre-configured
+    // in the scheduler_api_test {type}.yml files.
+    $customNodeName = 'scheduler_api_node_test';
+    $customNodetype = NodeType::load($customNodeName);
+    $this->assertNotNull($customNodetype, "Custom node type $customNodeName failed to load during setUp");
+
+    // Load the custom media type and check that it loaded OK.
+    $customMediaName = 'scheduler_api_media_test';
+    $customMediatype = MediaType::load($customMediaName);
+    $this->assertNotNull($customMediatype, "Custom media type $customMediaName failed to load during setUp");
+
+    // Load the custom product type and check that it loaded OK.
+    $customProductName = 'scheduler_api_product_test';
+    $customProductType = ProductType::load($customProductName);
+    $this->assertNotNull($customProductType, "Custom product type $customProductName failed to load during setUp");
+
+    // Create a web user that has permission to create and edit and schedule
+    // the custom entity types.
+    $this->webUser = $this->drupalCreateUser([
+      "create $customNodeName content",
+      "edit any $customNodeName content",
+      'schedule publishing of nodes',
+      "create $customMediaName media",
+      "edit any $customMediaName media",
+      'schedule publishing of media',
+      "create $customProductName commerce_product",
+      "update any $customProductName commerce_product",
+      'schedule publishing of commerce_product',
+      // 'administer commerce_store' is needed to see and use any store, i.e
+      // cannot add a product without this. Is it a bug?
+      'administer commerce_store',
+    ]);
+    $this->webUser->set('name', 'Wenlock the Web user')->save();
+  }
+
+  /**
+   * Provides test data containing the custom entity types.
+   *
+   * @return array
+   *   Each array item has the values: [entity type id, bundle id].
+   */
+  public function dataCustomEntityTypes() {
+    $data = [
+      '#node' => ['node', 'scheduler_api_node_test'],
+      '#media' => ['media', 'scheduler_api_media_test'],
+      '#commerce_product' => ['commerce_product', 'scheduler_api_product_test'],
+    ];
+    return $data;
+  }
+
+  /**
+   * Covers hook_scheduler_list() and hook_scheduler_{type}_list()
+   *
+   * These hooks allow other modules to add more entity ids into the list being
+   * processed. In real scenarios, the third-party module would likely have more
+   * complex data structures and/or tables from which to identify the ids to
+   * add. In this test, to keep it simple, we identify entities simply by title.
+   *
+   * @dataProvider dataStandardEntityTypes()
+   */
+  public function testList($entityTypeId, $bundle) {
+    $storage = $this->entityStorageObject($entityTypeId);
+    $this->drupalLogin($this->schedulerUser);
+
+    // Create test entities using the standard scheduler test entity types.
+    // Entity 1 is not published and has no publishing date set. The test API
+    // module will add this entity into the list to be published using an
+    // implementation of general hook_scheduler_list() function. Entity 2 is
+    // similar but will be added via the hook_scheduler_{type}_list() function.
+    $entity1 = $this->createEntity($entityTypeId, $bundle, [
+      'status' => FALSE,
+      'title' => "Pink $entityTypeId list publish me",
+    ]);
+    $entity2 = $this->createEntity($entityTypeId, $bundle, [
+      'status' => FALSE,
+      'title' => "Purple $entityTypeId list publish me",
+    ]);
+
+    // Entity 3 is published and has no unpublishing date set. The test API
+    // module will add this entity into the list to be unpublished.
+    $entity3 = $this->createEntity($entityTypeId, $bundle, [
+      'status' => TRUE,
+      'title' => "Pink $entityTypeId list unpublish me",
+    ]);
+    $entity4 = $this->createEntity($entityTypeId, $bundle, [
+      'status' => TRUE,
+      'title' => "Purple $entityTypeId list unpublish me",
+    ]);
+
+    // Before cron, check that entity 1 and 2 are unpublished and entity 3 and 4
+    // are published.
+    $this->assertFalse($entity1->isPublished(), "Before cron, $entityTypeId 1 '{$entity1->label()}' should be unpublished.");
+    $this->assertFalse($entity2->isPublished(), "Before cron, $entityTypeId 2 '{$entity2->label()}' should be unpublished.");
+    $this->assertTrue($entity3->isPublished(), "Before cron, $entityTypeId 3 '{$entity3->label()}' should be published.");
+    $this->assertTrue($entity4->isPublished(), "Before cron, $entityTypeId 4 '{$entity4->label()}' should be published.");
+
+    // Run cron and refresh the entities.
+    scheduler_cron();
+    $storage->resetCache();
+    for ($i = 1; $i <= 4; $i++) {
+      ${"entity$i"} = $storage->load(${"entity$i"}->id());
+    }
+
+    // Check tha entity 1 and 2 have been published.
+    $this->assertTrue($entity1->isPublished(), "After cron, $entityTypeId 1 '{$entity1->label()}' should be published.");
+    $this->assertTrue($entity2->isPublished(), "After cron, $entityTypeId 2 '{$entity2->label()}' should be published.");
+
+    // Check that entity 3 and 4 have been unpublished.
+    $this->assertFalse($entity3->isPublished(), "After cron, $entityTypeId 3 '{$entity3->label()}' should be unpublished.");
+    $this->assertFalse($entity4->isPublished(), "After cron, $entityTypeId 4 '{$entity4->label()}' should be unpublished.");
+  }
+
+  /**
+   * Covers hook_scheduler_list_alter() and hook_scheduler_{type}_list_alter()
+   *
+   * These hook allows other modules to add or remove entity ids from the list
+   * to be processed.
+   *
+   * @dataProvider dataStandardEntityTypes()
+   */
+  public function testListAlter($entityTypeId, $bundle) {
+    $storage = $this->entityStorageObject($entityTypeId);
+    $this->drupalLogin($this->schedulerUser);
+
+    // Create test entities using the standard scheduler test entity types.
+    // Entity 1 is set for scheduled publishing, but will be removed by the test
+    // API generic hook_scheduler_list_alter() function. Entity 2 is similar but
+    // is removed via the specifc hook_scheduler_{type}_list_alter() function.
+    $entity1 = $this->createEntity($entityTypeId, $bundle, [
+      'status' => FALSE,
+      'title' => "Pink $entityTypeId list_alter do not publish me",
+      'publish_on' => strtotime('-1 day'),
+    ]);
+    $entity2 = $this->createEntity($entityTypeId, $bundle, [
+      'status' => FALSE,
+      'title' => "Purple $entityTypeId list_alter do not publish me",
+      'publish_on' => strtotime('-1 day'),
+    ]);
+
+    // Entity 3 is not published and has no publishing date set. The test module
+    // generic hook_scheduler_list_alter() function will add a date and add the
+    // id into the list to be published. Entity 4 is similar but the date and id
+    // is added by the specifc hook_scheduler_{type}_list_alter() function.
+    $entity3 = $this->createEntity($entityTypeId, $bundle, [
+      'status' => FALSE,
+      'title' => "Pink $entityTypeId list_alter publish me",
+    ]);
+    $entity4 = $this->createEntity($entityTypeId, $bundle, [
+      'status' => FALSE,
+      'title' => "Purple $entityTypeId list_alter publish me",
+    ]);
+
+    // Entity 5 is set for scheduled unpublishing, but will be removed by the
+    // generic hook_scheduler_list_alter() function. Entity 6 is similar but is
+    // removed by the specifc hook_scheduler_{type}_list_alter() function.
+    $entity5 = $this->createEntity($entityTypeId, $bundle, [
+      'status' => TRUE,
+      'title' => "Pink $entityTypeId list_alter do not unpublish me",
+      'unpublish_on' => strtotime('-1 day'),
+    ]);
+    $entity6 = $this->createEntity($entityTypeId, $bundle, [
+      'status' => TRUE,
+      'title' => "Purple $entityTypeId list_alter do not unpublish me",
+      'unpublish_on' => strtotime('-1 day'),
+    ]);
+
+    // Entity 7 is published and has no unpublishing date set. The generic
+    // hook_scheduler_list_alter() will add a date and add the id into the list
+    // to be unpublished. Entity 8 is similar but the date and id will be added
+    // by the specifc hook_scheduler_{type}_list_alter() function.
+    $entity7 = $this->createEntity($entityTypeId, $bundle, [
+      'status' => TRUE,
+      'title' => "Pink $entityTypeId list_alter unpublish me",
+    ]);
+    $entity8 = $this->createEntity($entityTypeId, $bundle, [
+      'status' => TRUE,
+      'title' => "Purple $entityTypeId list_alter unpublish me",
+    ]);
+
+    // Before cron, check entities 1-4 are unpublished and 5-8 are published.
+    $this->assertFalse($entity1->isPublished(), "Before cron, $entityTypeId 1 '{$entity1->label()}' should be unpublished.");
+    $this->assertFalse($entity2->isPublished(), "Before cron, $entityTypeId 2 '{$entity2->label()}' should be unpublished.");
+    $this->assertFalse($entity3->isPublished(), "Before cron, $entityTypeId 3 '{$entity3->label()}' should be unpublished.");
+    $this->assertFalse($entity4->isPublished(), "Before cron, $entityTypeId 4 '{$entity4->label()}' should be unpublished.");
+    $this->assertTrue($entity5->isPublished(), "Before cron, $entityTypeId 5 '{$entity5->label()}' should be published.");
+    $this->assertTrue($entity6->isPublished(), "Before cron, $entityTypeId 6 '{$entity6->label()}' should be published.");
+    $this->assertTrue($entity7->isPublished(), "Before cron, $entityTypeId 7 '{$entity7->label()}' should be published.");
+    $this->assertTrue($entity8->isPublished(), "Before cron, $entityTypeId 8 '{$entity8->label()}' should be published.");
+
+    // Run cron and refresh the entities from storage.
+    scheduler_cron();
+    $storage->resetCache();
+    for ($i = 1; $i <= 8; $i++) {
+      ${"entity$i"} = $storage->load(${"entity$i"}->id());
+    }
+
+    // After cron, check that entities 1-2 remain unpublished, 3-4 have now
+    // been published, 5-6 remain published and 7-8 have been unpublished.
+    $this->assertFalse($entity1->isPublished(), "After cron, $entityTypeId 1 '{$entity1->label()}' should be unpublished.");
+    $this->assertFalse($entity2->isPublished(), "After cron, $entityTypeId 2 '{$entity2->label()}' should be unpublished.");
+    $this->assertTrue($entity3->isPublished(), "After cron, $entityTypeId 3 '{$entity3->label()}' should be published.");
+    $this->assertTrue($entity4->isPublished(), "After cron, $entityTypeId 4 '{$entity4->label()}' should be published.");
+    $this->assertTrue($entity5->isPublished(), "After cron, $entityTypeId 5 '{$entity5->label()}' should be published.");
+    $this->assertTrue($entity6->isPublished(), "After cron, $entityTypeId 6 '{$entity6->label()}' should be published.");
+    $this->assertFalse($entity7->isPublished(), "After cron, $entityTypeId 7 '{$entity7->label()}' should be unpublished.");
+    $this->assertFalse($entity8->isPublished(), "After cron, $entityTypeId 8 '{$entity8->label()}' should be unpublished.");
+  }
+
+  /**
+   * Covers hook_scheduler_{type}_publishing_allowed()
+   *
+   * This hook is used to deny the publishing of individual entities. The test
+   * uses the customised content type which has checkboxes 'Approved for
+   * publishing' and 'Approved for unpublishing'.
+   *
+   * @dataProvider dataCustomEntityTypes()
+   */
+  public function testPublishingAllowed($entityTypeId, $bundle) {
+    $storage = $this->entityStorageObject($entityTypeId);
+    $titleField = $this->titleField($entityTypeId);
+    $this->drupalLogin($this->webUser);
+
+    // Check the 'approved for publishing' field is shown on the entity form.
+    $this->drupalGet($this->entityAddUrl($entityTypeId, $bundle));
+    $this->assertSession()->fieldExists('edit-field-approved-publishing-value');
+
+    // Check that the message is shown when scheduling an entity for publishing
+    // which is not yet allowed to be published.
+    $edit = [
+      "{$titleField}[0][value]" => "Blue $entityTypeId - Set publish-on date without approval",
+      'publish_on[0][value][date]' => date('Y-m-d', time() + 3),
+      'publish_on[0][value][time]' => date('H:i:s', time() + 3),
+    ];
+    $this->submitForm($edit, 'Save');
+    $this->assertSession()->pageTextMatches('/is scheduled for publishing.* but will not be published until approved/');
+
+    // Create an entity that is scheduled but not approved for publishing. Then
+    // run cron for scheduler, and check that the entity is still not published.
+    $entity = $this->createUnapprovedEntity($entityTypeId, $bundle, 'publish_on');
+    scheduler_cron();
+    $storage->resetCache([$entity->id()]);
+    $entity = $storage->load($entity->id());
+    $this->assertFalse($entity->isPublished(), "Unapproved '{$entity->label()}' should not be published during cron processing.");
+
+    // Create an entity and approve it for publishing, run cron for scheduler
+    // and check that the entity is published. This is a stronger test than
+    // simply approving the previously used entity above, as we do not know what
+    // publish state that may be in after the cron run above.
+    $entity = $this->createUnapprovedEntity($entityTypeId, $bundle, 'publish_on');
+    $this->approveEntity($entityTypeId, $entity->id(), 'field_approved_publishing');
+    $this->assertFalse($entity->isPublished(), "New approved '{$entity->label()}' should not be initially published.");
+    scheduler_cron();
+    $storage->resetCache([$entity->id()]);
+    $entity = $storage->load($entity->id());
+    $this->assertTrue($entity->isPublished(), "Approved '{$entity->label()}' should be published during cron processing.");
+
+    // Turn on immediate publishing when the date is in the past and repeat
+    // the tests. It is not needed to run cron jobs here.
+    $bundle_field_name = $entity->getEntityType()->get('entity_keys')['bundle'];
+    $entity->$bundle_field_name->entity->setThirdPartySetting('scheduler', 'publish_past_date', 'publish')->save();
+
+    // Check that an entity can be approved and published programatically.
+    $entity = $this->createUnapprovedEntity($entityTypeId, $bundle, 'publish_on');
+    $this->assertFalse($entity->isPublished(), "New unapproved '{$entity->label()}' with a date in the past should not be published immediately after saving.");
+    $this->approveEntity($entityTypeId, $entity->id(), 'field_approved_publishing');
+    $storage->resetCache([$entity->id()]);
+    $entity = $storage->load($entity->id());
+    $this->assertTrue($entity->isPublished(), "New approved '{$entity->label()}' with a date in the past should be published immediately when created programatically.");
+
+    // Check that an entity can be approved and published via edit form.
+    $entity = $this->createUnapprovedEntity($entityTypeId, $bundle, 'publish_on');
+    $this->drupalGet($entity->toUrl('edit-form'));
+    $this->submitForm(['field_approved_publishing[value]' => '1'], 'Save');
+    $storage->resetCache([$entity->id()]);
+    $entity = $storage->load($entity->id());
+    $this->assertTrue($entity->isPublished(), "Approved '{$entity->label()}' with a date in the past is published immediately after saving via edit form.");
+  }
+
+  /**
+   * Covers hook_scheduler_{type}_unpublishing_allowed()
+   *
+   * This hook is used to deny the unpublishing of individual entities. This
+   * test is simpler than the test sequence for allowed publishing, because the
+   * past date 'publish' option is not applicable.
+   *
+   * @dataProvider dataCustomEntityTypes()
+   */
+  public function testUnpublishingAllowed($entityTypeId, $bundle) {
+    $storage = $this->entityStorageObject($entityTypeId);
+    $titleField = $this->titleField($entityTypeId);
+    $this->drupalLogin($this->webUser);
+
+    // Check the 'approved for unpublishing' field is shown on the entity form.
+    $this->drupalGet($this->entityAddUrl($entityTypeId, $bundle));
+    $this->assertSession()->fieldExists('edit-field-approved-unpublishing-value');
+
+    // Check that the message is shown when scheduling an entity for
+    // unpublishing which is not yet allowed to be unpublished.
+    $edit = [
+      "{$titleField}[0][value]" => "Red $entityTypeId - Set unpublish-on date without approval",
+      'unpublish_on[0][value][date]' => date('Y-m-d', time() + 3),
+      'unpublish_on[0][value][time]' => date('H:i:s', time() + 3),
+    ];
+    $this->submitForm($edit, 'Save');
+    $this->assertSession()->pageTextMatches('/is scheduled for unpublishing.* but will not be unpublished until approved/');
+
+    // Create an entity that is scheduled but not approved for unpublishing, run
+    // cron for scheduler, and check that the entity is still published.
+    $entity = $this->createUnapprovedEntity($entityTypeId, $bundle, 'unpublish_on');
+    scheduler_cron();
+    $storage->resetCache([$entity->id()]);
+    $entity = $storage->load($entity->id());
+    $this->assertTrue($entity->isPublished(), "Unapproved '{$entity->label()}' should not be unpublished during cron processing.");
+
+    // Create an entity and approve it for unpublishing, run cron for scheduler
+    // and check that the entity is unpublished.
+    $entity = $this->createUnapprovedEntity($entityTypeId, $bundle, 'unpublish_on');
+    $this->approveEntity($entityTypeId, $entity->id(), 'field_approved_unpublishing');
+    $this->assertTrue($entity->isPublished(), "New approved '{$entity->label()}' should initially remain published.");
+    scheduler_cron();
+    $storage->resetCache([$entity->id()]);
+    $entity = $storage->load($entity->id());
+    $this->assertFalse($entity->isPublished(), "Approved '{$entity->label()}' should be unpublished during cron processing.");
+  }
+
+  /**
+   * Creates a new entity that is not approved.
+   *
+   * The entity will have a publish/unpublish date in the past to make sure it
+   * will be included in the next cron run.
+   *
+   * @param string $entityTypeId
+   *   The entity type to create, 'node' or 'media'.
+   * @param string $bundle
+   *   The bundle to create, 'scheduler_api_test' or 'scheduler_api_media_test'.
+   * @param string $date_field
+   *   The Scheduler date field to set, either 'publish_on' or 'unpublish_on'.
+   *
+   * @return \Drupal\Core\Entity\EntityInterface
+   *   The created entity object.
+   */
+  protected function createUnapprovedEntity($entityTypeId, $bundle, $date_field) {
+    $settings = [
+      'title' => (($date_field == 'publish_on') ? 'Blue' : 'Red') . " $entityTypeId {$this->randomMachineName(10)}",
+      'status' => ($date_field == 'unpublish_on'),
+      $date_field => strtotime('-1 day'),
+      'field_approved_publishing' => 0,
+      'field_approved_unpublishing' => 0,
+    ];
+    return $this->createEntity($entityTypeId, $bundle, $settings);
+  }
+
+  /**
+   * Approves an entity for publication or unpublication.
+   *
+   * @param string $entityTypeId
+   *   The entity type to approve, 'node' or 'media'.
+   * @param int $id
+   *   The id of the entity to approve.
+   * @param string $field_name
+   *   The name of the field to set, either 'field_approved_publishing' or
+   *   'field_approved_unpublishing'.
+   */
+  protected function approveEntity($entityTypeId, $id, $field_name) {
+    $storage = $this->entityStorageObject($entityTypeId);
+    $storage->resetCache([$id]);
+    $entity = $storage->load($id);
+    $entity->set($field_name, TRUE);
+    $label_field = $entity->getEntityType()->get('entity_keys')['label'];
+    $entity->set($label_field, $entity->label() . " - approved for publishing: {$entity->field_approved_publishing->value}, for unpublishing: {$entity->field_approved_unpublishing->value}")->save();
+  }
+
+  /**
+   * Tests the hooks which allow hiding of scheduler input fields.
+   *
+   * This test covers:
+   *   hook_scheduler_hide_publish_date()
+   *   hook_scheduler_hide_unpublish_date()
+   *   hook_scheduler_{type}_hide_publish_date()
+   *   hook_scheduler_{type}_hide_unpublish_date()
+   *
+   * @dataProvider dataStandardEntityTypes()
+   */
+  public function testHideDateField($entityTypeId, $bundle) {
+    $this->drupalLogin($this->schedulerUser);
+
+    // Create test entities.
+    $entity1 = $this->createEntity($entityTypeId, $bundle, [
+      'title' => "Red $entityTypeId will have neither field hidden",
+    ]);
+    $entity2 = $this->createEntity($entityTypeId, $bundle, [
+      'title' => "Orange $entityTypeId will have the publish-on field hidden",
+    ]);
+    $entity3 = $this->createEntity($entityTypeId, $bundle, [
+      'title' => "Yellow $entityTypeId will have the unpublish-on field hidden",
+    ]);
+    $entity4 = $this->createEntity($entityTypeId, $bundle, [
+      'title' => "Green $entityTypeId will have both Scheduler fields hidden",
+    ]);
+
+    // Set the scheduler fieldset to always expand, for ease during development.
+    $bundle_field_name = $entity1->getEntityType()->get('entity_keys')['bundle'];
+    $entity1->$bundle_field_name->entity->setThirdPartySetting('scheduler', 'expand_fieldset', 'always')->save();
+
+    /** @var \Drupal\Tests\WebAssert $assert */
+    $assert = $this->assertSession();
+
+    // Entity 1 'Red' should have both fields displayed.
+    $this->drupalGet($entity1->toUrl('edit-form'));
+    $assert->ElementExists('xpath', '//input[@id = "edit-publish-on-0-value-date"]');
+    $assert->ElementExists('xpath', '//input[@id = "edit-unpublish-on-0-value-date"]');
+
+    // Entity 2 'Orange' should have only the publish-on field hidden.
+    $this->drupalGet($entity2->toUrl('edit-form'));
+    $assert->ElementNotExists('xpath', '//input[@id = "edit-publish-on-0-value-date"]');
+    $assert->ElementExists('xpath', '//input[@id = "edit-unpublish-on-0-value-date"]');
+
+    // Entity 3 'Yellow' should have only the unpublish-on field hidden.
+    $this->drupalGet($entity3->toUrl('edit-form'));
+    $assert->ElementExists('xpath', '//input[@id = "edit-publish-on-0-value-date"]');
+    $assert->ElementNotExists('xpath', '//input[@id = "edit-unpublish-on-0-value-date"]');
+
+    // Entity 4 'Green' should have both publish-on and unpublish-on hidden.
+    $this->drupalGet($entity4->toUrl('edit-form'));
+    $assert->ElementNotExists('xpath', '//input[@id = "edit-publish-on-0-value-date"]');
+    $assert->ElementNotExists('xpath', '//input[@id = "edit-unpublish-on-0-value-date"]');
+  }
+
+  /**
+   * Tests when other modules execute the 'publish' and 'unpublish' processes.
+   *
+   * This test covers:
+   *   hook_scheduler_publish_process()
+   *   hook_scheduler_unpublish_process()
+   *   hook_scheduler_{type}_publish_process()
+   *   hook_scheduler_{type}_unpublish_process()
+   *
+   * @dataProvider dataStandardEntityTypes()
+   */
+  public function testPublishUnpublishProcess($entityTypeId, $bundle) {
+    // $this->drupalLogin($this->schedulerUser);
+    $storage = $this->entityStorageObject($entityTypeId);
+
+    // Create test entities.
+    $entity1 = $this->createEntity($entityTypeId, $bundle, [
+      'status' => FALSE,
+      'title' => "Red $entityTypeId will cause a failure on publishing",
+      'publish_on' => strtotime('-1 day'),
+    ]);
+    $entity2 = $this->createEntity($entityTypeId, $bundle, [
+      'status' => TRUE,
+      'title' => "Orange $entityTypeId will be unpublished by the API test module not Scheduler",
+      'unpublish_on' => strtotime('-1 day'),
+    ]);
+    $entity3 = $this->createEntity($entityTypeId, $bundle, [
+      'status' => FALSE,
+      'title' => "Yellow $entityTypeId will be published by the API test module not Scheduler",
+      'publish_on' => strtotime('-1 day'),
+    ]);
+    // 'Green' will have both fields hidden so is harder to test manually.
+    // Therefore introduce a different colour - Blue.
+    $entity4 = $this->createEntity($entityTypeId, $bundle, [
+      'status' => TRUE,
+      'title' => "Blue $entityTypeId will cause a failure on unpublishing",
+      'unpublish_on' => strtotime('-1 day'),
+    ]);
+
+    // Simulate a cron run.
+    scheduler_cron();
+
+    // Check red.
+    $storage->resetCache([$entity1->id()]);
+    $entity1 = $storage->load($entity1->id());
+    $this->assertFalse($entity1->isPublished(), 'Red should remain unpublished.');
+    $this->assertNotEmpty($entity1->publish_on->value, 'Red should still have a publish-on date.');
+
+    // Check orange.
+    $storage->resetCache([$entity2->id()]);
+    $entity2 = $storage->load($entity2->id());
+    $this->assertFalse($entity2->isPublished(), 'Orange should be unpublished.');
+    $this->assertStringContainsString('unpublishing processed by API test module', $entity2->label(), 'Orange should be processed by the API test module.');
+    $this->assertEmpty($entity2->unpublish_on->value, 'Orange should not have an unpublish-on date.');
+
+    // Check yellow.
+    $storage->resetCache([$entity3->id()]);
+    $entity3 = $storage->load($entity3->id());
+    $this->assertTrue($entity3->isPublished(), 'Yellow should be published.');
+    $this->assertStringContainsString('publishing processed by API test module', $entity3->label(), 'Yellow should be processed by the API test module.');
+    $this->assertEmpty($entity3->publish_on->value, 'Yellow should not have a publish-on date.');
+
+    // Check blue.
+    $storage->resetCache([$entity4->id()]);
+    $entity4 = $storage->load($entity4->id());
+    $this->assertTrue($entity4->isPublished(), 'Blue should remain published.');
+    $this->assertNotEmpty($entity4->unpublish_on->value, 'Blue should still have an unpublish-on date.');
+  }
+
+}
diff --git a/web/modules/scheduler/tests/src/Functional/SchedulerLightweightCronTest.php b/web/modules/scheduler/tests/src/Functional/SchedulerLightweightCronTest.php
index 110a1eb6fd3f1724166561eb68aff0f3c3a35b87..c79c6bcc11b0bb7eb531b296e4ab94d4ab851f3e 100644
--- a/web/modules/scheduler/tests/src/Functional/SchedulerLightweightCronTest.php
+++ b/web/modules/scheduler/tests/src/Functional/SchedulerLightweightCronTest.php
@@ -14,7 +14,7 @@ class SchedulerLightweightCronTest extends SchedulerBrowserTestBase {
   /**
    * {@inheritdoc}
    */
-  public function setUp() {
+  protected function setUp(): void {
     parent::setUp();
 
     $this->routeCronForm = Url::fromRoute('scheduler.cron_form');
@@ -55,23 +55,26 @@ public function testLightweightCronSettingsForm() {
     $this->drupalGet($this->routeCronForm);
     $key_xpath = $this->xpath('//input[@id="edit-lightweight-access-key"]/@value');
     $key = $key_xpath[0]->getText();
-    $this->assertTrue(!empty($key), 'Default lightweight cron key field is not empty');
-    $this->assertTrue(strlen($key) == 20, 'Default lightweight cron key string length is 20');
+    $this->assertNotEmpty($key, 'The default lightweight cron key field should not be empty');
+    $this->assertEquals(20, strlen($key), 'The default lightweight cron key string length should be 20');
 
     // Check that a new random key can be generated.
-    $this->drupalPostForm($this->routeCronForm, [], 'Generate new random key');
+    $this->drupalGet($this->routeCronForm);
+    $this->submitForm([], 'Generate new random key');
     $new_key_xpath = $this->xpath('//input[@id="edit-lightweight-access-key"]/@value');
     $new_key = $new_key_xpath[0]->getText();
-    $this->assertTrue(!empty($new_key), 'Lightweight cron key field is not empty after generating new key');
-    $this->assertTrue(strlen($new_key) == 20, 'New lightweight cron key string length is 20');
-    $this->assertNotEqual($key, $new_key, 'Lightweight cron key has changed.');
+    $this->assertNotEmpty($new_key, 'The lightweight cron key field should not be empty after generating a new key');
+    $this->assertEquals(20, strlen($new_key), 'The new lightweight cron key string length should be 20');
+    $this->assertNotEquals($new_key, $key, 'The new lightweight cron key should be different from the previous key.');
 
     // Check that the 'run lightweight cron' button works.
-    $this->drupalPostForm($this->routeCronForm, [], "Run Scheduler's lightweight cron now");
+    $this->drupalGet($this->routeCronForm);
+    $this->submitForm([], "Run Scheduler's lightweight cron now");
     $this->assertSession()->pageTextContains('Lightweight cron run completed.');
 
     // Check that the form cannot be saved if the cron key is blank.
-    $this->drupalPostForm($this->routeCronForm, ['lightweight_access_key' => ''], 'Save configuration');
+    $this->drupalGet($this->routeCronForm);
+    $this->submitForm(['lightweight_access_key' => ''], 'Save configuration');
     $this->assertSession()->pageTextContains('Lightweight cron access key field is required.');
     $this->assertSession()->pageTextNotContains('The configuration options have been saved.');
   }
diff --git a/web/modules/scheduler/tests/src/Functional/SchedulerMessageTest.php b/web/modules/scheduler/tests/src/Functional/SchedulerMessageTest.php
index 8ca84158424e5ba07f332e3d3b58e78fe6964dd2..7e6bc915e4c4dfb4970eae124421b79cf663fb74 100644
--- a/web/modules/scheduler/tests/src/Functional/SchedulerMessageTest.php
+++ b/web/modules/scheduler/tests/src/Functional/SchedulerMessageTest.php
@@ -3,18 +3,21 @@
 namespace Drupal\Tests\scheduler\Functional;
 
 /**
- * Tests the option to display or not display the confirmations message.
+ * Tests the 'show confirmation message' entity type setting.
  *
  * @group scheduler
  */
 class SchedulerMessageTest extends SchedulerBrowserTestBase {
 
   /**
-   * Test the                .
+   * Tests the option to display or not display the confirmation message.
+   *
+   * @dataProvider dataStandardEntityTypes()
    */
-  public function testConfirmationMessage() {
+  public function testConfirmationMessage($entityTypeId, $bundle) {
     // Log in.
     $this->drupalLogin($this->schedulerUser);
+    $titleField = $this->titleField($entityTypeId);
 
     $publish_on = strtotime('+ 1 day 5 hours');
     $unpublish_on = strtotime('+ 2 day 7 hours');
@@ -27,52 +30,62 @@ public function testConfirmationMessage() {
     // Create the content and check that the messages are shown by default.
     // First just a publish_on date.
     $edit = [
-      'title[0][value]' => $title1,
+      "{$titleField}[0][value]" => $title1,
       'publish_on[0][value][date]' => date('Y-m-d', $publish_on),
       'publish_on[0][value][time]' => date('H:i:s', $publish_on),
     ];
-    $this->drupalPostForm('node/add/' . $this->type, $edit, 'Save');
-    $node1 = $this->drupalGetNodeByTitle($title1);
+    $add_url = $this->entityAddUrl($entityTypeId, $bundle);
+    $this->drupalGet($add_url);
+    $this->submitForm($edit, 'Save');
+    $entity1 = $this->getEntityByTitle($entityTypeId, $title1);
     $this->assertSession()->pageTextContains(sprintf('%s is scheduled to be published %s', $title1, $publish_on_formatted));
 
     // Second, just an unpublish_on date.
     $edit = [
-      'title[0][value]' => $title2,
+      "{$titleField}[0][value]" => $title2,
       'unpublish_on[0][value][date]' => date('Y-m-d', $unpublish_on),
       'unpublish_on[0][value][time]' => date('H:i:s', $unpublish_on),
     ];
-    $this->drupalPostForm('node/add/' . $this->type, $edit, 'Save');
-    $node2 = $this->drupalGetNodeByTitle($title2);
+    $this->drupalGet($add_url);
+    $this->submitForm($edit, 'Save');
+    $entity2 = $this->getEntityByTitle($entityTypeId, $title2);
     $this->assertSession()->pageTextContains(sprintf('%s is scheduled to be unpublished %s', $title2, $unpublish_on_formatted));
 
-    // Third, a node with both dates.
+    // Third, with both dates.
     $edit = [
-      'title[0][value]' => $title3,
+      "{$titleField}[0][value]" => $title3,
       'publish_on[0][value][date]' => date('Y-m-d', $publish_on),
       'publish_on[0][value][time]' => date('H:i:s', $publish_on),
       'unpublish_on[0][value][date]' => date('Y-m-d', $unpublish_on),
       'unpublish_on[0][value][time]' => date('H:i:s', $unpublish_on),
     ];
-    $this->drupalPostForm('node/add/' . $this->type, $edit, 'Save');
-    $node3 = $this->drupalGetNodeByTitle($title3);
+    $this->drupalGet($add_url);
+    $this->submitForm($edit, 'Save');
+    $entity3 = $this->getEntityByTitle($entityTypeId, $title3);
     $this->assertSession()->pageTextContains(sprintf('%s is scheduled to be published %s and unpublished %s', $title3, $publish_on_formatted, $unpublish_on_formatted));
 
     // Change the option to not display the messages.
-    $this->nodetype->setThirdPartySetting('scheduler', 'show_message_after_update', FALSE)->save();
-    $this->drupalPostForm('node/' . $node1->id() . '/edit', [], 'Save');
+    $this->entityTypeObject($entityTypeId, $bundle)->setThirdPartySetting('scheduler', 'show_message_after_update', FALSE)->save();
+    $this->drupalGet($entity1->toUrl('edit-form'));
+    $this->submitForm([], 'Save');
     $this->assertSession()->pageTextNotContains('is scheduled to be published');
-    $this->drupalPostForm('node/' . $node2->id() . '/edit', [], 'Save');
+    $this->drupalGet($entity2->toUrl('edit-form'));
+    $this->submitForm([], 'Save');
     $this->assertSession()->pageTextNotContains('is scheduled to be unpublished');
-    $this->drupalPostForm('node/' . $node3->id() . '/edit', [], 'Save');
+    $this->drupalGet($entity3->toUrl('edit-form'));
+    $this->submitForm([], 'Save');
     $this->assertSession()->pageTextNotContains('is scheduled to be published');
 
     // Set back to display the messages, and check after edit.
-    $this->nodetype->setThirdPartySetting('scheduler', 'show_message_after_update', TRUE)->save();
-    $this->drupalPostForm('node/' . $node1->id() . '/edit', [], 'Save');
+    $this->entityTypeObject($entityTypeId, $bundle)->setThirdPartySetting('scheduler', 'show_message_after_update', TRUE)->save();
+    $this->drupalGet($entity1->toUrl('edit-form'));
+    $this->submitForm([], 'Save');
     $this->assertSession()->pageTextContains(sprintf('%s is scheduled to be published %s', $title1, $publish_on_formatted));
-    $this->drupalPostForm('node/' . $node2->id() . '/edit', [], 'Save');
+    $this->drupalGet($entity2->toUrl('edit-form'));
+    $this->submitForm([], 'Save');
     $this->assertSession()->pageTextContains(sprintf('%s is scheduled to be unpublished %s', $title2, $unpublish_on_formatted));
-    $this->drupalPostForm('node/' . $node3->id() . '/edit', [], 'Save');
+    $this->drupalGet($entity3->toUrl('edit-form'));
+    $this->submitForm([], 'Save');
     $this->assertSession()->pageTextContains(sprintf('%s is scheduled to be published %s and unpublished %s', $title3, $publish_on_formatted, $unpublish_on_formatted));
   }
 
diff --git a/web/modules/scheduler/tests/src/Functional/SchedulerMetaInformationTest.php b/web/modules/scheduler/tests/src/Functional/SchedulerMetaInformationTest.php
index f22e875573e563681eec1c5664ea6c3407ba732e..86b6a0c6feddcac2264f1be722b1ea1b9e77747f 100644
--- a/web/modules/scheduler/tests/src/Functional/SchedulerMetaInformationTest.php
+++ b/web/modules/scheduler/tests/src/Functional/SchedulerMetaInformationTest.php
@@ -10,39 +10,47 @@
 class SchedulerMetaInformationTest extends SchedulerBrowserTestBase {
 
   /**
-   * Tests meta-information on scheduled nodes.
+   * Tests meta-information on scheduled entities.
    *
-   * When nodes are scheduled for unpublication, an X-Robots-Tag HTTP header is
-   * sent, alerting crawlers about when an item expires and should be removed
-   * from search results.
+   * When an entity is scheduled for unpublication, an X-Robots-Tag HTTP header
+   * is included, telling crawlers about when an item will expire and should be
+   * removed from search results.
+   *
+   * @dataProvider dataStandardEntityTypes()
    */
-  public function testMetaInformation() {
+  public function testMetaInformation($entityTypeId, $bundle) {
     // Log in.
     $this->drupalLogin($this->schedulerUser);
 
-    // Create a published node without scheduling.
-    $published_node = $this->drupalCreateNode([
-      'type' => $this->type,
-      'status' => TRUE,
-    ]);
-    $this->drupalGet('node/' . $published_node->id());
+    // Create a published entity without scheduling dates.
+    $entity = $this->createEntity($entityTypeId, $bundle, ['status' => TRUE]);
 
     // Since we did not set an unpublish date, there should be no X-Robots-Tag
     // header on the response.
-    $this->assertNull($this->drupalGetHeader('X-Robots-Tag'), 'X-Robots-Tag is not present when no unpublish date is set.');
+    $this->drupalGet($entity->toUrl());
+    $this->assertNull($this->getSession()->getResponseHeader('X-Robots-Tag'), 'X-Robots-Tag should not be present when no unpublish date is set.');
+    // Also check that there is no meta tag.
+    $this->assertSession()->responseNotContains('unavailable_after:');
 
-    // Set a scheduler unpublish date on the node.
+    // Set an unpublish date on the entity.
     $unpublish_date = strtotime('+1 day');
-    $edit = [
-      'unpublish_on[0][value][date]' => $this->dateFormatter->format($unpublish_date, 'custom', 'Y-m-d'),
-      'unpublish_on[0][value][time]' => $this->dateFormatter->format($unpublish_date, 'custom', 'H:i:s'),
-    ];
-    $this->drupalPostForm('node/' . $published_node->id() . '/edit', $edit, 'Save');
+    $entity->set('unpublish_on', $unpublish_date)->save();
 
-    // The node page should now have an X-Robots-Tag header with an
+    // The entity full page view should now have an X-Robots-Tag header with an
     // unavailable_after-directive and RFC850 date- and time-value.
-    $this->drupalGet('node/' . $published_node->id());
-    $this->assertSession()->responseHeaderEquals('X-Robots-Tag', 'unavailable_after: ' . date(DATE_RFC850, $unpublish_date), 'X-Robots-Tag is present with correct timestamp derived from unpublish_on date.');
+    $this->drupalGet($entity->toUrl());
+    $this->assertSession()->responseHeaderEquals('X-Robots-Tag', 'unavailable_after: ' . date(DATE_RFC850, $unpublish_date));
+
+    // Check that the required meta tag is added to the html head section.
+    $this->assertSession()->responseMatches('~meta name=[\'"]robots[\'"] content=[\'"]unavailable_after: ' . date(DATE_RFC850, $unpublish_date) . '[\'"]~');
+
+    // If the entity type has a summary listing page, check that the entity is
+    // shown but the two tags are not present.
+    if ($this->drupalGet("$entityTypeId") && $this->getSession()->getStatusCode() == '200') {
+      $this->assertSession()->pageTextContains($entity->label());
+      $this->assertNull($this->getSession()->getResponseHeader('X-Robots-Tag'), 'X-Robots-Tag should not be added when entity is not in "full" view mode.');
+      $this->assertSession()->responseNotContains('unavailable_after:');
+    }
   }
 
 }
diff --git a/web/modules/scheduler/tests/src/Functional/SchedulerMultilingualTest.php b/web/modules/scheduler/tests/src/Functional/SchedulerMultilingualTest.php
index 0776927a32293df736b710c4ec2f10662a8e2e69..9a0b876aa21cdb38387494be80773ee9d04de6d3 100644
--- a/web/modules/scheduler/tests/src/Functional/SchedulerMultilingualTest.php
+++ b/web/modules/scheduler/tests/src/Functional/SchedulerMultilingualTest.php
@@ -35,10 +35,10 @@ class SchedulerMultilingualTest extends SchedulerBrowserTestBase {
   /**
    * {@inheritdoc}
    */
-  public function setUp() {
+  protected function setUp(): void {
     parent::setUp();
 
-    // Create a user with the required translation permissions.
+    // Add four extra permissions for the adminUser -
     // 'administer languages' for url admin/config/regional/content-language.
     // 'administer content translation' to show the list of content fields at
     // url admin/config/regional/content-language.
@@ -46,21 +46,13 @@ public function setUp() {
     // url node/*/translations.
     // 'translate any entity' for the 'add translation' link on the translations
     // page, url node/*/translations/add/.
-    $this->translatorUser = $this->drupalCreateUser([
+    $this->addPermissionsToUser($this->adminUser, [
       'administer languages',
       'administer content translation',
       'create content translations',
       'translate any entity',
     ]);
-
-    // Get the additional role already assigned to the scheduler admin user
-    // created in SchedulerBrowserTestBase and add this role to the translator
-    // user, to avoid switching between users throughout this test.
-    $admin_roles = $this->adminUser->getRoles();
-    // Key 0 is 'authenticated' role. Key 1 is the first real role.
-    $this->translatorUser->addRole($admin_roles[1]);
-    $this->translatorUser->save();
-    $this->drupalLogin($this->translatorUser);
+    $this->drupalLogin($this->adminUser);
 
     // Allow scheduler dates in the past to be published on next cron run.
     $this->nodetype->setThirdPartySetting('scheduler', 'publish_past_date', 'schedule')->save();
@@ -138,23 +130,23 @@ public function testPublishingTranslations($publish_on_translatable, $unpublish_
     // The submit shows the updated values, so no need for second get.
     $this->submitForm($settings, 'Save configuration');
 
-    $early_return = FALSE;
     if ($publish_on_translatable <> $status_translatable) {
       // Check for validation form error on status and publish_on.
       $this->assertSession()->elementExists('xpath', '//input[@id = "edit-settings-node-' . $this->type . '-fields-publish-on" and contains(@class, "error")]');
       $this->assertSession()->elementExists('xpath', '//input[@id = "edit-settings-node-' . $this->type . '-fields-status" and contains(@class, "error")]');
-      $early_return = TRUE;
     }
     if ($unpublish_on_translatable <> $status_translatable) {
       // Check for validation form error on status and unpublish_on.
       $this->assertSession()->elementExists('xpath', '//input[@id = "edit-settings-node-' . $this->type . '-fields-unpublish-on" and contains(@class, "error")]');
       $this->assertSession()->elementExists('xpath', '//input[@id = "edit-settings-node-' . $this->type . '-fields-status" and contains(@class, "error")]');
-      $early_return = TRUE;
     }
-    if ($early_return) {
+
+    if (empty($expected_status_values_before)) {
+      // The test data on this run was to verify the validation messages above.
       // The rest of the test is meaningless so skip it and move to the next.
       return;
     }
+    $this->assertSession()->pageTextContains('Settings successfully updated.');
 
     // Create a node in the 'original' language, before any translations. It is
     // unpublished with no scheduled date.
@@ -177,7 +169,7 @@ public function testPublishingTranslations($publish_on_translatable, $unpublish_
     ];
     $this->submitForm($edit, 'Save');
 
-    // Create second translation, for publishing and unpublising in the future.
+    // Create second translation, for publishing and unpublishing in the future.
     $this->drupalGet('node/' . $nid . '/translations/add/' . $this->languages[0]['code'] . '/' . $this->languages[2]['code']);
     $edit = [
       'title[0][value]' => $this->languages[2]['name'] . '(2) - Publish in the future',
@@ -188,21 +180,25 @@ public function testPublishingTranslations($publish_on_translatable, $unpublish_
     ];
     $this->submitForm($edit, 'Save');
 
-    // Reset the cache, reload the node, and check if the dates of translation
-    // 3 have been synchronized to the other translations, or not, as required.
+    // Reset the cache, reload the node, and check if the dates of translation 2
+    // have been synchronized onto the other translations, or not, as required.
     $this->nodeStorage->resetCache([$nid]);
     $node = $this->nodeStorage->load($nid);
     $translation1 = $node->getTranslation($this->languages[1]['code']);
     $translation2 = $node->getTranslation($this->languages[2]['code']);
     if ($publish_on_translatable) {
-      $this->assertNotEquals($translation2->publish_on->value, $node->publish_on->value, 'Node publish_on');
-      $this->assertNotEquals($translation2->unpublish_on->value, $node->unpublish_on->value, 'Node unpublish_on');
+      $this->assertNotEquals($translation2->publish_on->value, $node->publish_on->value, 'The original translation publish_on should not be synchronized');
+      $this->assertNotEquals($translation2->unpublish_on->value, $node->unpublish_on->value, 'The original translation unpublish_on should not be synchronized');
+      $this->assertNotEquals($translation2->publish_on->value, $translation1->publish_on->value, 'Translation1 publish_on should not be synchronized');
+      $this->assertNotEquals($translation2->unpublish_on->value, $translation1->unpublish_on->value, 'Translation1 unpublish_on should not be synchronized');
     }
     else {
-      $this->assertEquals($translation2->publish_on->value, $node->publish_on->value, 'Node publish_on');
-      $this->assertEquals($translation2->unpublish_on->value, $node->unpublish_on->value, 'Node unpublish_on');
-      $this->assertEquals($translation2->publish_on->value, $translation1->publish_on->value, 'Translation 1 publish_on');
-      $this->assertEquals($translation2->unpublish_on->value, $translation1->unpublish_on->value, 'Translation 1 unpublish_on');
+      $this->assertEquals($translation2->publish_on->value, $node->publish_on->value, 'The original translation publish_on should be synchronized');
+      $this->assertEquals($translation2->unpublish_on->value, $node->unpublish_on->value, 'The original translation unpublish_on should be synchronized');
+      $this->assertEquals($translation2->isPublished(), $node->isPublished(), 'The original translation status should be synchronized');
+      $this->assertEquals($translation2->publish_on->value, $translation1->publish_on->value, 'Translation1 publish_on should be synchronized');
+      $this->assertEquals($translation2->unpublish_on->value, $translation1->unpublish_on->value, 'Translation1 unpublish_on should be synchronized');
+      $this->assertEquals($translation2->isPublished(), $translation1->isPublished(), 'Translation1 status should be synchronized');
     }
 
     // Create the third translation, to be published in the past.
@@ -214,30 +210,33 @@ public function testPublishingTranslations($publish_on_translatable, $unpublish_
     ];
     $this->submitForm($edit, 'Save');
 
-    // Reset the cache, reload the node, and check if the dates of translation
-    // 3 have been synchronized to the other translations, or not, as required.
+    // Reset the cache, reload the node, and check if the dates of translation 3
+    // have been synchronized onto the other translations, or not, as required.
     $this->nodeStorage->resetCache([$nid]);
     $node = $this->nodeStorage->load($nid);
     $translation1 = $node->getTranslation($this->languages[1]['code']);
     $translation2 = $node->getTranslation($this->languages[2]['code']);
     $translation3 = $node->getTranslation($this->languages[3]['code']);
     if ($publish_on_translatable) {
-      $this->assertNotEquals($translation3->publish_on->value, $translation2->publish_on->value, 'Node publish_on');
-      $this->assertNotEquals($translation3->unpublish_on->value, $translation2->unpublish_on->value, 'Node unpublish_on');
+      $this->assertNotEquals($translation3->publish_on->value, $translation2->publish_on->value, 'The original translation publish_on should not be synchronized');
+      $this->assertNotEquals($translation3->unpublish_on->value, $translation2->unpublish_on->value, 'The original translation unpublish_on should not be synchronized');
     }
     else {
       // The scheduer dates should be synchronized across all translations.
-      $this->assertEquals($translation3->publish_on->value, $node->publish_on->value, 'Node publish_on');
-      $this->assertEquals($translation3->unpublish_on->value, $node->unpublish_on->value, 'Node unpublish_on');
-      $this->assertEquals($translation3->publish_on->value, $translation1->publish_on->value, 'Translation 1 publish_on');
-      $this->assertEquals($translation3->unpublish_on->value, $translation1->unpublish_on->value, 'Translation 1 unpublish_on');
-      $this->assertEquals($translation3->publish_on->value, $translation2->publish_on->value, 'Translation 2 publish_on');
-      $this->assertEquals($translation3->unpublish_on->value, $translation2->unpublish_on->value, 'Translation 2 unpublish_on');
+      $this->assertEquals($translation3->publish_on->value, $node->publish_on->value, 'The original translation publish_on should be synchronized');
+      $this->assertEquals($translation3->unpublish_on->value, $node->unpublish_on->value, 'The original translation unpublish_on should be synchronized');
+      $this->assertEquals($translation3->isPublished(), $node->isPublished(), 'The original translation status should be synchronized');
+      $this->assertEquals($translation3->publish_on->value, $translation1->publish_on->value, 'Translation1 publish_on should be synchronized');
+      $this->assertEquals($translation3->unpublish_on->value, $translation1->unpublish_on->value, 'Translation1 unpublish_on should be synchronized');
+      $this->assertEquals($translation3->isPublished(), $translation1->isPublished(), 'Translation1 status should be synchronized');
+      $this->assertEquals($translation3->publish_on->value, $translation2->publish_on->value, 'Translation2 publish_on should be synchronized');
+      $this->assertEquals($translation3->unpublish_on->value, $translation2->unpublish_on->value, 'Translation2 unpublish_on should be synchronized');
+      $this->assertEquals($translation3->isPublished(), $translation2->isPublished(), 'Translation2 status should be synchronized');
     }
 
     // For info only.
     $this->drupalGet($this->languages[0]['code'] . '/node/' . $nid . '/translations');
-    $this->drupalGet('admin/content/scheduled');
+    $this->drupalGet($this->adminUrl('scheduled', 'node'));
 
     // Check that the status of all four pieces of content before running cron
     // match the expected values.
@@ -248,8 +247,8 @@ public function testPublishingTranslations($publish_on_translatable, $unpublish_
     $this->checkStatus($nid, 'After cron', $expected_status_values_after);
 
     // For info only.
-    $this->drupalGet('admin/content/scheduled');
-    $this->drupalGet('admin/content');
+    $this->drupalGet($this->adminUrl('scheduled', 'node'));
+    $this->drupalGet($this->adminUrl('collection', 'node'));
     $this->drupalGet('admin/reports/dblog');
     $this->drupalGet($this->languages[0]['code'] . '/node/' . $nid . '/translations');
   }
@@ -257,9 +256,11 @@ public function testPublishingTranslations($publish_on_translatable, $unpublish_
   /**
    * Provides data for testPublishingTranslations().
    *
-   * Case 1 when the date is translatable and can differ between translations.
-   * Case 2 when the date is not translatable and the behavior should be
+   * Case 1 when the dates are translatable and can differ between translations.
+   * Case 2 when the dates are not translatable and the behavior should be
    *   consistent over all translations.
+   * Case 3 - 8 when there are differences in the settings and the validation
+   *   should prevent the form being saved.
    *
    * @return array
    *   The test data. Each array element has the format:
@@ -270,8 +271,8 @@ public function testPublishingTranslations($publish_on_translatable, $unpublish_
    *   Expected status of four translations after cron
    */
   public function dataPublishingTranslations() {
-    // The key text relates to which fields are translatable.
-    return [
+    // The key text is just for info, and shows which fields are translatable.
+    $data = [
       'all fields' => [TRUE, TRUE, TRUE,
         [FALSE, TRUE, FALSE, FALSE],
         [FALSE, TRUE, FALSE, TRUE],
@@ -288,6 +289,7 @@ public function dataPublishingTranslations() {
       'publish_on and status' => [TRUE, FALSE, TRUE, [], []],
       'unpublish_on and status' => [FALSE, TRUE, TRUE, [], []],
     ];
+    return $data;
   }
 
 }
diff --git a/web/modules/scheduler/tests/src/Functional/SchedulerNodeAccessTest.php b/web/modules/scheduler/tests/src/Functional/SchedulerNodeAccessTest.php
deleted file mode 100644
index 517540c87283e01863eb129152136fd6eceec522..0000000000000000000000000000000000000000
--- a/web/modules/scheduler/tests/src/Functional/SchedulerNodeAccessTest.php
+++ /dev/null
@@ -1,90 +0,0 @@
-<?php
-
-namespace Drupal\Tests\scheduler\Functional;
-
-/**
- * Tests that Scheduler cron has full access to the scheduled nodes.
- *
- * This test uses an additional test module 'scheduler_access_test' which uses
- * a custom node access definition to deny viewing of all nodes.
- *
- * @group scheduler
- */
-class SchedulerNodeAccessTest extends SchedulerBrowserTestBase {
-
-  /**
-   * Additional modules required.
-   *
-   * @var array
-   */
-  protected static $modules = ['scheduler_access_test'];
-
-  /**
-   * {@inheritdoc}
-   */
-  public function setUp() {
-    parent::setUp();
-    // scheduler_access_test_install() sets node_access_needs_rebuild(TRUE) and
-    // this works when testing the module interactively, but during simpletest
-    // the node access table is not rebuilt. Hence do that here explicitly here.
-    node_access_rebuild();
-  }
-
-  /**
-   * Tests Scheduler cron functionality when access to the nodes is denied.
-   */
-  public function testNodeAccess() {
-
-    // Create data to test publishing then unpublishing via loop.
-    // @TODO Convert this test to use a @dataProvider function instead of this
-    // array and the loop.
-    $test_data = [
-      'publish_on' => [
-        'status' => FALSE,
-        'before' => 'unpublished',
-        'after' => 'published',
-      ],
-      'unpublish_on' => [
-        'status' => TRUE,
-        'before' => 'published',
-        'after' => 'unpublished',
-      ],
-    ];
-
-    foreach ($test_data as $field => $data) {
-      // Create a node with the necessary scheduler date.
-      $settings = [
-        'type' => $this->type,
-        'status' => $data['status'],
-        'title' => 'Test node to be ' . $data['after'],
-        $field => $this->requestTime + 1,
-      ];
-      $node = $this->drupalCreateNode($settings);
-      $this->drupalGet('node/' . $node->id());
-      // Before running cron, viewing the node should give "403 Not Authorized".
-      $this->assertSession()->statusCodeEquals(403);
-
-      // Delay so that the date entered is now in the past, then run cron.
-      sleep(2);
-      $this->cronRun();
-
-      // Reload the node.
-      $this->nodeStorage->resetCache([$node->id()]);
-      $node = $this->nodeStorage->load($node->id());
-      // Check that the node has been published or unpublished as required.
-      $this->assertTrue($node->isPublished() === !$data['status'], 'Scheduler has ' . $data['after'] . ' the node via cron.');
-
-      // Check the node is still not viewable.
-      $this->drupalGet('node/' . $node->id());
-      // After cron, viewing the node should still give "403 Not Authorized".
-      $this->assertSession()->statusCodeEquals(403);
-    }
-
-    // Log in and assert that the two dblog messages are shown.
-    $this->drupalLogin($this->adminUser);
-    $this->drupalGet('admin/reports/dblog');
-    $this->assertSession()->pageTextContains('scheduled publishing');
-    $this->assertSession()->pageTextContains('scheduled unpublishing');
-  }
-
-}
diff --git a/web/modules/scheduler/tests/src/Functional/SchedulerNonEnabledTypeTest.php b/web/modules/scheduler/tests/src/Functional/SchedulerNonEnabledTypeTest.php
index 8b11dcacb3d9b2f152ccc6d517cf5b65d64b872f..18ad8140e0b02575a2044f2915f2c194a21fc576 100644
--- a/web/modules/scheduler/tests/src/Functional/SchedulerNonEnabledTypeTest.php
+++ b/web/modules/scheduler/tests/src/Functional/SchedulerNonEnabledTypeTest.php
@@ -3,138 +3,195 @@
 namespace Drupal\Tests\scheduler\Functional;
 
 /**
- * Tests a content type which is not enabled for scheduling.
+ * Tests entity types which are not enabled for scheduling.
  *
  * @group scheduler
  */
 class SchedulerNonEnabledTypeTest extends SchedulerBrowserTestBase {
 
   /**
-   * Helper function for testNonEnabledNodeType().
+   * Additional core module field_ui is required for entity form display page.
    *
-   * This function is called four times.
-   * Check that the date fields are correctly shown or not shown in /node/add.
-   * Check that a node is not processed if it is not enabled for the action.
+   * @var array
    */
-  protected function checkNonEnabledTypes($publishing_enabled, $unpublishing_enabled, $run_number) {
-
-    // Create title to show what combinations are being tested. Store base info
-    // then add secondary details.
-    $details = [
-      1 => 'by default',
-      2 => 'after disabling both settings',
-      3 => 'after enabling publishing only',
-      4 => 'after enabling unpublishing only',
-    ];
-    $info = $run_number >= 2 ?
-      'Publishing ' . ($publishing_enabled ? 'enabled' : 'not enabled')
-      . ', Unpublishing ' . ($unpublishing_enabled ? 'enabled' : 'not enabled') . ', ' . $details[$run_number]
-      : $details[$run_number];
-
-    // Check that the field(s) are displayed only for the correct settings.
-    $title = $info . ' (' . $run_number . 'a)';
-    $this->drupalGet('node/add/' . $this->nonSchedulerNodeType->id());
+  protected static $modules = ['field_ui'];
+
+  /**
+   * Tests the publish_enable and unpublish_enable entity type settings.
+   *
+   * @dataProvider dataNonEnabledScenarios()
+   */
+  public function testNonEnabledType($id, $entityTypeId, $bundle, $description, $publishing_enabled, $unpublishing_enabled) {
+    // Give adminUser the permissions to use the field_ui 'manage form display'
+    // tab for the entity type being tested.
+    $this->addPermissionsToUser($this->adminUser, ["administer {$entityTypeId} form display"]);
+    $this->drupalLogin($this->adminUser);
+    $entityType = $this->entityTypeObject($entityTypeId, $bundle);
+    $storage = $this->entityStorageObject($entityTypeId);
+    $titleField = $this->titleField($entityTypeId);
+
+    // The 'default' case specifically checks the behavior of the unchanged
+    // settings, so only change these when not running the default test.
+    if ($description != 'Default') {
+      // Set the enabled checkboxes via entity type admin form. This will also
+      // partially test the form display adjustments.
+      $this->drupalGet($this->adminUrl('bundle_edit', $entityTypeId, $bundle));
+      $edit = [
+        'scheduler_publish_enable' => $publishing_enabled,
+        'scheduler_unpublish_enable' => $unpublishing_enabled,
+      ];
+      $this->submitForm($edit, 'Save');
+
+      // Show the form display page for info.
+      $this->drupalGet($this->adminUrl('bundle_form_display', $entityTypeId, $bundle));
+
+      // ThirdPartySettings are set correctly by saving the entity type form,
+      // however this does not get replicated back to $entityType here (is this
+      // a bug is core test traits somewhere?). Thwerefore resort to setting the
+      // values here too.
+      $entityType->setThirdPartySetting('scheduler', 'publish_enable', $publishing_enabled)
+        ->setThirdPartySetting('scheduler', 'unpublish_enable', $unpublishing_enabled)
+        ->save();
+    }
+
+    // When publishing and/or unpublishing are not enabled but the 'required'
+    // setting remains on, the entity must be able to be saved without a date.
+    $entityType->setThirdPartySetting('scheduler', 'publish_required', !$publishing_enabled)->save();
+    $entityType->setThirdPartySetting('scheduler', 'unpublish_required', !$unpublishing_enabled)->save();
+
+    // Allow dates in the past to be valid on saving the entity, to simplify the
+    // testing process.
+    $entityType->setThirdPartySetting('scheduler', 'publish_past_date', 'schedule')->save();
+
+    // Create a new entity via the add/bundle url, and check that the correct
+    // fields are displayed on the form depending on the enabled settings.
+    $this->drupalGet($this->entityAddUrl($entityTypeId, $bundle));
     if ($publishing_enabled) {
-      $this->assertFieldByName('publish_on[0][value][date]', NULL, 'The Publish-on field is shown - ' . $title);
+      $this->assertSession()->fieldExists('publish_on[0][value][date]');
     }
     else {
-      $this->assertNoFieldByName('publish_on[0][value][date]', NULL, 'The Publish-on field is not shown - ' . $title);
+      $this->assertSession()->fieldNotExists('publish_on[0][value][date]');
     }
 
     if ($unpublishing_enabled) {
-      $this->assertFieldByName('unpublish_on[0][value][date]', NULL, 'The Unpublish-on field is shown - ' . $title);
+      $this->assertSession()->fieldExists('unpublish_on[0][value][date]');
     }
     else {
-      $this->assertNoFieldByName('unpublish_on[0][value][date]', NULL, 'The Unpublish-on field is not shown - ' . $title);
+      $this->assertSession()->fieldNotExists('unpublish_on[0][value][date]');
     }
 
-    // Create an unpublished node with a publishing date, which mimics what
-    // could be done by a third-party module, or a by-product of the node type
+    // Fill in the title field and check that the entity can be saved OK.
+    $title = $id . 'a - ' . $description;
+    $this->submitForm(["{$titleField}[0][value]" => $title], 'Save');
+    $this->assertSession()->pageTextMatches($this->entitySavedMessage($entityTypeId, $title));
+
+    // Create an unpublished entity with a publishing date, which mimics what
+    // could be done by a third-party module, or a by-product of the entity type
     // being enabled for publishing then being disabled before it got published.
-    $title = $info . ' (' . $run_number . 'b)';
-    $edit = [
-      'title' => $title,
-      'status' => 0,
-      'type' => $this->nonSchedulerNodeType->id(),
-      'publish_on' => $this->requestTime - 2,
+    $title = $id . 'b - ' . $description;
+    $values = [
+      "$titleField" => $title,
+      'status' => FALSE,
+      'publish_on' => $this->requestTime - 120,
     ];
-    $node = $this->drupalCreateNode($edit);
+    $entity = $this->createEntity($entityTypeId, $bundle, $values);
+
+    // Check that the entity can be edited and saved OK.
+    $this->drupalGet($entity->toUrl('edit-form'));
+    $this->submitForm([], 'Save');
+    $this->assertSession()->pageTextMatches($this->entitySavedMessage($entityTypeId, $title));
 
     // Run cron and display the dblog.
     $this->cronRun();
     $this->drupalGet('admin/reports/dblog');
 
-    // Reload the node.
-    $this->nodeStorage->resetCache([$node->id()]);
-    $node = $this->nodeStorage->load($node->id());
-    // Check if the node has been published or remains unpublished.
+    // Reload the entity.
+    $storage->resetCache([$entity->id()]);
+    $entity = $storage->load($entity->id());
+    // Check if the entity has been published or remains unpublished.
     if ($publishing_enabled) {
-      $this->assertTrue($node->isPublished(), 'The unpublished node has been published - ' . $title);
+      $this->assertTrue($entity->isPublished(), "The unpublished entity '$title' should now be published");
     }
     else {
-      $this->assertFalse($node->isPublished(), 'The unpublished node remains unpublished - ' . $title);
+      $this->assertFalse($entity->isPublished(), "The unpublished entity '$title' should remain unpublished");
     }
-    // Delete the node to avoid affecting subsequent tests.
-    $node->delete();
-
-    // Do the same for unpublishing.
-    $title = $info . ' (' . $run_number . 'c)';
-    $edit = [
-      'title' => $title,
-      'status' => 1,
-      'type' => $this->nonSchedulerNodeType->id(),
-      'unpublish_on' => $this->requestTime - 1,
-    ];
-    $node = $this->drupalCreateNode($edit);
 
-    // Run cron and display the dblog.
+    // Do the same for unpublishing - create a published entity with an
+    // unpublishing date in the future, to be valid for editing and saving.
+    $title = $id . 'c - ' . $description;
+    $values = [
+      "$titleField" => $title,
+      'status' => TRUE,
+      'unpublish_on' => $this->requestTime + 180,
+    ];
+    $entity = $this->createEntity($entityTypeId, $bundle, $values);
+
+    // Check that the entity can be edited and saved.
+    $this->drupalGet($entity->toUrl('edit-form'));
+    $this->submitForm([], 'Save');
+    $this->assertSession()->pageTextMatches($this->entitySavedMessage($entityTypeId, $title));
+
+    // Create a published entity with a date in the past, then run cron.
+    $title = $id . 'd - ' . $description;
+    $values = [
+      "$titleField" => $title,
+      'status' => TRUE,
+      'unpublish_on' => $this->requestTime - 120,
+    ];
+    $entity = $this->createEntity($entityTypeId, $bundle, $values);
     $this->cronRun();
     $this->drupalGet('admin/reports/dblog');
 
-    // Reload the node.
-    $this->nodeStorage->resetCache([$node->id()]);
-    $node = $this->nodeStorage->load($node->id());
-    // Check if the node has been unpublished or remains published.
+    // Reload the entity.
+    $storage->resetCache([$entity->id()]);
+    $entity = $storage->load($entity->id());
+    // Check if the entity has been unpublished or remains published.
     if ($unpublishing_enabled) {
-      $this->assertFalse($node->isPublished(), 'The published node has been unpublished - ' . $title);
+      $this->assertFalse($entity->isPublished(), "The published entity '$title' should now be unpublished");
     }
     else {
-      $this->assertTrue($node->isPublished(), 'The published node remains published - ' . $title);
+      $this->assertTrue($entity->isPublished(), "The published entity '$title' should remain published");
     }
-    // Delete the node to avoid affecting subsequent tests.
-    $node->delete();
+
+    // Display the full content list and the scheduled list. Calls to these
+    // pages are for information and debug only.
+    $this->drupalGet($this->adminUrl('collection', $entityTypeId, $bundle));
+    $this->drupalGet($this->adminUrl('scheduled', $entityTypeId, $bundle));
   }
 
   /**
-   * Tests that a non-enabled node type cannot be scheduled.
+   * Provides data for testNonEnabledType().
    *
-   * The case when both options are enabled is covered in the main tests. Here
-   * we need to check each of the other combinations, to ensure that the
-   * settings work independently.
+   * @return array
+   *   Each item in the test data array has the follow elements:
+   *     id                     - (int) a sequential id for use in titles
+   *     entityTypeId           - (string) 'node', 'media' or 'commerce_product'
+   *     bundle                 - (string) the bundle which is not enabled
+   *     description            - (string) describing the scenario being checked
+   *     publishing_enabled     - (bool) whether publishing is enabled
+   *     unpublishing_enabled   - (bool) whether unpublishing is enabled
    */
-  public function testNonEnabledNodeType() {
-    // Log in.
-    $this->drupalLogin($this->adminUser);
+  public function dataNonEnabledScenarios() {
+    $data = [];
+    foreach ($this->dataNonEnabledTypes() as $key => $values) {
+      $entityTypeId = $values[0];
+      $bundle = $values[1];
+      // By default check that the scheduler date fields are not displayed.
+      $data["$key-1"] = [1, $entityTypeId, $bundle, 'Default', FALSE, FALSE];
+
+      // Explicitly disable this content type for both settings.
+      $data["$key-2"] = [2, $entityTypeId, $bundle, 'Disabling both settings', FALSE, FALSE];
+
+      // Turn on scheduled publishing only.
+      $data["$key-3"] = [3, $entityTypeId, $bundle, 'Enabling publishing only', TRUE, FALSE];
 
-    // 1. By default check that the scheduler date fields are not displayed.
-    $this->checkNonEnabledTypes(FALSE, FALSE, 1);
-
-    // 2. Explicitly disable this content type for both settings and test again.
-    $this->nonSchedulerNodeType->setThirdPartySetting('scheduler', 'publish_enable', FALSE)
-      ->setThirdPartySetting('scheduler', 'unpublish_enable', FALSE)
-      ->save();
-    $this->checkNonEnabledTypes(FALSE, FALSE, 2);
-
-    // 3. Turn on scheduled publishing only and test again.
-    $this->nonSchedulerNodeType->setThirdPartySetting('scheduler', 'publish_enable', TRUE)
-      ->save();
-    $this->checkNonEnabledTypes(TRUE, FALSE, 3);
-
-    // 4. Turn on scheduled unpublishing only and test again.
-    $this->nonSchedulerNodeType->setThirdPartySetting('scheduler', 'publish_enable', FALSE)
-      ->setThirdPartySetting('scheduler', 'unpublish_enable', TRUE)
-      ->save();
-    $this->checkNonEnabledTypes(FALSE, TRUE, 4);
+      // Turn on scheduled unpublishing only.
+      $data["$key-4"] = [4, $entityTypeId, $bundle, 'Enabling unpublishing only', FALSE, TRUE];
+
+      // For completeness turn on both scheduled publishing and unpublishing.
+      $data["$key-5"] = [5, $entityTypeId, $bundle, 'Enabling both publishing and unpublishing', TRUE, TRUE];
+    }
+    return $data;
   }
 
 }
diff --git a/web/modules/scheduler/tests/src/Functional/SchedulerPastDatesTest.php b/web/modules/scheduler/tests/src/Functional/SchedulerPastDatesTest.php
index 6440ac77d7a5523cfc866bd35ae5eeea2c75e6ae..ef2a419d9104b069974baebeaffa1f127a533975 100644
--- a/web/modules/scheduler/tests/src/Functional/SchedulerPastDatesTest.php
+++ b/web/modules/scheduler/tests/src/Functional/SchedulerPastDatesTest.php
@@ -11,112 +11,132 @@ class SchedulerPastDatesTest extends SchedulerBrowserTestBase {
 
   /**
    * Test the different options for past publication dates.
+   *
+   * @dataProvider dataStandardEntityTypes()
    */
-  public function testSchedulerPastDates() {
+  public function testSchedulerPastDates($entityTypeId, $bundle) {
+    $storage = $this->entityStorageObject($entityTypeId);
+    $titleField = $this->titleField($entityTypeId);
+    $entityType = $this->entityTypeObject($entityTypeId, $bundle);
+
     // Log in.
     $this->drupalLogin($this->schedulerUser);
 
-    // Create an unpublished page node.
-    /** @var NodeInterface $node */
-    $node = $this->drupalCreateNode(['type' => $this->type, 'status' => FALSE]);
-    $created_time = $node->getCreatedTime();
-
-    // Test the default behavior: an error message should be shown when the user
-    // enters a publication date that is in the past.
+    // Create data for use in edits.
+    $title = 'Publish in the past ' . $this->randomString(10);
     $edit = [
-      'title[0][value]' => 'Past ' . $this->randomString(10),
+      "{$titleField}[0][value]" => $title,
       'publish_on[0][value][date]' => $this->dateFormatter->format(strtotime('-1 day', $this->requestTime), 'custom', 'Y-m-d'),
       'publish_on[0][value][time]' => $this->dateFormatter->format(strtotime('-1 day', $this->requestTime), 'custom', 'H:i:s'),
     ];
-    $this->drupalPostForm('node/' . $node->id() . '/edit', $edit, 'Save');
+
+    // Create an unpublished entity.
+    $entity = $this->createEntity($entityTypeId, $bundle, ['status' => FALSE]);
+    // Some entities do not have a 'created' date and if that is the case we
+    // skip the specific parts of this test that relate to this.
+    if ($check_created_time = method_exists($entity, 'getCreatedTime')) {
+      $created_time = $entity->getCreatedTime();
+    }
+
+    // Test the default behavior: an error message should be shown when the user
+    // enters a publication date that is in the past.
+    $this->drupalGet($entity->toUrl('edit-form'));
+    $this->submitForm($edit, 'Save');
     $this->assertSession()->pageTextContains("The 'publish on' date must be in the future");
 
     // Test the 'error' behavior explicitly.
-    $this->nodetype->setThirdPartySetting('scheduler', 'publish_past_date', 'error')->save();
-    $this->drupalPostForm('node/' . $node->id() . '/edit', $edit, 'Save');
+    $entityType->setThirdPartySetting('scheduler', 'publish_past_date', 'error')->save();
+    $this->drupalGet($entity->toUrl('edit-form'));
+    $this->submitForm($edit, 'Save');
     $this->assertSession()->pageTextContains("The 'publish on' date must be in the future");
 
-    // Test the 'publish' behavior: the node should be published immediately.
-    $this->nodetype->setThirdPartySetting('scheduler', 'publish_past_date', 'publish')->save();
-    $this->drupalPostForm('node/' . $node->id() . '/edit', $edit, 'Save');
+    // Test the 'publish' behavior: the entity should be published immediately.
+    $entityType->setThirdPartySetting('scheduler', 'publish_past_date', 'publish')->save();
+    $this->drupalGet($entity->toUrl('edit-form'));
+    $this->submitForm($edit, 'Save');
+
     // Check that no error message is shown when the publication date is in the
     // past and the "publish" behavior is chosen.
     $this->assertSession()->pageTextNotContains("The 'publish on' date must be in the future");
-    $this->assertSession()->pageTextContains(sprintf('%s %s has been updated.', $this->typeName, $edit['title[0][value]']));
-
-    // Reload the node.
-    $this->nodeStorage->resetCache([$node->id()]);
-    $node = $this->nodeStorage->load($node->id());
-
-    // Check that the node is published and has the expected timestamps.
-    $this->assertTrue($node->isPublished(), 'The node has been published immediately when the publication date is in the past and the "publish" behavior is chosen.');
-    $this->assertNull($node->publish_on->value, 'The node publish_on date has been removed after publishing when the "publish" behavior is chosen.');
-    $this->assertEquals($node->getChangedTime(), strtotime('-1 day', $this->requestTime), 'The changed time of the node has been updated to the publish_on time when published immediately.');
-    $this->assertEquals($node->getCreatedTime(), $created_time, 'The created time of the node has not been changed when the "publish" behavior is chosen.');
-
-    // Test the 'schedule' behavior: the node should be unpublished and become
-    // published on the next cron run. Use a new unpublished node.
-    $this->nodetype->setThirdPartySetting('scheduler', 'publish_past_date', 'schedule')->save();
-    $node = $this->drupalCreateNode(['type' => $this->type, 'status' => FALSE]);
-    $created_time = $node->getCreatedTime();
-    $this->drupalPostForm('node/' . $node->id() . '/edit', $edit, 'Save');
-    // Check that no error is shown when the publish_on date is in the past.
+    $this->assertSession()->pageTextMatches($this->entitySavedMessage($entityTypeId, $title));
+
+    // Reload the entity.
+    $storage->resetCache([$entity->id()]);
+    $entity = $storage->load($entity->id());
+
+    // Check that the entity is published and has the expected timestamps.
+    $this->assertTrue($entity->isPublished(), 'The entity has been published immediately when the publication date is in the past and the "publish" behavior is chosen.');
+    $this->assertNull($entity->publish_on->value, 'The entity publish_on date has been removed after publishing when the "publish" behavior is chosen.');
+    $this->assertEquals($entity->getChangedTime(), strtotime('-1 day', $this->requestTime), 'The changed time of the entity has been updated to the publish_on time when published immediately.');
+    $check_created_time ? $this->assertEquals($entity->getCreatedTime(), $created_time, 'The created time of the entity has not been changed when the "publish" behavior is chosen.') : NULL;
+
+    // Test the 'schedule' behavior: the entity should be unpublished and become
+    // published on the next cron run. Use a new unpublished entity.
+    $entityType->setThirdPartySetting('scheduler', 'publish_past_date', 'schedule')->save();
+    $entity = $this->createEntity($entityTypeId, $bundle, ['status' => FALSE]);
+    $check_created_time ? $created_time = $entity->getCreatedTime() : NULL;
+
+    // Edit, save and check that no error is shown when the publish_on date is
+    // in the past.
+    $this->drupalGet($entity->toUrl('edit-form'));
+    $this->submitForm($edit, 'Save');
     $this->assertSession()->pageTextNotContains("The 'publish on' date must be in the future");
-    $this->assertSession()->pageTextContains(sprintf('%s is scheduled to be published', $edit['title[0][value]']));
-    $this->assertSession()->pageTextContains(sprintf('%s %s has been updated.', $this->typeName, $edit['title[0][value]']));
+    $this->assertSession()->pageTextContains(sprintf('%s is scheduled to be published', $title));
+    $this->assertSession()->pageTextMatches($this->entitySavedMessage($entityTypeId, $title));
 
-    // Reload the node.
-    $this->nodeStorage->resetCache([$node->id()]);
-    $node = $this->nodeStorage->load($node->id());
+    // Reload the entity.
+    $storage->resetCache([$entity->id()]);
+    $entity = $storage->load($entity->id());
 
-    // Check that the node is unpublished but scheduled correctly.
-    $this->assertFalse($node->isPublished(), 'The node has been unpublished when the publication date is in the past and the "schedule" behavior is chosen.');
-    $this->assertEquals(strtotime('-1 day', $this->requestTime), (int) $node->publish_on->value, 'The node has the correct publish_on date stored.');
+    // Check that the entity is unpublished but scheduled correctly.
+    $this->assertFalse($entity->isPublished(), 'The entity has been unpublished when the publication date is in the past and the "schedule" behavior is chosen.');
+    $this->assertEquals(strtotime('-1 day', $this->requestTime), (int) $entity->publish_on->value, 'The entity has the correct publish_on date stored.');
 
-    // Simulate a cron run and check that the node is published.
+    // Simulate a cron run and check that the entity is published.
     scheduler_cron();
-    $this->nodeStorage->resetCache([$node->id()]);
-    $node = $this->nodeStorage->load($node->id());
-    $this->assertTrue($node->isPublished(), 'The node with publication date in the past and the "schedule" behavior has now been published by cron.');
-    $this->assertEquals($node->getChangedTime(), strtotime('-1 day', $this->requestTime), 'The changed time of the node has been updated to the publish_on time when published via cron.');
-    $this->assertEquals($node->getCreatedTime(), $created_time, 'The created time of the node has not been changed when the "schedule" behavior is chosen.');
+    $storage->resetCache([$entity->id()]);
+    $entity = $storage->load($entity->id());
+    $this->assertTrue($entity->isPublished(), 'The entity with publication date in the past and the "schedule" behavior has now been published by cron.');
+    $this->assertEquals($entity->getChangedTime(), strtotime('-1 day', $this->requestTime), 'The changed time of the entity has been updated to the publish_on time when published via cron.');
+    $check_created_time ? $this->assertEquals($entity->getCreatedTime(), $created_time, 'The created time of the entity has not been changed when the "schedule" behavior is chosen.') : NULL;
 
     // Test the option to alter the creation time if the publishing time is
-    // earlier than the node created time.
-    $this->nodetype->setThirdPartySetting('scheduler', 'publish_past_date_created', TRUE)->save();
-
-    $past_date_options = [
-      'publish' => 'publish',
-      'schedule' => 'schedule',
-    ];
-
-    foreach ($past_date_options as $key => $option) {
-      $this->nodetype->setThirdPartySetting('scheduler', 'publish_past_date', $key)->save();
-
-      // Create a new node, edit and save.
-      $node = $this->drupalCreateNode(['type' => $this->type, 'status' => FALSE]);
-      $this->drupalPostForm('node/' . $node->id() . '/edit', $edit, 'Save');
-
-      if ($option == 'schedule') {
-        scheduler_cron();
+    // earlier than the entity created time.
+    if ($check_created_time) {
+      $entityType->setThirdPartySetting('scheduler', 'publish_past_date_created', TRUE)->save();
+      $past_date_options = [
+        'publish' => 'publish',
+        'schedule' => 'schedule',
+      ];
+      foreach ($past_date_options as $key => $option) {
+        $entityType->setThirdPartySetting('scheduler', 'publish_past_date', $key)->save();
+
+        // Create a new unpublished entity, edit and save.
+        $entity = $this->createEntity($entityTypeId, $bundle, ['status' => FALSE]);
+        $this->drupalGet($entity->toUrl('edit-form'));
+        $this->submitForm($edit, 'Save');
+
+        if ($option == 'schedule') {
+          scheduler_cron();
+        }
+
+        // Reload the entity.
+        $storage->resetCache([$entity->id()]);
+        $entity = $storage->load($entity->id());
+
+        // Check that the created time is altered to match publishing time.
+        $this->assertEquals($entity->getCreatedTime(), strtotime('-1 day', $this->requestTime), sprintf('The created time of the entity has not been changed when the %s option is chosen.', $option));
       }
-
-      // Reload the node.
-      $this->nodeStorage->resetCache([$node->id()]);
-      $node = $this->nodeStorage->load($node->id());
-
-      // Check that the created time has been altered to match publishing time.
-      $this->assertEquals($node->getCreatedTime(), strtotime('-1 day', $this->requestTime), sprintf('The created time of the node has not been changed when the %s option is chosen.', $option));
-
     }
 
     // Check that an Unpublish date in the past fails validation.
     $edit = [
-      'title[0][value]' => 'Unpublish in the past ' . $this->randomString(10),
+      "{$titleField}[0][value]" => 'Unpublish in the past ' . $this->randomString(10),
       'unpublish_on[0][value][date]' => $this->dateFormatter->format($this->requestTime - 3600, 'custom', 'Y-m-d'),
       'unpublish_on[0][value][time]' => $this->dateFormatter->format($this->requestTime - 3600, 'custom', 'H:i:s'),
     ];
-    $this->drupalPostForm('node/add/' . $this->type, $edit, 'Save');
+    $this->drupalGet($this->entityAddUrl($entityTypeId, $bundle));
+    $this->submitForm($edit, 'Save');
     $this->assertSession()->pageTextContains("The 'unpublish on' date must be in the future");
   }
 
diff --git a/web/modules/scheduler/tests/src/Functional/SchedulerPermissionsTest.php b/web/modules/scheduler/tests/src/Functional/SchedulerPermissionsTest.php
index 73874119586609dc52d4aba5e539293d9b7a1049..b3d4bf75491e11a1f2430670c4b2408507dd1a08 100644
--- a/web/modules/scheduler/tests/src/Functional/SchedulerPermissionsTest.php
+++ b/web/modules/scheduler/tests/src/Functional/SchedulerPermissionsTest.php
@@ -3,71 +3,115 @@
 namespace Drupal\Tests\scheduler\Functional;
 
 /**
- * Tests the permissions of the Scheduler module.
+ * Tests some permissions of the Scheduler module.
+ *
+ * These tests check the permissions when adding and editing a scheduler-enabled
+ * node or media entity type. The permission to access the scheduled content
+ * overview and user tab views is covered in SchedulerViewsAccessTest.
  *
  * @group scheduler
  */
 class SchedulerPermissionsTest extends SchedulerBrowserTestBase {
 
   /**
-   * Tests that users without permission do not see the scheduler date fields.
+   * {@inheritdoc}
    */
-  public function testUserPermissionsAdd() {
-    // Create a user who can add the content type but who does not have the
-    // permission to use the scheduler functionality.
-    $this->webUser = $this->drupalCreateUser([
-      'access content',
-      'administer nodes',
+  protected function setUp(): void {
+    parent::setUp();
+
+    // Define a set of permissions which all users get. Then in addition, each
+    // user gets the specific permission to schedule their own entity type.
+    // The permission 'administer nodes' is needed when setting the node status
+    // field on edit. There is no corresponding separate permission for media or
+    // product entity types.
+    $permissions = [
       'create ' . $this->type . ' content',
       'edit own ' . $this->type . ' content',
-      'delete own ' . $this->type . ' content',
-      'view own unpublished content',
-    ]);
-    $this->drupalLogin($this->webUser);
+      'administer nodes',
+      'create ' . $this->mediaTypeName . ' media',
+      'edit own ' . $this->mediaTypeName . ' media',
+      'view own unpublished media',
+      'create ' . $this->productTypeName . ' commerce_product',
+      'update own ' . $this->productTypeName . ' commerce_product',
+      'view own unpublished commerce_product',
+      // 'administer commerce_store' is needed to see and use any store, i.e
+      // cannot add a product without this. Is it a bug?
+      'administer commerce_store',
+      'create terms in ' . $this->vocabularyId,
+      'edit terms in ' . $this->vocabularyId,
+    ];
+
+    // Create a user who can add and edit the standard scheduler-enabled
+    // entities, but only schedule nodes.
+    $this->nodeUser = $this->drupalCreateUser(array_merge($permissions, ['schedule publishing of nodes']));
+    $this->nodeUser->set('name', 'Noddy the Node Editor')->save();
+
+    // Create a user who can add and edit the standard scheduler-enabled
+    // entities, but only schedule media items.
+    $this->mediaUser = $this->drupalCreateUser(array_merge($permissions, ['schedule publishing of media']));
+    $this->mediaUser->set('name', 'Medina the Media Editor')->save();
+
+    // Create a user who can add and edit the standard scheduler-enabled
+    // entities, but only schedule products.
+    $this->commerce_productUser = $this->drupalCreateUser(array_merge($permissions, ['schedule publishing of commerce_product']));
+    $this->commerce_productUser->set('name', 'Proctor the Product Editor')->save();
+
+    // Create a user who can add and edit the standard scheduler-enabled
+    // entities, but only schedule taxonomy terms.
+    $this->taxonomy_termUser = $this->drupalCreateUser(array_merge($permissions, ['schedule publishing of taxonomy_term']));
+    $this->taxonomy_termUser->set('name', 'Taximayne the Taxonomy Editor')->save();
+  }
 
-    // Check that neither of the fields are displayed when creating a node.
-    $this->drupalGet('node/add/' . $this->type);
-    $this->assertNoFieldByName('publish_on[0][value][date]', NULL, 'The Publish-on field is not shown for users who do not have permission to schedule content');
-    $this->assertNoFieldByName('unpublish_on[0][value][date]', NULL, 'The Unpublish-on field is not shown for users who do not have permission to schedule content');
+  /**
+   * Tests that users without permission do not see the scheduler date fields.
+   *
+   * @dataProvider dataPermissionsTest()
+   */
+  public function testUserPermissionsAdd($entityTypeId, $bundle, $user) {
+    $titleField = $this->titleField($entityTypeId);
 
-    // At core 8.4 an enhancement will be committed to change the 'save and ...'
-    // button into a 'save' with a corresponding status checkbox. This test has
-    // to pass at 8.3 but the core change will not be backported. Hence derive
-    // the button text and whether we need a 'status'field.
-    // @see https://www.drupal.org/node/2873108
-    $checkbox = $this->xpath('//input[@type="checkbox" and @id="edit-status-value"]');
+    // Log in with the required user, as specified by the parameter.
+    $this->drupalLogin($this->$user);
 
     // Initially run tests when publishing and unpublishing are not required.
-    $this->nodetype->setThirdPartySetting('scheduler', 'publish_required', FALSE)
+    $this->entityTypeObject($entityTypeId)->setThirdPartySetting('scheduler', 'publish_required', FALSE)
       ->setThirdPartySetting('scheduler', 'unpublish_required', FALSE)
       ->save();
 
-    // Check that a new node can be saved and published.
-    $title = $this->randomString(15);
-    $edit = ['title[0][value]' => $title];
-    if ($checkbox) {
-      $edit['status[value]'] = TRUE;
+    // Check that the fields are displayed as expected when creating an entity.
+    // If the user variable matches the entity type id then that user has
+    // scheduling permission on this type, so the fields should be shown.
+    // Otherwise the fields should not be shown.
+    $add_url = $this->entityAddUrl($entityTypeId, $bundle);
+    $this->drupalGet($add_url);
+    if (strpos($user, $entityTypeId) !== FALSE) {
+      $this->assertSession()->fieldExists('publish_on[0][value][date]');
+      $this->assertSession()->fieldExists('unpublish_on[0][value][date]');
     }
-    $this->drupalPostForm('node/add/' . $this->type, $edit, $checkbox ? 'Save' : 'Save and publish');
-    $this->assertSession()->pageTextContains(sprintf('%s %s has been created.', $this->typeName, $title));
-    $this->assertTrue($this->drupalGetNodeByTitle($title)->isPublished(), 'The new node is published');
-
-    // Check that a new node can be saved as unpublished.
-    $title = $this->randomString(15);
-    $edit = ['title[0][value]' => $title];
-    if ($checkbox) {
-      $edit['status[value]'] = FALSE;
+    else {
+      $this->assertSession()->fieldNotExists('publish_on[0][value][date]');
+      $this->assertSession()->fieldNotExists('unpublish_on[0][value][date]');
     }
-    $this->drupalPostForm('node/add/' . $this->type, $edit, $checkbox ? 'Save' : 'Save as unpublished');
-    $this->assertSession()->pageTextContains(sprintf('%s %s has been created.', $this->typeName, $title));
-    $this->assertFalse($this->drupalGetNodeByTitle($title)->isPublished(), 'The new node is unpublished');
 
-    // Set publishing and unpublishing to required, to make it a stronger test.
-    $this->nodetype->setThirdPartySetting('scheduler', 'publish_required', TRUE)
-      ->setThirdPartySetting('scheduler', 'unpublish_required', TRUE)
-      ->save();
+    // Check that the new entity can be saved and published.
+    $title = 'Published - ' . $this->randomString(15);
+    $edit = ["{$titleField}[0][value]" => $title, 'status[value]' => TRUE];
+    $this->submitForm($edit, 'Save');
+    $this->assertSession()->pageTextMatches($this->entitySavedMessage($entityTypeId, $title));
+    $this->assertNotEmpty($entity = $this->getEntityByTitle($entityTypeId, $title), sprintf('The new %s with title "%s" was created sucessfully.', $entityTypeId, $title));
+    $this->assertTrue($entity->isPublished(), 'The new entity is published');
+
+    // Check that a new entity can be saved as unpublished.
+    $title = 'Unpublished - ' . $this->randomString(15);
+    $edit = ["{$titleField}[0][value]" => $title, 'status[value]' => FALSE];
+    $this->drupalGet($add_url);
+    $this->submitForm($edit, 'Save');
+    $this->assertSession()->pageTextMatches($this->entitySavedMessage($entityTypeId, $title));
+    $this->assertNotEmpty($entity = $this->getEntityByTitle($entityTypeId, $title), sprintf('The new %s with title "%s" was created sucessfully.', $entityTypeId, $title));
+    $this->assertFalse($entity->isPublished(), 'The new entity is unpublished');
 
-    // @TODO Add tests when scheduled publishing and unpublishing are required.
+    // Set publishing and unpublishing to required, to make it a stronger test.
+    // @todo Add tests when scheduled publishing and unpublishing are required.
     // Cannot be done until we make a decision on what 'required'  means.
     // @see https://www.drupal.org/node/2707411
     // "Conflict between 'required publishing' and not having scheduler
@@ -76,64 +120,90 @@ public function testUserPermissionsAdd() {
 
   /**
    * Tests that users without permission can edit existing scheduled content.
+   *
+   * @dataProvider dataPermissionsTest()
    */
-  public function testUserPermissionsEdit() {
-    // Create a user who can add the content type but who does not have the
-    // permission to use the scheduler functionality.
-    $this->webUser = $this->drupalCreateUser([
-      'access content',
-      'administer nodes',
-      'create ' . $this->type . ' content',
-      'edit own ' . $this->type . ' content',
-      'delete own ' . $this->type . ' content',
-      'view own unpublished content',
-    ]);
-    $this->drupalLogin($this->webUser);
+  public function testUserPermissionsEdit($entityTypeId, $bundle, $user) {
+    $storage = $this->entityStorageObject($entityTypeId);
+    $titleField = $this->titleField($entityTypeId);
+
+    // Log in with the required user, as specified by the parameter.
+    $this->drupalLogin($this->$user);
 
     $publish_time = strtotime('+ 6 hours', $this->requestTime);
     $unpublish_time = strtotime('+ 10 hours', $this->requestTime);
 
-    // Create nodes with publish_on and unpublish_on dates.
-    $unpublished_node = $this->drupalCreateNode([
-      'type' => $this->type,
+    // Create an unpublished entity with a publish_on date.
+    $unpublished_entity = $this->createEntity($entityTypeId, $bundle, [
       'status' => FALSE,
       'publish_on' => $publish_time,
     ]);
-    $published_node = $this->drupalCreateNode([
-      'type' => $this->type,
-      'status' => TRUE,
-      'unpublish_on' => $unpublish_time,
-    ]);
 
     // Verify that the publish_on date is stored as expected before editing.
-    $this->assertEquals($publish_time, $unpublished_node->publish_on->value, 'The publish_on value is stored correctly before edit.');
+    $this->assertEquals($publish_time, $unpublished_entity->publish_on->value, 'The publish_on value is stored correctly before edit.');
+
+    // Edit the unpublished entity and check that the fields are displayed as
+    // expected, depending on the user.
+    $this->drupalGet($unpublished_entity->toUrl('edit-form'));
+    if (strpos($user, $entityTypeId) !== FALSE) {
+      $this->assertSession()->fieldExists('publish_on[0][value][date]');
+      $this->assertSession()->fieldExists('unpublish_on[0][value][date]');
+    }
+    else {
+      $this->assertSession()->fieldNotExists('publish_on[0][value][date]');
+      $this->assertSession()->fieldNotExists('unpublish_on[0][value][date]');
+    }
 
-    // Edit the unpublished node and save.
+    // Save the entity and check the title is updated as expected.
     $title = 'For Publishing ' . $this->randomString(10);
-    $this->drupalPostForm('node/' . $unpublished_node->id() . '/edit', ['title[0][value]' => $title], 'Save');
-
-    // Check the updated title, to verify that edit and save was sucessful.
-    $unpublished_node = $this->nodeStorage->load($unpublished_node->id());
-    $this->assertEquals($title, $unpublished_node->title->value, 'The unpublished node title has been updated correctly after edit.');
+    $this->submitForm(["{$titleField}[0][value]" => $title], 'Save');
+    $unpublished_entity = $storage->load($unpublished_entity->id());
+    $this->assertEquals($title, $unpublished_entity->label(), 'The unpublished entity title has been updated correctly after edit.');
 
     // Test that the publish_on date is still stored and is unchanged.
-    $this->assertEquals($publish_time, $unpublished_node->publish_on->value, 'The node publish_on value is still stored correctly after edit.');
+    $this->assertEquals($publish_time, $unpublished_entity->publish_on->value, 'The publish_on value is still stored correctly after edit.');
+
+    // Repeat for unpublishing. Create an entity scheduled for unpublishing.
+    $published_entity = $this->createEntity($entityTypeId, $bundle, [
+      'status' => TRUE,
+      'unpublish_on' => $unpublish_time,
+    ]);
 
-    // Do the same for unpublishing.
     // Verify that the unpublish_on date is stored as expected before editing.
-    $this->assertEquals($unpublish_time, $published_node->unpublish_on->value, 'The unpublish_on value is stored correctly before edit.');
+    $this->assertEquals($unpublish_time, $published_entity->unpublish_on->value, 'The unpublish_on value is stored correctly before edit.');
 
-    // Edit the published node and save.
+    // Edit the published entity and save.
     $title = 'For Unpublishing ' . $this->randomString(10);
-    $this->drupalPostForm('node/' . $published_node->id() . '/edit', ['title[0][value]' => $title], 'Save');
+    $this->drupalGet($published_entity->toUrl('edit-form'));
+    $this->submitForm(["{$titleField}[0][value]" => $title], 'Save');
 
     // Check the updated title, to verify that edit and save was sucessful.
-    $published_node = $this->nodeStorage->load($published_node->id());
-    $this->assertEquals($title, $published_node->title->value, 'The published node title has been updated correctly after edit.');
+    $published_entity = $storage->load($published_entity->id());
+    $this->assertEquals($title, $published_entity->label(), 'The published entity title has been updated correctly after edit.');
 
     // Test that the unpublish_on date is still stored and is unchanged.
-    $this->assertEquals($unpublish_time, $published_node->unpublish_on->value, 'The node unpublish_on value is still stored correctly after edit.');
+    $this->assertEquals($unpublish_time, $published_entity->unpublish_on->value, 'The unpublish_on value is still stored correctly after edit.');
+  }
 
+  /**
+   * Provides data for testUserPermissionsAdd() and testUserPermissionsEdit()
+   *
+   * The data in dataStandardEntityTypes() is expanded to test each entity type
+   * with users who only have scheduler permission on one entity type and no
+   * permission for the other entity types.
+   *
+   * @return array
+   *   Each array item has the values: [entity type id, bundle id, user name].
+   */
+  public function dataPermissionsTest() {
+    $data = [];
+    foreach ($this->dataStandardEntityTypes() as $key => $values) {
+      $data["$key-1"] = array_merge($values, ['nodeUser']);
+      $data["$key-2"] = array_merge($values, ['mediaUser']);
+      $data["$key-3"] = array_merge($values, ['commerce_productUser']);
+      $data["$key-4"] = array_merge($values, ['taxonomy_termUser']);
+    }
+    return $data;
   }
 
 }
diff --git a/web/modules/scheduler/tests/src/Functional/SchedulerRequiredTest.php b/web/modules/scheduler/tests/src/Functional/SchedulerRequiredTest.php
index d08aeb640728fad2be1e3ba4d9ce194044d74713..ad42b469dca58c564881c99c94d45414c183c13a 100644
--- a/web/modules/scheduler/tests/src/Functional/SchedulerRequiredTest.php
+++ b/web/modules/scheduler/tests/src/Functional/SchedulerRequiredTest.php
@@ -11,215 +11,334 @@ class SchedulerRequiredTest extends SchedulerBrowserTestBase {
 
   /**
    * Tests creating and editing nodes with required scheduling enabled.
+   *
+   * @dataProvider dataRequiredScheduling()
    */
-  public function testRequiredScheduling() {
+  public function testRequiredScheduling($id, $publish_required, $unpublish_required, $operation, $scheduled, $status, $publish_expected, $unpublish_expected, $message) {
+
     $this->drupalLogin($this->schedulerUser);
 
-    // Define test scenarios with expected results.
-    // @TODO Re-write this with a dataProvider function.
-    $test_cases = [
-      // The 1-10 numbering used below matches the test cases described in
+    $fields = $this->container->get('entity_field.manager')
+      ->getFieldDefinitions('node', $this->type);
+
+    // Set required (un)publishing as stipulated by the test case.
+    $this->nodetype->setThirdPartySetting('scheduler', 'publish_required', $publish_required)
+      ->setThirdPartySetting('scheduler', 'unpublish_required', $unpublish_required)
+      ->save();
+
+    // To assist viewing and analysing the generated test result pages create a
+    // text string showing all the test case parameters.
+    $title_data = ['id = ' . $id,
+      $publish_required ? 'Publishing required' : '',
+      $unpublish_required ? 'Unpublishing required' : '',
+      'on ' . $operation,
+      $status ? 'published' : 'unpublished',
+      $scheduled ? 'scheduled' : 'not scheduled',
+    ];
+    // Remove any empty items.
+    $title_data = array_filter($title_data);
+    $title = implode(', ', $title_data);
+
+    // If the scenario requires editing a node, we need to create one first.
+    if ($operation == 'edit') {
+      // Note: The key names in the $options parameter for drupalCreateNode()
+      // are the plain field names i.e. 'title' not title[0][value].
+      $options = [
+        'title' => $title,
+        'type' => $this->type,
+        'status' => $status,
+        'publish_on' => $scheduled ? strtotime('+1 day') : NULL,
+        'body' => $message,
+      ];
+      $node = $this->drupalCreateNode($options);
+      // Define the path and button to use for editing the node.
+      $path = 'node/' . $node->id() . '/edit';
+    }
+    else {
+      // Set the default status, used when testing creation of the new node.
+      $fields['status']->getConfig($this->type)
+        ->setDefaultValue($status)
+        ->save();
+      // Define the path and button to use for creating the node.
+      $path = 'node/add/' . $this->type;
+    }
+
+    // Make sure that both date fields are empty so we can check if they throw
+    // validation errors when the fields are required.
+    $values = [
+      'title[0][value]' => $title,
+      'publish_on[0][value][date]' => '',
+      'publish_on[0][value][time]' => '',
+      'unpublish_on[0][value][date]' => '',
+      'unpublish_on[0][value][time]' => '',
+    ];
+    // Add or edit the node.
+    $this->drupalGet($path);
+    $this->submitForm($values, 'Save');
+
+    // Check for the expected result.
+    if ($publish_expected) {
+      $string = sprintf('The %s date is required.', ucfirst('publish') . ' on');
+      $this->assertSession()->pageTextContains($string);
+    }
+    if ($unpublish_expected) {
+      $string = sprintf('The %s date is required.', ucfirst('unpublish') . ' on');
+      $this->assertSession()->pageTextContains($string);
+    }
+    if (!$publish_expected && !$unpublish_expected) {
+      $string = sprintf('%s %s has been %s.', $this->typeName, $title, ($operation == 'add' ? 'created' : 'updated'));
+      $this->assertSession()->pageTextContains($string);
+    }
+  }
+
+  /**
+   * Provides data for testRequiredScheduling().
+   *
+   * @return array
+   *   id                 - a sequential id to help in identifying test output
+   *   publish_required   - (bool) whether the publish_on field is required
+   *   unpublish_required - (bool) whether the unpublish_on field is required
+   *   operation          - what is being done to the node, 'add' or 'edit'
+   *   scheduled          - (bool) the node is already scheduled for publishing
+   *   status             - (bool) the current published status of the node
+   *   publish_expected   - (bool) will this scenario produced a 'publish on
+   *                        required' error message
+   *   unpublish_expected -  (bool) will this scenario produced a 'unpublish on
+   *                        required' error message
+   *   message            - Descriptive text used in the body of the node
+   */
+  public function dataRequiredScheduling() {
+
+    $data = [
+      // The numbering used below matches the test cases described in
       // http://drupal.org/node/1198788#comment-7816119
-      //
-      [
+
+      // Check the default case when neither date should be required.
+      '#node-0' => [
         'id' => 0,
-        'required' => '',
+        'publish_required' => FALSE,
+        'unpublish_required' => FALSE,
         'operation' => 'add',
-        'status' => 1,
-        'expected' => 'not required',
+        'scheduled' => FALSE,
+        'status' => TRUE,
+        'publish_expected' => FALSE,
+        'unpublish_expected' => FALSE,
         'message' => 'By default when a new node is created, the publish on and unpublish on dates are not required.',
       ],
+
       // A. Test scenarios that require scheduled publishing.
       // When creating a new unpublished node it is required to enter a
       // publication date.
-      [
+      '#node-1' => [
         'id' => 1,
-        'required' => 'publish',
+        'publish_required' => TRUE,
+        'unpublish_required' => FALSE,
         'operation' => 'add',
-        'status' => 0,
-        'expected' => 'required',
+        'scheduled' => FALSE,
+        'status' => FALSE,
+        'publish_expected' => TRUE,
+        'unpublish_expected' => FALSE,
         'message' => 'When scheduled publishing is required and a new unpublished node is created, entering a date in the publish on field is required.',
       ],
 
       // When creating a new published node it is required to enter a
       // publication date. The node will be unpublished on form submit.
-      [
+      '#node-2' => [
         'id' => 2,
-        'required' => 'publish',
+        'publish_required' => TRUE,
+        'unpublish_required' => FALSE,
         'operation' => 'add',
-        'status' => 1,
-        'expected' => 'required',
+        'scheduled' => FALSE,
+        'status' => TRUE,
+        'publish_expected' => TRUE,
+        'unpublish_expected' => FALSE,
         'message' => 'When scheduled publishing is required and a new published node is created, entering a date in the publish on field is required.',
       ],
 
       // When editing a published node it is not needed to enter a publication
       // date since the node is already published.
-      [
+      '#node-3' => [
         'id' => 3,
-        'required' => 'publish',
+        'publish_required' => TRUE,
+        'unpublish_required' => FALSE,
         'operation' => 'edit',
-        'scheduled' => 0,
-        'status' => 1,
-        'expected' => 'not required',
+        'scheduled' => FALSE,
+        'status' => TRUE,
+        'publish_expected' => FALSE,
+        'unpublish_expected' => FALSE,
         'message' => 'When scheduled publishing is required and an existing published, unscheduled node is edited, entering a date in the publish on field is not required.',
       ],
 
       // When editing an unpublished node that is scheduled for publication it
       // is required to enter a publication date.
-      [
+      '#node-4' => [
         'id' => 4,
-        'required' => 'publish',
+        'publish_required' => TRUE,
+        'unpublish_required' => FALSE,
         'operation' => 'edit',
-        'scheduled' => 1,
-        'status' => 0,
-        'expected' => 'required',
+        'scheduled' => TRUE,
+        'status' => FALSE,
+        'publish_expected' => TRUE,
+        'unpublish_expected' => FALSE,
         'message' => 'When scheduled publishing is required and an existing unpublished, scheduled node is edited, entering a date in the publish on field is required.',
       ],
 
       // When editing an unpublished node that is not scheduled for publication
       // it is not required to enter a publication date since this means that
       // the node has already gone through a publication > unpublication cycle.
-      [
+      '#node-5' => [
         'id' => 5,
-        'required' => 'publish',
+        'publish_required' => TRUE,
+        'unpublish_required' => FALSE,
         'operation' => 'edit',
-        'scheduled' => 0,
-        'status' => 0,
-        'expected' => 'not required',
+        'scheduled' => FALSE,
+        'status' => FALSE,
+        'publish_expected' => FALSE,
+        'unpublish_expected' => FALSE,
         'message' => 'When scheduled publishing is required and an existing unpublished, unscheduled node is edited, entering a date in the publish on field is not required.',
       ],
 
       // B. Test scenarios that require scheduled unpublishing.
+
       // When creating a new unpublished node it is required to enter an
       // unpublication date since it is to be expected that the node will be
       // published at some point and should subsequently be unpublished.
-      [
+      '#node-6' => [
         'id' => 6,
-        'required' => 'unpublish',
+        'publish_required' => FALSE,
+        'unpublish_required' => TRUE,
         'operation' => 'add',
-        'status' => 0,
-        'expected' => 'required',
+        'scheduled' => FALSE,
+        'status' => FALSE,
+        'publish_expected' => FALSE,
+        'unpublish_expected' => TRUE,
         'message' => 'When scheduled unpublishing is required and a new unpublished node is created, entering a date in the unpublish on field is required.',
       ],
 
       // When creating a new published node it is required to enter an
       // unpublication date.
-      [
+      '#node-7' => [
         'id' => 7,
-        'required' => 'unpublish',
+        'publish_required' => FALSE,
+        'unpublish_required' => TRUE,
         'operation' => 'add',
-        'status' => 1,
-        'expected' => 'required',
+        'scheduled' => FALSE,
+        'status' => TRUE,
+        'publish_expected' => FALSE,
+        'unpublish_expected' => TRUE,
         'message' => 'When scheduled unpublishing is required and a new published node is created, entering a date in the unpublish on field is required.',
       ],
 
       // When editing a published node it is required to enter an unpublication
       // date.
-      [
+      '#node-8' => [
         'id' => 8,
-        'required' => 'unpublish',
+        'publish_required' => FALSE,
+        'unpublish_required' => TRUE,
         'operation' => 'edit',
-        'scheduled' => 0,
-        'status' => 1,
-        'expected' => 'required',
+        'scheduled' => FALSE,
+        'status' => TRUE,
+        'publish_expected' => FALSE,
+        'unpublish_expected' => TRUE,
         'message' => 'When scheduled unpublishing is required and an existing published, unscheduled node is edited, entering a date in the unpublish on field is required.',
       ],
 
       // When editing an unpublished node that is scheduled for publication it
       // it is required to enter an unpublication date.
-      [
+      '#node-9' => [
         'id' => 9,
-        'required' => 'unpublish',
+        'publish_required' => FALSE,
+        'unpublish_required' => TRUE,
         'operation' => 'edit',
-        'scheduled' => 1,
-        'status' => 0,
-        'expected' => 'required',
+        'scheduled' => TRUE,
+        'status' => FALSE,
+        'publish_expected' => FALSE,
+        'unpublish_expected' => TRUE,
         'message' => 'When scheduled unpublishing is required and an existing unpublished, scheduled node is edited, entering a date in the unpublish on field is required.',
       ],
 
       // When editing an unpublished node that is not scheduled for publication
       // it is not required to enter an unpublication date since this means that
       // the node has already gone through a publication - unpublication cycle.
-      [
+      '#node-10' => [
         'id' => 10,
-        'required' => 'unpublish',
+        'publish_required' => FALSE,
+        'unpublish_required' => TRUE,
         'operation' => 'edit',
-        'scheduled' => 0,
-        'status' => 0,
-        'expected' => 'not required',
+        'scheduled' => FALSE,
+        'status' => FALSE,
+        'publish_expected' => FALSE,
+        'unpublish_expected' => FALSE,
         'message' => 'When scheduled unpublishing is required and an existing unpublished, unscheduled node is edited, entering a date in the unpublish on field is not required.',
       ],
-    ];
 
-    $fields = $this->container->get('entity_field.manager')
-      ->getFieldDefinitions('node', $this->type);
+      // C. Test scenarios that require both publishing and unpublishing.
 
-    foreach ($test_cases as $test_case) {
-      // Set required (un)publishing as stipulated by the test case.
-      if (!empty($test_case['required'])) {
-        $this->nodetype->setThirdPartySetting('scheduler', 'publish_required', $test_case['required'] == 'publish')
-          ->setThirdPartySetting('scheduler', 'unpublish_required', $test_case['required'] == 'unpublish')
-          ->save();
-      }
-
-      // To assist viewing and analysing the generated test result pages create
-      // a text string showing all the test case parameters.
-      $title_data = [];
-      foreach ($test_case as $key => $value) {
-        if ($key != 'message') {
-          $title_data[] = $key . ' = ' . $value;
-        }
-      }
-      $title = implode(', ', $title_data);
-
-      // If the test case requires editing a node, we need to create one first.
-      if ($test_case['operation'] == 'edit') {
-        // Note: The key names in the $options parameter for drupalCreateNode()
-        // are the plain field names i.e. 'title' not title[0][value].
-        $options = [
-          'title' => $title,
-          'type' => $this->type,
-          'status' => $test_case['status'],
-          'publish_on' => !empty($test_case['scheduled']) ? strtotime('+1 day') : NULL,
-        ];
-        $node = $this->drupalCreateNode($options);
-        // Define the path and button to use for editing the node.
-        $path = 'node/' . $node->id() . '/edit';
-      }
-      else {
-        // Set the default status, used when testing creation of the new node.
-        $fields['status']->getConfig($this->type)
-          ->setDefaultValue($test_case['status'])
-          ->save();
-        // Define the path and button to use for creating the node.
-        $path = 'node/add/' . $this->type;
-      }
-
-      // Make sure that both date fields are empty so we can check if they throw
-      // validation errors when the fields are required.
-      $values = [
-        'title[0][value]' => $title,
-        'publish_on[0][value][date]' => '',
-        'publish_on[0][value][time]' => '',
-        'unpublish_on[0][value][date]' => '',
-        'unpublish_on[0][value][time]' => '',
-      ];
-      // Add or edit the node.
-      $this->drupalPostForm($path, $values, 'Save');
-
-      // Check for the expected result.
-      switch ($test_case['expected']) {
-        case 'required':
-          $string = sprintf('The %s date is required.', ucfirst($test_case['required']) . ' on');
-          $this->assertSession()->pageTextContains($string);
-          break;
-
-        case 'not required':
-          $string = sprintf('%s %s has been %s.', $this->typeName, $title, ($test_case['operation'] == 'add' ? 'created' : 'updated'));
-          $this->assertSession()->pageTextContains($string);
-          break;
-      }
-    }
+      // This section is an amalgamation of the values in the sections A and B
+      // to check that the settings do not interfere with each other.
+      '#node-11' => [
+        'id' => 11,
+        'publish_required' => TRUE,
+        'unpublish_required' => TRUE,
+        'operation' => 'add',
+        'scheduled' => FALSE,
+        'status' => FALSE,
+        'publish_expected' => TRUE,
+        'unpublish_expected' => TRUE,
+        'message' => 'When both scheduled publishing and unpublishing are required and a new unpublished node is created, entering a date in both the publish and unpublish on fields is required.',
+      ],
+
+      '#node-12' => [
+        'id' => 12,
+        'publish_required' => TRUE,
+        'unpublish_required' => TRUE,
+        'operation' => 'add',
+        'scheduled' => FALSE,
+        'status' => TRUE,
+        'publish_expected' => TRUE,
+        'unpublish_expected' => TRUE,
+        'message' => 'When both scheduled publishing and unpublishing are required and a new published node is created, entering a date in both the publish and unpublish on fields is required.',
+      ],
+
+      '#node-13' => [
+        'id' => 13,
+        'publish_required' => TRUE,
+        'unpublish_required' => TRUE,
+        'operation' => 'edit',
+        'scheduled' => FALSE,
+        'status' => TRUE,
+        'publish_expected' => FALSE,
+        'unpublish_expected' => TRUE,
+        'message' => 'When both scheduled publishing and unpublishing are required and an existing published, unscheduled node is edited, entering a date in the unpublish on field is required, but a publish date is not required.',
+      ],
+
+      '#node-14' => [
+        'id' => 14,
+        'publish_required' => TRUE,
+        'unpublish_required' => TRUE,
+        'operation' => 'edit',
+        'scheduled' => TRUE,
+        'status' => FALSE,
+        'publish_expected' => TRUE,
+        'unpublish_expected' => TRUE,
+        'message' => 'When both scheduled publishing and unpublishing are required and an existing unpublished, scheduled node is edited, entering a date in both the publish and unpublish on fields is required.',
+      ],
+
+      '#node-15' => [
+        'id' => 15,
+        'publish_required' => TRUE,
+        'unpublish_required' => TRUE,
+        'operation' => 'edit',
+        'scheduled' => FALSE,
+        'status' => FALSE,
+        'publish_expected' => FALSE,
+        'unpublish_expected' => FALSE,
+        'message' => 'When both scheduled publishing and unpublishing are required and an existing unpublished, unscheduled node is edited, entering a date in the publish or unpublish on fields is not required.',
+      ],
+
+    ];
+
+    return $data;
   }
 
 }
diff --git a/web/modules/scheduler/tests/src/Functional/SchedulerRevisioningTest.php b/web/modules/scheduler/tests/src/Functional/SchedulerRevisioningTest.php
index 8f20b570752e98cc17838299e91af13859c87551..29f16b54eadb7d4fed90e399e533ca6e1435b10e 100644
--- a/web/modules/scheduler/tests/src/Functional/SchedulerRevisioningTest.php
+++ b/web/modules/scheduler/tests/src/Functional/SchedulerRevisioningTest.php
@@ -2,7 +2,7 @@
 
 namespace Drupal\Tests\scheduler\Functional;
 
-use Drupal\node\NodeInterface;
+use Drupal\Core\Entity\EntityInterface;
 
 /**
  * Tests revision options when Scheduler publishes or unpublishes content.
@@ -12,153 +12,155 @@
 class SchedulerRevisioningTest extends SchedulerBrowserTestBase {
 
   /**
-   * Simulates the scheduled (un)publication of a node.
+   * Simulates the scheduled (un)publication of an entity.
    *
-   * @param \Drupal\node\NodeInterface $node
-   *   The node to schedule.
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity to schedule.
    * @param string $action
-   *   The action to perform: either 'publish' or 'unpublish'. Defaults to
-   *   'publish'.
+   *   The action to perform: either 'publish' or 'unpublish'.
    *
-   * @return \Drupal\node\NodeInterface
-   *   The updated node, after scheduled (un)publication via a cron run.
+   * @return \Drupal\Core\Entity\EntityInterface
+   *   The updated entity, after scheduled (un)publication via a cron run.
    */
-  protected function schedule(NodeInterface $node, $action = 'publish') {
+  protected function scheduleAndRunCron(EntityInterface $entity, string $action) {
     // Simulate scheduling by setting the (un)publication date in the past and
     // running cron.
-    $node->{$action . '_on'} = strtotime('-5 hour', $this->requestTime);
-    $node->save();
+    $entity->{$action . '_on'} = strtotime('-5 hour', $this->requestTime);
+    $entity->save();
     scheduler_cron();
-    $this->nodeStorage->resetCache([$node->id()]);
-    return $this->nodeStorage->load($node->id());
+    $storage = $this->entityStorageObject($entity->getEntityTypeId());
+    $storage->resetCache([$entity->id()]);
+    return $storage->load($entity->id());
   }
 
   /**
-   * Check if the number of revisions for a node matches a given value.
+   * Check if the number of revisions for an entity matches a given value.
    *
-   * @param int $nid
-   *   The node id of the node to check.
-   * @param string $value
-   *   The value with which the number of revisions will be compared.
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity to check.
+   * @param int $expected
+   *   The expected number of revisions.
    * @param string $message
    *   The message to display along with the assertion.
-   *
-   * @return bool
-   *   TRUE if the assertion succeeded, FALSE otherwise.
    */
-  protected function assertRevisionCount($nid, $value, $message = '') {
-    $count = \Drupal::database()->select('node_revision', 'r')
-      ->condition('nid', $nid)
-      ->countQuery()
-      ->execute()
-      ->fetchColumn();
-    return $this->assertEquals($value, (int) $count, $message);
+  protected function assertRevisionCount(EntityInterface $entity, int $expected, string $message = '') {
+    if (!$entity->getEntityType()->isRevisionable()) {
+      return;
+    }
+    // Because we are not deleting any revisions we can take a short cut and use
+    // getLatestRevisionId() which will effectively be the number of revisions.
+    $storage = $this->entityStorageObject($entity->getEntityTypeId());
+    $count = $storage->getLatestRevisionId($entity->id());
+    $this->assertEquals($expected, (int) $count, $message);
   }
 
   /**
-   * Check if the latest revision log message of a node matches a given string.
+   * Tests the creation of new revisions on scheduling.
    *
-   * @param int $nid
-   *   The node id of the node to check.
-   * @param string $value
-   *   The value with which the log message will be compared.
-   * @param string $message
-   *   The message to display along with the assertion.
+   * This test is still useful for Commerce Products which are not revisionable
+   * because it shows that this entity type can be processed correctly even if
+   * the scheduler revision option is incorrectly set on.
    *
-   * @return bool
-   *   TRUE if the assertion succeeded, FALSE otherwise.
+   * @dataProvider dataStandardEntityTypes()
    */
-  protected function assertRevisionLogMessage($nid, $value, $message = '') {
-    // Retrieve the latest revision log message for this node.
-    $log_message = $this->database->select('node_revision', 'r')
-      ->fields('r', ['revision_log'])
-      ->condition('nid', $nid)
-      ->orderBy('vid', 'DESC')
-      ->range(0, 1)
-      ->execute()
-      ->fetchColumn();
-
-    return $this->assertEquals($value, $log_message, $message);
-  }
+  public function testNewRevision($entityTypeId, $bundle) {
+    $entityType = $this->entityTypeObject($entityTypeId, $bundle);
 
-  /**
-   * Tests the creation of new revisions on scheduling.
-   */
-  public function testRevisioning() {
-    // Create a scheduled node that is not automatically revisioned.
-    $created = strtotime('-2 day', $this->requestTime);
-    $settings = [
-      'type' => $this->type,
-      'revision' => 0,
-      'created' => $created,
-    ];
-    $node = $this->drupalCreateNode($settings);
+    // Create a scheduled entity that is not automatically revisioned.
+    $entity = $this->createEntity($entityTypeId, $bundle, ['revision' => 0]);
+    $this->assertRevisionCount($entity, 1, 'The initial revision count is 1 when the entity is created.');
 
-    // Ensure nodes with past dates will be scheduled not published immediately.
-    $this->nodetype->setThirdPartySetting('scheduler', 'publish_past_date', 'schedule')->save();
+    // Ensure entities with past dates are scheduled not published immediately.
+    $entityType->setThirdPartySetting('scheduler', 'publish_past_date', 'schedule')->save();
 
     // First test scheduled publication with revisioning disabled by default.
-    $node = $this->schedule($node);
-    $this->assertRevisionCount($node->id(), 1, 'No new revision is created by default when a node is published.');
+    $entity = $this->scheduleAndRunCron($entity, 'publish');
+    $this->assertRevisionCount($entity, 1, 'No new revision is created by default when entity is published. Revision count remains at 1.');
 
     // Test scheduled unpublication.
-    $node = $this->schedule($node, 'unpublish');
-    $this->assertRevisionCount($node->id(), 1, 'No new revision is created by default when a node is unpublished.');
+    $entity = $this->scheduleAndRunCron($entity, 'unpublish');
+    $this->assertRevisionCount($entity, 1, 'No new revision is created by default when entity is unpublished. Revision count remains at 1.');
 
     // Enable revisioning.
-    $this->nodetype->setThirdPartySetting('scheduler', 'publish_revision', TRUE)
+    $entityType->setThirdPartySetting('scheduler', 'publish_revision', TRUE)
       ->setThirdPartySetting('scheduler', 'unpublish_revision', TRUE)
       ->save();
 
     // Test scheduled publication with revisioning enabled.
-    $node = $this->schedule($node);
-    $this->assertRevisionCount($node->id(), 2, 'A new revision was created when revisioning is enabled.');
-    $expected_message = sprintf('Published by Scheduler. The scheduled publishing date was %s.',
-    $this->dateFormatter->format(strtotime('-5 hour', $this->requestTime), 'short'));
-    $this->assertRevisionLogMessage($node->id(), $expected_message, 'The correct message was found in the node revision log after scheduled publishing.');
+    $entity = $this->scheduleAndRunCron($entity, 'publish');
+    $this->assertTrue($entity->isPublished(), 'Entity is published after cron.');
+
+    if ($entity->getEntityType()->isRevisionable()) {
+      $this->assertRevisionCount($entity, 2, 'A new revision was created when the entity was published with revisioning enabled.');
+      $expected_message = sprintf('Published by Scheduler. The scheduled publishing date was %s.',
+        $this->dateFormatter->format(strtotime('-5 hour', $this->requestTime), 'short'));
+      $this->assertEquals($entity->getRevisionLogMessage(), $expected_message, 'The correct message was found in the entity revision log after scheduled publishing.');
+    }
 
     // Test scheduled unpublication with revisioning enabled.
-    $node = $this->schedule($node, 'unpublish');
-    $this->assertRevisionCount($node->id(), 3, 'A new revision was created when a node was unpublished with revisioning enabled.');
-    $expected_message = sprintf('Unpublished by Scheduler. The scheduled unpublishing date was %s.',
-    $this->dateFormatter->format(strtotime('-5 hour', $this->requestTime), 'short'));
-    $this->assertRevisionLogMessage($node->id(), $expected_message, 'The correct message was found in the node revision log after scheduled unpublishing.');
+    $entity = $this->scheduleAndRunCron($entity, 'unpublish');
+    $this->assertFalse($entity->isPublished(), 'Entity is unpublished after cron.');
+
+    if ($entity->getEntityType()->isRevisionable()) {
+      $this->assertRevisionCount($entity, 3, 'A new revision was created when the entity was unpublished with revisioning enabled.');
+      $expected_message = sprintf('Unpublished by Scheduler. The scheduled unpublishing date was %s.',
+        $this->dateFormatter->format(strtotime('-5 hour', $this->requestTime), 'short'));
+      $this->assertEquals($entity->getRevisionLogMessage(), $expected_message, 'The correct message was found in the entity revision log after scheduled unpublishing.');
+    }
   }
 
   /**
-   * Tests the 'touch' option to alter the node created date during publishing.
+   * Tests the 'touch' option to alter the created date during publishing.
+   *
+   * @dataProvider dataAlterCreationDate()
    */
-  public function testAlterCreationDate() {
-    // Ensure nodes with past dates will be scheduled not published immediately.
-    $this->nodetype->setThirdPartySetting('scheduler', 'publish_past_date', 'schedule')->save();
+  public function testAlterCreationDate($entityTypeId, $bundle) {
+    // Ensure entities with past dates are scheduled not published immediately.
+    $entityType = $this->entityTypeObject($entityTypeId, $bundle);
+    $entityType->setThirdPartySetting('scheduler', 'publish_past_date', 'schedule')->save();
 
-    // Create a node with a 'created' date two days in the past.
+    // Create an entity with a 'created' date two days in the past.
     $created = strtotime('-2 day', $this->requestTime);
     $settings = [
-      'type' => $this->type,
       'created' => $created,
       'status' => FALSE,
     ];
-    $node = $this->drupalCreateNode($settings);
-    // Show that the node is not published.
-    $this->assertFalse($node->isPublished(), 'The node is not published.');
+    $entity = $this->createEntity($entityTypeId, $bundle, $settings);
 
-    // Schedule the node for publishing and run cron.
-    $node = $this->schedule($node, 'publish');
-    // Get the created date from the node and check that it has not changed.
-    $created_after_cron = $node->created->value;
-    $this->assertTrue($node->isPublished(), 'The node has been published.');
-    $this->assertEquals($created, $created_after_cron, 'The node creation date is not changed by default.');
+    // Show that the entity is not published.
+    $this->assertFalse($entity->isPublished(), 'The entity is not published.');
+
+    // Schedule the entity for publishing and run cron.
+    $entity = $this->scheduleAndRunCron($entity, 'publish');
+    // Get the created date from the entity and check that it has not changed.
+    $created_after_cron = $entity->created->value;
+    $this->assertTrue($entity->isPublished(), 'The entity has been published.');
+    $this->assertEquals($created, $created_after_cron, 'The entity creation date is not changed by default.');
 
     // Set option to change the created date to match the publish_on date.
-    $this->nodetype->setThirdPartySetting('scheduler', 'publish_touch', TRUE)->save();
+    $entityType->setThirdPartySetting('scheduler', 'publish_touch', TRUE)->save();
 
-    // Schedule the node again and run cron.
-    $node = $this->schedule($node, 'publish');
+    // Schedule the entity again and run cron.
+    $entity = $this->scheduleAndRunCron($entity, 'publish');
     // Check that the created date has changed to match the publish_on date.
-    $created_after_cron = $node->created->value;
-    $this->assertEquals(strtotime('-5 hour', $this->requestTime), $created_after_cron, "With 'touch' option set, the node creation date is changed to match the publishing date.");
+    $created_after_cron = $entity->created->value;
+    $this->assertEquals(strtotime('-5 hour', $this->requestTime), $created_after_cron, "With 'touch' option set, the entity creation date is changed to match the publishing date.");
+
+  }
 
+  /**
+   * Provides test data for testAlterCreationDate.
+   *
+   * Taxonomy terms do not have a 'created' date and the therefore the 'touch'
+   * option is not available, and the test should be skipped.
+   *
+   * @return array
+   *   Each array item has the values: [entity type id, bundle id].
+   */
+  public function dataAlterCreationDate() {
+    $data = $this->dataStandardEntityTypes();
+    unset($data['#taxonomy_term']);
+    return $data;
   }
 
 }
diff --git a/web/modules/scheduler/tests/src/Functional/SchedulerRulesActionsTest.php b/web/modules/scheduler/tests/src/Functional/SchedulerRulesActionsTest.php
deleted file mode 100644
index 8240fbf0ad1cb332b62d48c8b58dec3015ccee1f..0000000000000000000000000000000000000000
--- a/web/modules/scheduler/tests/src/Functional/SchedulerRulesActionsTest.php
+++ /dev/null
@@ -1,364 +0,0 @@
-<?php
-
-namespace Drupal\Tests\scheduler\Functional;
-
-use Drupal\Core\Logger\RfcLogLevel;
-use Drupal\rules\Context\ContextConfig;
-
-/**
- * Tests the six actions that Scheduler provides for use in Rules module.
- *
- * @group scheduler
- * @group legacy
- * @todo Remove the 'legacy' tag when Rules no longer uses deprecated code.
- * @see https://www.drupal.org/project/scheduler/issues/2924353
- */
-class SchedulerRulesActionsTest extends SchedulerBrowserTestBase {
-
-  /**
-   * Additional modules required.
-   *
-   * @var array
-   */
-  protected static $modules = ['scheduler_rules_integration'];
-
-  /**
-   * {@inheritdoc}
-   */
-  public function setUp() {
-    parent::setUp();
-
-    $this->rulesStorage = $this->container->get('entity_type.manager')->getStorage('rules_reaction_rule');
-    $this->expressionManager = $this->container->get('plugin.manager.rules_expression');
-    $this->drupalLogin($this->adminUser);
-
-    // Create node A which is published and enabled for Scheduling.
-    $this->node_a = $this->drupalCreateNode([
-      'title' => 'Initial Test Node',
-      'type' => $this->type,
-      'uid' => $this->adminUser->id(),
-      'status' => TRUE,
-    ]);
-
-    // Create node B which is published but not enabled for Scheduling.
-    $this->node_b = $this->drupalCreateNode([
-      'title' => 'Something Else',
-      'type' => $this->nonSchedulerNodeType->id(),
-      'uid' => $this->adminUser->id(),
-      'status' => TRUE,
-    ]);
-  }
-
-  /**
-   * Tests the actions which set and remove the 'Publish On' date.
-   */
-  public function testPublishOnActions() {
-
-    // Create rule 1 to set the publishing date.
-    $rule1 = $this->expressionManager->createRule();
-    $rule1->addCondition('rules_data_comparison',
-        ContextConfig::create()
-          ->map('data', 'node.title.value')
-          ->setValue('operation', '==')
-          ->setValue('value', 'Trigger Action Rule 1')
-    );
-    $message1 = 'RULES message 1. Action to set Publish-on date.';
-    $rule1->addAction('scheduler_set_publishing_date_action',
-      ContextConfig::create()
-        ->map('node', 'node')
-        ->setValue('date', $this->requestTime + 1800)
-      )
-      ->addAction('rules_system_message',
-        ContextConfig::create()
-          ->setValue('message', $message1)
-          ->setValue('type', 'status')
-    );
-    // The event needs to be rules_entity_presave:node 'before saving' because
-    // rules_entity_update:node 'after save' is too late to set the date.
-    $config_entity = $this->rulesStorage->create([
-      'id' => 'rule1',
-      'events' => [['event_name' => 'rules_entity_presave:node']],
-      'expression' => $rule1->getConfiguration(),
-    ]);
-    $config_entity->save();
-
-    // Create rule 2 to remove the publishing date and publish the node.
-    $rule2 = $this->expressionManager->createRule();
-    $rule2->addCondition('rules_data_comparison',
-        ContextConfig::create()
-          ->map('data', 'node.title.value')
-          ->setValue('operation', '==')
-          ->setValue('value', 'Trigger Action Rule 2')
-    );
-    $message2 = 'RULES message 2. Action to remove Publish-on date and publish the node immediately.';
-    $rule2->addAction('scheduler_remove_publishing_date_action',
-      ContextConfig::create()
-        ->map('node', 'node')
-      )
-      ->addAction('scheduler_publish_now_action',
-        ContextConfig::create()
-          ->map('node', 'node')
-      )
-      ->addAction('rules_system_message',
-        ContextConfig::create()
-          ->setValue('message', $message2)
-          ->setValue('type', 'status')
-      );
-    $config_entity = $this->rulesStorage->create([
-      'id' => 'rule2',
-      'events' => [['event_name' => 'rules_entity_presave:node']],
-      'expression' => $rule2->getConfiguration(),
-    ]);
-    $config_entity->save();
-
-    $assert = $this->assertSession();
-
-    // Firstly, use the Scheduler-enabled node.
-    $node = $this->node_a;
-
-    // Edit node without changing title.
-    $edit = [
-      'body[0][value]' => $this->randomString(30),
-    ];
-    $this->drupalPostForm('node/' . $node->id() . '/edit', $edit, 'Save');
-    $this->nodeStorage->resetCache([$node->id()]);
-    $node = $this->nodeStorage->load($node->id());
-    // Check that neither of the rules are triggered, no publish and unpublish
-    // dates are set and the status is still published.
-    $assert->pageTextNotContains($message1);
-    $assert->pageTextNotContains($message2);
-    $this->assertFalse($node->publish_on->value, 'Node is not scheduled for publishing.');
-    $this->assertFalse($node->unpublish_on->value, 'Node is not scheduled for unpublishing.');
-    $this->assertTrue($node->isPublished(), 'Node remains published for title: "' . $node->title->value . '".');
-
-    // Edit the node, triggering rule 1.
-    $edit = [
-      'title[0][value]' => 'Trigger Action Rule 1',
-      'body[0][value]' => $this->randomString(30),
-    ];
-    $this->drupalPostForm('node/' . $node->id() . '/edit', $edit, 'Save');
-    $this->nodeStorage->resetCache([$node->id()]);
-    $node = $this->nodeStorage->load($node->id());
-    // Check that rule 1 is triggered and rule 2 is not. Check that a publishing
-    // date has been set and the status is now unpublished.
-    $assert->pageTextContains($message1);
-    $assert->pageTextNotContains($message2);
-    $this->assertTrue($node->publish_on->value, 'Node is scheduled for publishing.');
-    $this->assertFalse($node->unpublish_on->value, 'Node is not scheduled for unpublishing.');
-    $this->assertFalse($node->isPublished(), 'Node is now unpublished for title: "' . $node->title->value . '".');
-
-    // Edit the node, triggering rule 2.
-    $edit = [
-      'title[0][value]' => 'Trigger Action Rule 2',
-      'body[0][value]' => $this->randomString(30),
-    ];
-    $this->drupalPostForm('node/' . $node->id() . '/edit', $edit, 'Save');
-    $this->nodeStorage->resetCache([$node->id()]);
-    $node = $this->nodeStorage->load($node->id());
-    // Check that rule 2 is triggered and rule 1 is not. Check that the
-    // publishing date has been removed and the status is now published.
-    $assert->pageTextNotContains($message1);
-    $assert->pageTextContains($message2);
-    $this->assertFalse($node->publish_on->value, 'Node is not scheduled for publishing.');
-    $this->assertFalse($node->unpublish_on->value, 'Node is not scheduled for unpublishing.');
-    $this->assertTrue($node->isPublished(), 'Node is now published for title: "' . $node->title->value . '".');
-
-    // Secondly, use the node which is not enabled for Scheduler.
-    $node = $this->node_b;
-
-    // Edit the node, triggering rule 1.
-    $edit = [
-      'title[0][value]' => 'Trigger Action Rule 1',
-      'body[0][value]' => $this->randomString(30),
-    ];
-    $this->drupalPostForm('node/' . $node->id() . '/edit', $edit, 'Save');
-    // Check that rule 1 issued a warning message.
-    $assert->pageTextContains('warning message');
-    $assert->elementExists('xpath', '//div[@aria-label="Warning message" and contains(string(), "Action")]');
-    // Check that no publishing date is set.
-    $this->assertFalse($node->publish_on->value, 'Node is not scheduled for publishing.');
-    // Check that a log message has been recorded.
-    $log = \Drupal::database()->select('watchdog', 'w')
-      ->condition('type', 'scheduler')
-      ->condition('severity', RfcLogLevel::WARNING)
-      ->countQuery()
-      ->execute()
-      ->fetchColumn();
-    $this->assertEquals(1, $log, 'There is 1 watchdog warning message from Scheduler');
-
-    // Edit the node, triggering rule 2.
-    $edit = [
-      'title[0][value]' => 'Trigger Action Rule 2',
-      'body[0][value]' => $this->randomString(30),
-    ];
-    $this->drupalPostForm('node/' . $node->id() . '/edit', $edit, 'Save');
-    // Check that rule 2 issued a warning message.
-    $assert->pageTextContains('warning message');
-    $assert->elementExists('xpath', '//div[@aria-label="Warning message" and contains(string(), "Action")]');
-    // Check that a second log message has been recorded.
-    $log = \Drupal::database()->select('watchdog', 'w')
-      ->condition('type', 'scheduler')
-      ->condition('severity', RfcLogLevel::WARNING)
-      ->countQuery()
-      ->execute()
-      ->fetchColumn();
-    $this->assertEquals(2, $log, 'There are now 2 watchdog warning messages from Scheduler');
-  }
-
-  /**
-   * Tests the actions which set and remove the 'Unpublish On' date.
-   */
-  public function testUnpublishOnActions() {
-
-    // Create rule 3 to set the unpublishing date.
-    $rule3 = $this->expressionManager->createRule();
-    $rule3->addCondition('rules_data_comparison',
-        ContextConfig::create()
-          ->map('data', 'node.title.value')
-          ->setValue('operation', '==')
-          ->setValue('value', 'Trigger Action Rule 3')
-    );
-    $message3 = 'RULES message 3. Action to set Unpublish-on date.';
-    $rule3->addAction('scheduler_set_unpublishing_date_action',
-      ContextConfig::create()
-        ->map('node', 'node')
-        ->setValue('date', $this->requestTime + 1800)
-      )
-      ->addAction('rules_system_message',
-        ContextConfig::create()
-          ->setValue('message', $message3)
-          ->setValue('type', 'status')
-    );
-    // The event needs to be rules_entity_presave:node 'before saving' because
-    // rules_entity_update:node 'after save' is too late to set the date.
-    $config_entity = $this->rulesStorage->create([
-      'id' => 'rule3',
-      'events' => [['event_name' => 'rules_entity_presave:node']],
-      'expression' => $rule3->getConfiguration(),
-    ]);
-    $config_entity->save();
-
-    // Create rule 4 to remove the unpublishing date and unpublish the node.
-    $rule4 = $this->expressionManager->createRule();
-    $rule4->addCondition('rules_data_comparison',
-        ContextConfig::create()
-          ->map('data', 'node.title.value')
-          ->setValue('operation', '==')
-          ->setValue('value', 'Trigger Action Rule 4')
-    );
-    $message4 = 'RULES message 4. Action to remove Unpublish-on date and unpublish the node immediately.';
-    $rule4->addAction('scheduler_remove_unpublishing_date_action',
-      ContextConfig::create()
-        ->map('node', 'node')
-      )
-      ->addAction('scheduler_unpublish_now_action',
-        ContextConfig::create()
-          ->map('node', 'node')
-      )
-      ->addAction('rules_system_message',
-        ContextConfig::create()
-          ->setValue('message', $message4)
-          ->setValue('type', 'status')
-      );
-    $config_entity = $this->rulesStorage->create([
-      'id' => 'rule4',
-      'events' => [['event_name' => 'rules_entity_presave:node']],
-      'expression' => $rule4->getConfiguration(),
-    ]);
-    $config_entity->save();
-
-    $assert = $this->assertSession();
-
-    // Firstly, use the Scheduler-enabled node.
-    $node = $this->node_a;
-
-    // Edit node without changing title.
-    $edit = [
-      'body[0][value]' => $this->randomString(30),
-    ];
-    $this->drupalPostForm('node/' . $node->id() . '/edit', $edit, 'Save');
-    $this->nodeStorage->resetCache([$node->id()]);
-    $node = $this->nodeStorage->load($node->id());
-    // Check that neither of the rules are triggered, no publish and unpublish
-    // dates are set and the status is still published.
-    $assert->pageTextNotContains($message3);
-    $assert->pageTextNotContains($message4);
-    $this->assertFalse($node->publish_on->value, 'Node is not scheduled for publishing.');
-    $this->assertFalse($node->unpublish_on->value, 'Node is not scheduled for unpublishing.');
-    $this->assertTrue($node->isPublished(), 'Node remains published for title: "' . $node->title->value . '".');
-
-    // Edit the node, triggering rule 3.
-    $edit = [
-      'title[0][value]' => 'Trigger Action Rule 3',
-      'body[0][value]' => $this->randomString(30),
-    ];
-    $this->drupalPostForm('node/' . $node->id() . '/edit', $edit, 'Save');
-    $this->nodeStorage->resetCache([$node->id()]);
-    $node = $this->nodeStorage->load($node->id());
-    // Check that rule 3 is triggered and rule 4 is not. Check that an
-    // unpublishing date has been set and the status is still published.
-    $assert->pageTextContains($message3);
-    $assert->pageTextNotContains($message4);
-    $this->assertFalse($node->publish_on->value, 'Node is not scheduled for publishing.');
-    $this->assertTrue($node->unpublish_on->value, 'Node is scheduled for unpublishing.');
-    $this->assertTrue($node->isPublished(), 'Node is still published for title: "' . $node->title->value . '".');
-
-    // Edit the node, triggering rule 4.
-    $edit = [
-      'title[0][value]' => 'Trigger Action Rule 4',
-      'body[0][value]' => $this->randomString(30),
-    ];
-    $this->drupalPostForm('node/' . $node->id() . '/edit', $edit, 'Save');
-    $this->nodeStorage->resetCache([$node->id()]);
-    $node = $this->nodeStorage->load($node->id());
-    // Check that rule 4 is triggered and rule 3 is not. Check that the
-    // unpublishing date has been removed and the status is now unpublished.
-    $assert->pageTextNotContains($message3);
-    $assert->pageTextContains($message4);
-    $this->assertFalse($node->publish_on->value, 'Node is not scheduled for publishing.');
-    $this->assertFalse($node->unpublish_on->value, 'Node is not scheduled for unpublishing.');
-    $this->assertFalse($node->isPublished(), 'Node is now unpublished for title: "' . $node->title->value . '".');
-
-    // Secondly, use the node which is not enabled for Scheduler.
-    $node = $this->node_b;
-
-    // Edit the node, triggering rule 3.
-    $edit = [
-      'title[0][value]' => 'Trigger Action Rule 3',
-      'body[0][value]' => $this->randomString(30),
-    ];
-    $this->drupalPostForm('node/' . $node->id() . '/edit', $edit, 'Save');
-    // Check that rule 3 issued a warning message.
-    $assert->pageTextContains('warning message');
-    $assert->elementExists('xpath', '//div[@aria-label="Warning message" and contains(string(), "Action")]');
-    // Check that no unpublishing date is set.
-    $this->assertFalse($node->publish_on->value, 'Node is not scheduled for unpublishing.');
-    // Check that a log message has been recorded.
-    $log = \Drupal::database()->select('watchdog', 'w')
-      ->condition('type', 'scheduler')
-      ->condition('severity', RfcLogLevel::WARNING)
-      ->countQuery()
-      ->execute()
-      ->fetchColumn();
-    $this->assertEquals(1, $log, 'There is 1 watchdog warning message from Scheduler');
-
-    // Edit the node, triggering rule 4.
-    $edit = [
-      'title[0][value]' => 'Trigger Action Rule 4',
-      'body[0][value]' => $this->randomString(30),
-    ];
-    $this->drupalPostForm('node/' . $node->id() . '/edit', $edit, 'Save');
-    // Check that rule 4 issued a warning message.
-    $assert->pageTextContains('warning message');
-    $assert->elementExists('xpath', '//div[@aria-label="Warning message" and contains(string(), "Action")]');
-    // Check that a second log message has been recorded.
-    $log = \Drupal::database()->select('watchdog', 'w')
-      ->condition('type', 'scheduler')
-      ->condition('severity', RfcLogLevel::WARNING)
-      ->countQuery()
-      ->execute()
-      ->fetchColumn();
-    $this->assertEquals(2, $log, 'There are now 2 watchdog warning messages from Scheduler');
-  }
-
-}
diff --git a/web/modules/scheduler/tests/src/Functional/SchedulerRulesConditionsTest.php b/web/modules/scheduler/tests/src/Functional/SchedulerRulesConditionsTest.php
deleted file mode 100644
index 0e14248614480e99b831af64f9139904694a5d89..0000000000000000000000000000000000000000
--- a/web/modules/scheduler/tests/src/Functional/SchedulerRulesConditionsTest.php
+++ /dev/null
@@ -1,268 +0,0 @@
-<?php
-
-namespace Drupal\Tests\scheduler\Functional;
-
-use Drupal\rules\Context\ContextConfig;
-
-/**
- * Tests the four conditions that Scheduler provides for use in Rules module.
- *
- * @group scheduler
- * @group legacy
- * @todo Remove the 'legacy' tag when Rules no longer uses deprecated code.
- * @see https://www.drupal.org/project/scheduler/issues/2924353
- */
-class SchedulerRulesConditionsTest extends SchedulerBrowserTestBase {
-
-  /**
-   * Additional modules required.
-   *
-   * @var array
-   */
-  protected static $modules = ['scheduler_rules_integration'];
-
-  /**
-   * {@inheritdoc}
-   */
-  public function setUp() {
-    parent::setUp();
-
-    $this->rulesStorage = $this->container->get('entity_type.manager')->getStorage('rules_reaction_rule');
-    $this->expressionManager = $this->container->get('plugin.manager.rules_expression');
-
-    // Create a published node.
-    $this->node = $this->drupalCreateNode([
-      'title' => 'Rules Test Node',
-      'type' => $this->type,
-      'uid' => $this->schedulerUser->id(),
-      'status' => TRUE,
-    ]);
-  }
-
-  /**
-   * Tests the conditions for whether a nodetype is enabled for Scheduler.
-   */
-  public function testNodeTypeEnabledConditions() {
-    // Create a reaction rule to display a message when viewing a node of a type
-    // that is enabled for scheduled publishing.
-    // "viewing content" actually means "viewing PUBLISHED content".
-    $rule1 = $this->expressionManager->createRule();
-    $rule1->addCondition('scheduler_condition_publishing_is_enabled',
-      ContextConfig::create()->map('node', 'node')
-    );
-    $message1 = 'RULES message 1. This node type is enabled for scheduled publishing.';
-    $rule1->addAction('rules_system_message', ContextConfig::create()
-      ->setValue('message', $message1)
-      ->setValue('type', 'status')
-      );
-    $config_entity = $this->rulesStorage->create([
-      'id' => 'rule1',
-      'events' => [['event_name' => 'rules_entity_view:node']],
-      'expression' => $rule1->getConfiguration(),
-    ]);
-    $config_entity->save();
-
-    // Create a reaction rule to display a message when viewing a node of a type
-    // that is enabled for scheduled unpublishing.
-    $rule2 = $this->expressionManager->createRule();
-    $rule2->addCondition('scheduler_condition_unpublishing_is_enabled',
-      ContextConfig::create()->map('node', 'node')
-    );
-    $message2 = 'RULES message 2. This node type is enabled for scheduled unpublishing.';
-    $rule2->addAction('rules_system_message', ContextConfig::create()
-      ->setValue('message', $message2)
-      ->setValue('type', 'status')
-      );
-    $config_entity = $this->rulesStorage->create([
-      'id' => 'rule2',
-      'events' => [['event_name' => 'rules_entity_view:node']],
-      'expression' => $rule2->getConfiguration(),
-    ]);
-    $config_entity->save();
-
-    // Create a reaction rule to display a message when viewing a node of a type
-    // that is NOT enabled for scheduled publishing.
-    $rule3 = $this->expressionManager->createRule();
-    $rule3->addCondition('scheduler_condition_publishing_is_enabled',
-      ContextConfig::create()->map('node', 'node')->negateResult()
-    );
-    $message3 = 'RULES message 3. This node type is not enabled for scheduled publishing.';
-    $rule3->addAction('rules_system_message', ContextConfig::create()
-      ->setValue('message', $message3)
-      ->setValue('type', 'status')
-      );
-    $config_entity = $this->rulesStorage->create([
-      'id' => 'rule3',
-      'events' => [['event_name' => 'rules_entity_view:node']],
-      'expression' => $rule3->getConfiguration(),
-    ]);
-    $config_entity->save();
-
-    // Create a reaction rule to display a message when viewing a node of a type
-    // that is NOT enabled for scheduled unpublishing.
-    $rule4 = $this->expressionManager->createRule();
-    $rule4->addCondition('scheduler_condition_unpublishing_is_enabled',
-      ContextConfig::create()->map('node', 'node')->negateResult()
-    );
-    $message4 = 'RULES message 4. This node type is not enabled for scheduled unpublishing.';
-    $rule4->addAction('rules_system_message', ContextConfig::create()
-      ->setValue('message', $message4)
-      ->setValue('type', 'status')
-      );
-    $config_entity = $this->rulesStorage->create([
-      'id' => 'rule4',
-      'events' => [['event_name' => 'rules_entity_view:node']],
-      'expression' => $rule4->getConfiguration(),
-    ]);
-    $config_entity->save();
-
-    $assert = $this->assertSession();
-
-    // View the node and check the default position - that the node type is
-    // enabled for both publishing and unpublishing.
-    $this->drupalGet('node/' . $this->node->id());
-    $assert->pageTextContains($message1);
-    $assert->pageTextContains($message2);
-    $assert->pageTextNotContains($message3);
-    $assert->pageTextNotContains($message4);
-
-    // Turn off scheduled publishing for the node type and check the rules.
-    $this->nodetype->setThirdPartySetting('scheduler', 'publish_enable', FALSE)->save();
-    // Flushing the caches was not required when using WebTestBase but is needed
-    // after converting to BrowserTestBase.
-    drupal_flush_all_caches();
-    $this->drupalGet('node/' . $this->node->id());
-    $assert->pageTextNotContains($message1);
-    $assert->pageTextContains($message2);
-    $assert->pageTextContains($message3);
-    $assert->pageTextNotContains($message4);
-
-    // Turn off scheduled unpublishing for the node type and the check again.
-    $this->nodetype->setThirdPartySetting('scheduler', 'unpublish_enable', FALSE)->save();
-    drupal_flush_all_caches();
-    $this->drupalGet('node/' . $this->node->id());
-    $assert->pageTextNotContains($message1);
-    $assert->pageTextNotContains($message2);
-    $assert->pageTextContains($message3);
-    $assert->pageTextContains($message4);
-
-  }
-
-  /**
-   * Tests the conditions for whether a node is scheduled.
-   */
-  public function testNodeIsScheduledConditions() {
-    // Create a reaction rule to display a message when a node is updated and
-    // is not scheduled for publishing.
-    $rule5 = $this->expressionManager->createRule();
-    $rule5->addCondition('scheduler_condition_node_scheduled_for_publishing',
-      ContextConfig::create()->map('node', 'node')->negateResult()
-    );
-    $message5 = 'RULES message 5. This content is not scheduled for publishing.';
-    $rule5->addAction('rules_system_message', ContextConfig::create()
-      ->setValue('message', $message5)
-      ->setValue('type', 'status')
-      );
-    $config_entity = $this->rulesStorage->create([
-      'id' => 'rule5',
-      'events' => [['event_name' => 'rules_entity_update:node']],
-      'expression' => $rule5->getConfiguration(),
-    ]);
-    $config_entity->save();
-
-    // Create a reaction rule to display a message when a node is updated and
-    // is not scheduled for unpublishing.
-    $rule6 = $this->expressionManager->createRule();
-    $rule6->addCondition('scheduler_condition_node_scheduled_for_unpublishing',
-      ContextConfig::create()->map('node', 'node')->negateResult()
-    );
-    $message6 = 'RULES message 6. This content is not scheduled for unpublishing.';
-    $rule6->addAction('rules_system_message', ContextConfig::create()
-      ->setValue('message', $message6)
-      ->setValue('type', 'status')
-      );
-    $config_entity = $this->rulesStorage->create([
-      'id' => 'rule6',
-      'events' => [['event_name' => 'rules_entity_update:node']],
-      'expression' => $rule6->getConfiguration(),
-    ]);
-    $config_entity->save();
-
-    // Create a reaction rule to display a message when a node is updated and
-    // is scheduled for publishing.
-    $rule7 = $this->expressionManager->createRule();
-    $rule7->addCondition('scheduler_condition_node_scheduled_for_publishing',
-      ContextConfig::create()->map('node', 'node')
-    );
-    $message7 = 'RULES message 7. This content is scheduled for publishing.';
-    $rule7->addAction('rules_system_message', ContextConfig::create()
-      ->setValue('message', $message7)
-      ->setValue('type', 'status')
-      );
-    $config_entity = $this->rulesStorage->create([
-      'id' => 'rule7',
-      'events' => [['event_name' => 'rules_entity_update:node']],
-      'expression' => $rule7->getConfiguration(),
-    ]);
-    $config_entity->save();
-
-    $assert = $this->assertSession();
-
-    // Create a reaction rule to display a message when a node is updated and
-    // is scheduled for unpublishing.
-    $rule8 = $this->expressionManager->createRule();
-    $rule8->addCondition('scheduler_condition_node_scheduled_for_unpublishing',
-      ContextConfig::create()->map('node', 'node')
-    );
-    $message8 = 'RULES message 8. This content is scheduled for unpublishing.';
-    $rule8->addAction('rules_system_message', ContextConfig::create()
-      ->setValue('message', $message8)
-      ->setValue('type', 'status')
-      );
-    $config_entity = $this->rulesStorage->create([
-      'id' => 'rule8',
-      'events' => [['event_name' => 'rules_entity_update:node']],
-      'expression' => $rule8->getConfiguration(),
-    ]);
-    $config_entity->save();
-
-    $this->drupalLogin($this->schedulerUser);
-
-    // Edit the node but do not enter any scheduling dates.
-    $edit = [
-      'body[0][value]' => $this->randomString(30),
-    ];
-    $this->drupalPostForm('node/' . $this->node->id() . '/edit', $edit, 'Save');
-
-    $assert->pageTextContains($message5);
-    $assert->pageTextContains($message6);
-    $assert->pageTextNotContains($message7);
-    $assert->pageTextNotContains($message8);
-
-    // Edit the node and set a publish_on date.
-    $edit = [
-      'publish_on[0][value][date]' => date('Y-m-d', strtotime('+1 day', $this->requestTime)),
-      'publish_on[0][value][time]' => date('H:i:s', strtotime('+1 day', $this->requestTime)),
-    ];
-    $this->drupalPostForm('node/' . $this->node->id() . '/edit', $edit, 'Save');
-
-    $assert->pageTextNotContains($message5);
-    $assert->pageTextContains($message6);
-    $assert->pageTextContains($message7);
-    $assert->pageTextNotContains($message8);
-
-    // Edit the node and set an unpublish_on date.
-    $edit = [
-      'unpublish_on[0][value][date]' => date('Y-m-d', strtotime('+2 day', $this->requestTime)),
-      'unpublish_on[0][value][time]' => date('H:i:s', strtotime('+2 day', $this->requestTime)),
-    ];
-    $this->drupalPostForm('node/' . $this->node->id() . '/edit', $edit, 'Save');
-
-    $assert->pageTextNotContains($message5);
-    $assert->pageTextNotContains($message6);
-    $assert->pageTextContains($message7);
-    $assert->pageTextContains($message8);
-
-  }
-
-}
diff --git a/web/modules/scheduler/tests/src/Functional/SchedulerRulesEventsTest.php b/web/modules/scheduler/tests/src/Functional/SchedulerRulesEventsTest.php
deleted file mode 100644
index 38d651a3534e0620478d46f6330c6a67f709119f..0000000000000000000000000000000000000000
--- a/web/modules/scheduler/tests/src/Functional/SchedulerRulesEventsTest.php
+++ /dev/null
@@ -1,238 +0,0 @@
-<?php
-
-namespace Drupal\Tests\scheduler\Functional;
-
-use Drupal\rules\Context\ContextConfig;
-
-/**
- * Tests the six events that Scheduler provides for use in Rules module.
- *
- * @group scheduler
- * @group legacy
- * @todo Remove the 'legacy' tag when Rules no longer uses deprecated code.
- * @see https://www.drupal.org/project/scheduler/issues/2924353
- */
-class SchedulerRulesEventsTest extends SchedulerBrowserTestBase {
-
-  /**
-   * Additional modules required.
-   *
-   * @var array
-   */
-  protected static $modules = ['scheduler_rules_integration'];
-
-  /**
-   * {@inheritdoc}
-   */
-  public function setUp() {
-    parent::setUp();
-
-    $this->rulesStorage = $this->container->get('entity_type.manager')->getStorage('rules_reaction_rule');
-    $this->expressionManager = $this->container->get('plugin.manager.rules_expression');
-
-  }
-
-  /**
-   * Tests the six events provided by Scheduler.
-   *
-   * This class tests all six events provided by Scheduler, by creating six
-   * rules which are all active throughout the test. They are all checked in
-   * this one test class to make the tests stronger, as this will show not only
-   * that the correct events are triggered in the right places, but also
-   * that they are not triggered in the wrong places.
-   */
-  public function testRulesEvents() {
-
-    // Create six reaction rules, one for each event that Scheduler triggers.
-    $rule_data = [
-      1 => ['scheduler_new_node_is_scheduled_for_publishing_event', 'A new node is created and is scheduled for publishing.'],
-      2 => ['scheduler_existing_node_is_scheduled_for_publishing_event', 'An existing node is saved and is scheduled for publishing.'],
-      3 => ['scheduler_has_published_this_node_event', 'Scheduler has published this node during cron.'],
-      4 => ['scheduler_new_node_is_scheduled_for_unpublishing_event', 'A new node is created and is scheduled for unpublishing.'],
-      5 => ['scheduler_existing_node_is_scheduled_for_unpublishing_event', 'An existing node is saved and is scheduled for unpublishing.'],
-      6 => ['scheduler_has_unpublished_this_node_event', 'Scheduler has unpublished this node during cron.'],
-    ];
-    // PHPCS throws a false-positive 'variable $var is undefined' message when
-    // the variable is defined by list( ) syntax. To avoid the unwanted warnings
-    // we can wrap the section with @codingStandardsIgnoreStart and IgnoreEnd.
-    // @see https://www.drupal.org/project/coder/issues/2876245
-    // @codingStandardsIgnoreStart
-    foreach ($rule_data as $i => list($event_name, $description)) {
-      $rule[$i] = $this->expressionManager->createRule();
-      $message[$i] = 'RULES message ' . $i . '. ' . $description;
-      $rule[$i]->addAction('rules_system_message', ContextConfig::create()
-        ->setValue('message', $message[$i])
-        ->setValue('type', 'status')
-        );
-      $config_entity = $this->rulesStorage->create([
-        'id' => 'rule' . $i,
-        'events' => [['event_name' => $event_name]],
-        'expression' => $rule[$i]->getConfiguration(),
-      ]);
-      $config_entity->save();
-    }
-    // @codingStandardsIgnoreEnd
-
-    $this->drupalLogin($this->schedulerUser);
-
-    $assert = $this->assertSession();
-
-    // Create a node without any scheduled dates, using node/add/ not
-    // drupalCreateNode(), and check that no events are triggered.
-    $edit = [
-      'title[0][value]' => 'Test for no events',
-      'body[0][value]' => $this->randomString(30),
-    ];
-    $this->drupalPostForm('node/add/' . $this->type, $edit, 'Save');
-    $node = $this->drupalGetNodeByTitle($edit['title[0][value]']);
-    $assert->pageTextNotContains($message[1]);
-    $assert->pageTextNotContains($message[2]);
-    $assert->pageTextNotContains($message[3]);
-    $assert->pageTextNotContains($message[4]);
-    $assert->pageTextNotContains($message[5]);
-    $assert->pageTextNotContains($message[6]);
-
-    // Edit the node and check that no events are triggered.
-    $edit = [
-      'body[0][value]' => $this->randomString(30),
-    ];
-    $this->drupalPostForm('node/' . $node->id() . '/edit', $edit, 'Save');
-    $assert->pageTextNotContains($message[1]);
-    $assert->pageTextNotContains($message[2]);
-    $assert->pageTextNotContains($message[3]);
-    $assert->pageTextNotContains($message[4]);
-    $assert->pageTextNotContains($message[5]);
-    $assert->pageTextNotContains($message[6]);
-
-    // Create a new node with a publish-on date, and check that only event 1 is
-    // triggered.
-    $edit = [
-      'title[0][value]' => 'Create node with publish-on date',
-      'publish_on[0][value][date]' => date('Y-m-d', time() + 3),
-      'publish_on[0][value][time]' => date('H:i:s', time() + 3),
-      'body[0][value]' => $this->randomString(30),
-    ];
-    $this->drupalPostForm('node/add/' . $this->type, $edit, 'Save');
-    $node = $this->drupalGetNodeByTitle($edit['title[0][value]']);
-    $assert->pageTextContains($message[1]);
-    $assert->pageTextNotContains($message[2]);
-    $assert->pageTextNotContains($message[3]);
-    $assert->pageTextNotContains($message[4]);
-    $assert->pageTextNotContains($message[5]);
-    $assert->pageTextNotContains($message[6]);
-
-    // Edit this node and check that only event 2 is triggered.
-    $edit = [
-      'title[0][value]' => 'Edit node with publish-on date',
-      'body[0][value]' => $this->randomString(30),
-    ];
-    $this->drupalPostForm('node/' . $node->id() . '/edit', $edit, 'Save');
-    $assert->pageTextNotContains($message[1]);
-    $assert->pageTextContains($message[2]);
-    $assert->pageTextNotContains($message[3]);
-    $assert->pageTextNotContains($message[4]);
-    $assert->pageTextNotContains($message[5]);
-    $assert->pageTextNotContains($message[6]);
-
-    // Delay to ensure that the date entered is now in the past so that the node
-    // will be processed during cron, and assert that event 3 is triggered.
-    sleep(5);
-    $this->cronRun();
-    $this->drupalGet('admin/reports/dblog');
-    $assert->pageTextNotContains($message[1]);
-    $assert->pageTextNotContains($message[2]);
-    $assert->pageTextContains($message[3]);
-    $assert->pageTextNotContains($message[4]);
-    $assert->pageTextNotContains($message[5]);
-    $assert->pageTextNotContains($message[6]);
-
-    // Create a new node with an unpublish-on date, and check that only event 4
-    // is triggered.
-    $edit = [
-      'title[0][value]' => 'Create node with unpublish-on date',
-      'unpublish_on[0][value][date]' => date('Y-m-d', time() + 3),
-      'unpublish_on[0][value][time]' => date('H:i:s', time() + 3),
-      'body[0][value]' => $this->randomString(30),
-    ];
-    $this->drupalPostForm('node/add/' . $this->type, $edit, 'Save');
-    $node = $this->drupalGetNodeByTitle($edit['title[0][value]']);
-    $assert->pageTextNotContains($message[1]);
-    $assert->pageTextNotContains($message[2]);
-    $assert->pageTextNotContains($message[3]);
-    $assert->pageTextContains($message[4]);
-    $assert->pageTextNotContains($message[5]);
-    $assert->pageTextNotContains($message[6]);
-
-    // Edit this node and check that only event 5 is triggered.
-    $edit = [
-      'title[0][value]' => 'Edit node with unpublish-on date',
-      'body[0][value]' => $this->randomString(30),
-    ];
-    $this->drupalPostForm('node/' . $node->id() . '/edit', $edit, 'Save');
-    $assert->pageTextNotContains($message[1]);
-    $assert->pageTextNotContains($message[2]);
-    $assert->pageTextNotContains($message[3]);
-    $assert->pageTextNotContains($message[4]);
-    $assert->pageTextContains($message[5]);
-    $assert->pageTextNotContains($message[6]);
-
-    // Delay to ensure that the date entered is now in the past so that the node
-    // will be processed during cron, and assert that event 6 is triggered.
-    sleep(5);
-    $this->cronRun();
-    $this->drupalGet('admin/reports/dblog');
-    $assert->pageTextNotContains($message[1]);
-    $assert->pageTextNotContains($message[2]);
-    $assert->pageTextNotContains($message[3]);
-    $assert->pageTextNotContains($message[4]);
-    $assert->pageTextNotContains($message[5]);
-    $assert->pageTextContains($message[6]);
-
-    // Create a new node with both publish-on and unpublish-on dates, and check
-    // that events 1 and event 4 are both triggered.
-    $edit = [
-      'title[0][value]' => 'Create node with both dates',
-      'publish_on[0][value][date]' => date('Y-m-d', time() + 3),
-      'publish_on[0][value][time]' => date('H:i:s', time() + 3),
-      'unpublish_on[0][value][date]' => date('Y-m-d', time() + 4),
-      'unpublish_on[0][value][time]' => date('H:i:s', time() + 4),
-      'body[0][value]' => $this->randomString(30),
-    ];
-    $this->drupalPostForm('node/add/' . $this->type, $edit, 'Save');
-    $node = $this->drupalGetNodeByTitle($edit['title[0][value]']);
-    $assert->pageTextContains($message[1]);
-    $assert->pageTextNotContains($message[2]);
-    $assert->pageTextNotContains($message[3]);
-    $assert->pageTextContains($message[4]);
-    $assert->pageTextNotContains($message[5]);
-    $assert->pageTextNotContains($message[6]);
-
-    // Edit this node and check that events 2 and 5 are triggered.
-    $edit = [
-      'title[0][value]' => 'Edit node with both dates',
-      'body[0][value]' => $this->randomString(30),
-    ];
-    $this->drupalPostForm('node/' . $node->id() . '/edit', $edit, 'Save');
-    $node = $this->drupalGetNodeByTitle($edit['title[0][value]']);
-    $assert->pageTextNotContains($message[1]);
-    $assert->pageTextContains($message[2]);
-    $assert->pageTextNotContains($message[3]);
-    $assert->pageTextNotContains($message[4]);
-    $assert->pageTextContains($message[5]);
-    $assert->pageTextNotContains($message[6]);
-
-    // Delay to ensure that the dates are now in the past so that the node will
-    // be processed during cron, and assert that events 3, 5 & 6 are triggered.
-    sleep(6);
-    $this->cronRun();
-    $this->drupalGet('admin/reports/dblog');
-    $assert->pageTextNotContains($message[1]);
-    $assert->pageTextNotContains($message[2]);
-    $assert->pageTextContains($message[3]);
-    $assert->pageTextNotContains($message[4]);
-    $assert->pageTextContains($message[5]);
-    $assert->pageTextContains($message[6]);
-
-  }
-
-}
diff --git a/web/modules/scheduler/tests/src/Functional/SchedulerScheduledContentListAccessTest.php b/web/modules/scheduler/tests/src/Functional/SchedulerScheduledContentListAccessTest.php
deleted file mode 100644
index dd95bbd37a45f7bca6d9d1026cba1baa999e76eb..0000000000000000000000000000000000000000
--- a/web/modules/scheduler/tests/src/Functional/SchedulerScheduledContentListAccessTest.php
+++ /dev/null
@@ -1,148 +0,0 @@
-<?php
-
-namespace Drupal\Tests\scheduler\Functional;
-
-/**
- * Tests access to the scheduled content overview page and user tab.
- *
- * @group scheduler
- */
-class SchedulerScheduledContentListAccessTest extends SchedulerBrowserTestBase {
-
-  /**
-   * Additional modules required.
-   *
-   * @var array
-   */
-  protected static $modules = ['views'];
-
-  /**
-   * {@inheritdoc}
-   */
-  public function setUp() {
-    parent::setUp();
-
-    $base_permissions = [
-      'access content',
-      'create ' . $this->type . ' content',
-      'view own unpublished content',
-    ];
-
-    $this->editorUser = $this->drupalCreateUser(array_merge($base_permissions, ['access content overview']));
-    $this->schedulerUser = $this->drupalCreateUser(array_merge($base_permissions, ['schedule publishing of nodes']));
-    $this->schedulerManager = $this->drupalCreateUser(array_merge($base_permissions, ['view scheduled content']));
-
-    // Create nodes scheduled for publishing and for unpublishing.
-    $this->node1 = $this->drupalCreateNode([
-      'title' => 'Node created by Scheduler User for publishing',
-      'uid' => $this->schedulerUser->id(),
-      'status' => FALSE,
-      'type' => $this->type,
-      'publish_on' => strtotime('+1 week'),
-    ]);
-    $this->node2 = $this->drupalCreateNode([
-      'title' => 'Node created by Scheduler User for unpublishing',
-      'uid' => $this->schedulerUser->id(),
-      'status' => TRUE,
-      'type' => $this->type,
-      'unpublish_on' => strtotime('+1 week'),
-    ]);
-    $this->node3 = $this->drupalCreateNode([
-      'title' => 'Node created by Scheduler Manager for publishing',
-      'uid' => $this->schedulerManager->id(),
-      'status' => FALSE,
-      'type' => $this->type,
-      'publish_on' => strtotime('+1 week'),
-    ]);
-    $this->node4 = $this->drupalCreateNode([
-      'title' => 'Node created by Scheduler Manager for unpublishing',
-      'uid' => $this->schedulerManager->id(),
-      'status' => TRUE,
-      'type' => $this->type,
-      'unpublish_on' => strtotime('+1 week'),
-    ]);
-  }
-
-  /**
-   * Tests the scheduled content tab on the user page.
-   */
-  public function testViewScheduledContentUser() {
-    $assert = $this->assertSession();
-
-    // Access a scheduled content user tab as an anonymous visitor.
-    $this->drupalGet("user/{$this->schedulerUser->id()}/scheduled");
-    // An anonymous visitor cannot access a user's scheduled content tab.
-    $assert->statusCodeEquals(403);
-
-    // Try to access a users own scheduled content tab when they do not have
-    // any scheduler permissions. This should give "403 Access Denied".
-    $this->drupalLogin($this->editorUser);
-    $this->drupalGet("user/{$this->editorUser->id()}/scheduled");
-    $assert->statusCodeEquals(403);
-
-    // Access a users own scheduled content tab when they have only
-    // 'schedule publishing of nodes' permission. This will give "200 OK".
-    $this->drupalLogin($this->schedulerUser);
-    $this->drupalGet("user/{$this->schedulerUser->id()}/scheduled");
-    $assert->statusCodeEquals(200);
-    $assert->pageTextContains('Node created by Scheduler User for publishing');
-    $assert->pageTextContains('Node created by Scheduler User for unpublishing');
-    $assert->pageTextNotContains('Node created by Scheduler Manager for unpublishing');
-
-    // Access another users scheduled content tab as "Scheduler User". This
-    // should not be possible and will give "403 Access Denied".
-    $this->drupalGet("user/{$this->schedulerManager->id()}/scheduled");
-    $assert->statusCodeEquals(403);
-
-    // Access the users own scheduled content tab as "Scheduler Manager" with
-    // only 'view scheduled content' permission.
-    $this->drupalLogin($this->schedulerManager);
-    $this->drupalGet("user/{$this->schedulerManager->id()}/scheduled");
-    $assert->statusCodeEquals(200);
-    $assert->pageTextContains('Node created by Scheduler Manager for publishing');
-    $assert->pageTextContains('Node created by Scheduler Manager for unpublishing');
-    $assert->pageTextNotContains('Node created by Scheduler User for unpublishing');
-
-    // Access another users scheduled content tab as "Scheduler Manager".
-    // The published and unpublished content should be listed.
-    $this->drupalGet("user/{$this->schedulerUser->id()}/scheduled");
-    $assert->statusCodeEquals(200);
-    $assert->pageTextContains('Node created by Scheduler User for publishing');
-    $assert->pageTextContains('Node created by Scheduler User for unpublishing');
-  }
-
-  /**
-   * Tests the scheduled content overview.
-   */
-  public function testViewScheduledContentOverview() {
-    $assert = $this->assertSession();
-
-    // Access the scheduled content overview as anonymous visitor.
-    $this->drupalGet('admin/content/scheduled');
-    $assert->statusCodeEquals(403);
-
-    // Access the scheduled content overview as "Editor" without any
-    // scheduler permissions.
-    $this->drupalLogin($this->editorUser);
-    $this->drupalGet('admin/content/scheduled');
-    $assert->statusCodeEquals(403);
-
-    // Access the scheduled content overview as "Scheduler User" with only
-    // 'schedule publishing of nodes' permission.
-    $this->drupalLogin($this->schedulerUser);
-    $this->drupalGet('admin/content/scheduled');
-    $assert->statusCodeEquals(403);
-
-    // Access the scheduled content overview as "Scheduler Manager" with only
-    // 'view scheduled content' permission. They should be able to see the
-    // scheduled published and unpublished content by all users.
-    $this->drupalLogin($this->schedulerManager);
-    $this->drupalGet('admin/content/scheduled');
-    $assert->statusCodeEquals(200);
-    $assert->pageTextContains('Node created by Scheduler User for publishing');
-    $assert->pageTextContains('Node created by Scheduler User for unpublishing');
-    $assert->pageTextContains('Node created by Scheduler Manager for publishing');
-    $assert->pageTextContains('Node created by Scheduler Manager for unpublishing');
-  }
-
-}
diff --git a/web/modules/scheduler/tests/src/Functional/SchedulerStatusReportTest.php b/web/modules/scheduler/tests/src/Functional/SchedulerStatusReportTest.php
index bb9698aa030779d37480eeac7d2fa4336e119fe5..844148674f164934ae2c724fb7521a56e8784764 100644
--- a/web/modules/scheduler/tests/src/Functional/SchedulerStatusReportTest.php
+++ b/web/modules/scheduler/tests/src/Functional/SchedulerStatusReportTest.php
@@ -22,12 +22,12 @@ public function testStatusReport() {
     $this->assertSession()->pageTextContains('In most cases the server time should match Coordinated Universal Time (UTC) / Greenwich Mean Time (GMT)');
 
     $admin_regional_settings = Url::fromRoute('system.regional_settings');
-    $this->assertLink('changed by admin users');
-    $this->assertLinkByHref($admin_regional_settings->toString());
+    $this->assertSession()->linkExists('changed by admin users');
+    $this->assertSession()->linkByHrefExists($admin_regional_settings->toString());
 
     $account_edit = Url::fromRoute('entity.user.edit_form', ['user' => $this->adminUser->id()]);
-    $this->assertLink('user account');
-    $this->assertLinkByHref($account_edit->toString());
+    $this->assertSession()->linkExists('user account');
+    $this->assertSession()->linkByHrefExists($account_edit->toString());
   }
 
 }
diff --git a/web/modules/scheduler/tests/src/Functional/SchedulerTokenReplaceTest.php b/web/modules/scheduler/tests/src/Functional/SchedulerTokenReplaceTest.php
index 784f960034b221687413a9664c6057f7d6590f84..042ab30966e8e2911eb0ea51a24f409cb8320951 100644
--- a/web/modules/scheduler/tests/src/Functional/SchedulerTokenReplaceTest.php
+++ b/web/modules/scheduler/tests/src/Functional/SchedulerTokenReplaceTest.php
@@ -11,25 +11,27 @@ class SchedulerTokenReplaceTest extends SchedulerBrowserTestBase {
 
   /**
    * Creates a node, then tests the tokens generated from it.
+   *
+   * @dataProvider dataSchedulerTokenReplacement()
    */
-  public function testSchedulerTokenReplacement() {
+  public function testSchedulerTokenReplacement($entityTypeId, $bundle) {
     $this->drupalLogin($this->schedulerUser);
     // Define timestamps for consistent use when repeated throughout this test.
     $publish_on_timestamp = $this->requestTime + 3600;
     $unpublish_on_timestamp = $this->requestTime + 7200;
 
-    // Create an unpublished page with scheduled dates.
-    $node = $this->drupalCreateNode([
-      'type' => $this->type,
+    // Create an unpublished entity with scheduled dates.
+    $entity = $this->createEntity($entityTypeId, $bundle, [
       'status' => FALSE,
       'publish_on' => $publish_on_timestamp,
       'unpublish_on' => $unpublish_on_timestamp,
     ]);
-    // Show that the node is scheduled.
-    $this->drupalGet('admin/content/scheduled');
+    // Check that the entity is scheduled.
+    $this->assertFalse($entity->isPublished(), 'The entity is not published');
+    $this->assertNotEmpty($entity->publish_on->value, 'The entity has a publish_on date');
+    $this->assertNotEmpty($entity->unpublish_on->value, 'The entity has an unpublish_on date');
 
     // Create array of test case data.
-    // @TODO Convert this test to use @dataProvider instead of array and loop?
     $test_cases = [
       ['token_format' => '', 'date_format' => 'medium', 'custom' => ''],
       ['token_format' => ':long', 'date_format' => 'long', 'custom' => ''],
@@ -41,18 +43,21 @@ public function testSchedulerTokenReplacement() {
       ],
     ];
 
+    $storage = $this->entityStorageObject($entityTypeId);
     foreach ($test_cases as $test_data) {
-      // Edit the node and set the body tokens to use the format being tested.
+      // Edit the entity and set the body tokens to use the format being tested.
       $edit = [
-        'body[0][value]' => 'Publish on: [node:scheduler-publish' . $test_data['token_format'] . ']. Unpublish on: [node:scheduler-unpublish' . $test_data['token_format'] . '].',
+        "{$this->bodyField($entityTypeId)}[0][value]" => "Publish on: [{$entityTypeId}:scheduler-publish{$test_data['token_format']}]. Unpublish on: [{$entityTypeId}:scheduler-unpublish{$test_data['token_format']}].",
       ];
-      $this->drupalPostForm('node/' . $node->id() . '/edit', $edit, 'Save');
-      $this->drupalGet('node/' . $node->id());
+      $this->drupalGet($entity->toUrl('edit-form'));
+      $this->submitForm($edit, 'Save');
+      // View the entity.
+      $this->drupalGet($entity->toUrl());
 
-      // Refresh the node and get the body output value.
-      $this->nodeStorage->resetCache([$node->id()]);
-      $node = $this->nodeStorage->load($node->id());
-      $body_output = \Drupal::token()->replace($node->body->value, ['node' => $node]);
+      // Refresh the entity and get the body output value using token replace.
+      $storage->resetCache([$entity->id()]);
+      $entity = $storage->load($entity->id());
+      $body_output = \Drupal::token()->replace($entity->{$this->bodyField($entityTypeId)}->value, ["$entityTypeId" => $entity]);
 
       // Create the expected text for the body.
       $publish_on_date = $this->dateFormatter->format($publish_on_timestamp, $test_data['date_format'], $test_data['custom']);
@@ -63,4 +68,18 @@ public function testSchedulerTokenReplacement() {
     }
   }
 
+  /**
+   * Provides test data for TokenReplacement test.
+   *
+   * This test is not run for Media entities because there is no body field.
+   *
+   * @return array
+   *   Each array item has the values: [entity type id, bundle id].
+   */
+  public function dataSchedulerTokenReplacement() {
+    $data = $this->dataStandardEntityTypes();
+    unset($data['#media']);
+    return $data;
+  }
+
 }
diff --git a/web/modules/scheduler/tests/src/Functional/SchedulerValidationTest.php b/web/modules/scheduler/tests/src/Functional/SchedulerValidationTest.php
index af8253f279aa7e126bf4da9d1bd0f8b1d90cf512..eb27cbc27a9076dda1e693d6ae26db707b7d654c 100644
--- a/web/modules/scheduler/tests/src/Functional/SchedulerValidationTest.php
+++ b/web/modules/scheduler/tests/src/Functional/SchedulerValidationTest.php
@@ -13,74 +13,62 @@ class SchedulerValidationTest extends SchedulerBrowserTestBase {
    * Tests the validation when editing a node.
    *
    * The 'required' checks and 'dates in the past' checks are handled in other
-   * tests. This test checks validation when fields interact.
+   * tests. This test checks validation when the two fields interact, and covers
+   * the error message text stored in the following constraint variables:
+   *   $messageUnpublishOnRequiredIfPublishOnEntered
+   *   $messageUnpublishOnRequiredIfPublishing
+   *   $messageUnpublishOnTooEarly.
+   *
+   * @dataProvider dataStandardEntityTypes()
    */
-  public function testValidationDuringEdit() {
+  public function testValidationDuringEdit($entityTypeId, $bundle) {
     $this->drupalLogin($this->adminUser);
 
-    // Set unpublishing to be required.
-    $this->nodetype->setThirdPartySetting('scheduler', 'unpublish_required', TRUE)->save();
+    // Set unpublishing to be required for this entity type.
+    $this->entityTypeObject($entityTypeId)->setThirdPartySetting('scheduler', 'unpublish_required', TRUE)->save();
+
+    // Create an unpublished entity.
+    $entity = $this->createEntity($entityTypeId, $bundle, ['status' => FALSE]);
 
-    // Create an unpublished page node, then edit the node and check that if a
-    // publish-on date is entered then an unpublish-on date is also needed.
-    $node = $this->drupalCreateNode([
-      'type' => $this->type,
-      'status' => FALSE,
-    ]);
+    // Edit the unpublished entity and try to save a publish-on date.
     $edit = [
       'publish_on[0][value][date]' => date('Y-m-d', strtotime('+1 day', $this->requestTime)),
       'publish_on[0][value][time]' => date('H:i:s', strtotime('+1 day', $this->requestTime)),
     ];
-    $this->drupalGet('node/' . $node->id() . '/edit');
-
-    // At core 8.4 an enhancement will be committed to change the 'save and ...'
-    // button into a 'save' with a corresponding status checkbox. This test has
-    // to pass at 8.3 but the core change will not be backported. Hence derive
-    // the button text and whether we need a 'status'field.
-    // @see https://www.drupal.org/node/2873108
-    $checkbox = $this->xpath('//input[@type="checkbox" and @id="edit-status-value"]');
-
-    $this->submitForm($edit, $checkbox ? 'Save' : 'Save and keep unpublished');
+    $this->drupalGet($entity->toUrl('edit-form'));
+    $this->submitForm($edit, 'Save');
     // Check that validation prevents entering a publish-on date with no
     // unpublish-on date if unpublishing is required.
     $this->assertSession()->pageTextContains("If you set a 'publish on' date then you must also set an 'unpublish on' date.");
-    $this->assertSession()->pageTextNotContains(sprintf('%s %s has been updated.', $this->typeName, $node->title->value));
+    $this->assertSession()->pageTextNotMatches('/has been (updated|successfully saved)/');
+
+    // Create an unpublished entity.
+    $entity = $this->createEntity($entityTypeId, $bundle, ['status' => FALSE]);
 
-    // Create an unpublished page node, then edit the node and check that if the
-    // status is changed to published, then an unpublish-on date is also needed.
-    $node = $this->drupalCreateNode([
-      'type' => $this->type,
-      'status' => FALSE,
-    ]);
-    if ($checkbox) {
-      $edit = ['status[value]' => TRUE];
-    }
-    else {
-      $edit = [];
-    }
-    $this->drupalPostForm('node/' . $node->id() . '/edit', $edit, $checkbox ? 'Save' : 'Save and publish');
-    // Check that validation prevents publishing the node directly without an
+    // Edit the unpublished entity and try to change the status to 'published'.
+    $edit = ['status[value]' => TRUE];
+    $this->drupalGet($entity->toUrl('edit-form'));
+    $this->submitForm($edit, 'Save');
+    // Check that validation prevents publishing the entity directly without an
     // unpublish-on date if unpublishing is required.
-    $this->assertSession()->pageTextContains("Either you must set an 'unpublish on' date or save this node as unpublished.");
-    $this->assertSession()->pageTextNotContains(sprintf('%s %s has been updated.', $this->typeName, $node->title->value));
+    $this->assertSession()->pageTextContains("Either you must set an 'unpublish on' date or save as unpublished.");
+    $this->assertSession()->pageTextNotMatches('/has been (updated|successfully saved)/');
 
-    // Create an unpublished node, edit the node and check that if both dates
-    // are entered then the unpublish date is later than the publish date.
-    $node = $this->drupalCreateNode([
-      'type' => $this->type,
-      'status' => FALSE,
-    ]);
+    // Create an unpublished entity, and try to edit and save with a publish-on
+    // date later than the unpublish-on date.
+    $entity = $this->createEntity($entityTypeId, $bundle, ['status' => FALSE]);
     $edit = [
-      'publish_on[0][value][date]' => $this->dateFormatter->format($this->requestTime + 8100, 'custom', 'Y-m-d'),
-      'publish_on[0][value][time]' => $this->dateFormatter->format($this->requestTime + 8100, 'custom', 'H:i:s'),
+      'publish_on[0][value][date]' => $this->dateFormatter->format($this->requestTime + 7200, 'custom', 'Y-m-d'),
+      'publish_on[0][value][time]' => $this->dateFormatter->format($this->requestTime + 7200, 'custom', 'H:i:s'),
       'unpublish_on[0][value][date]' => $this->dateFormatter->format($this->requestTime + 1800, 'custom', 'Y-m-d'),
       'unpublish_on[0][value][time]' => $this->dateFormatter->format($this->requestTime + 1800, 'custom', 'H:i:s'),
     ];
-    $this->drupalPostForm('node/' . $node->id() . '/edit', $edit, $checkbox ? 'Save' : 'Save and keep unpublished');
+    $this->drupalGet($entity->toUrl('edit-form'));
+    $this->submitForm($edit, 'Save');
     // Check that validation prevents entering an unpublish-on date which is
     // earlier than the publish-on date.
     $this->assertSession()->pageTextContains("The 'unpublish on' date must be later than the 'publish on' date.");
-    $this->assertSession()->pageTextNotContains(sprintf('%s %s has been updated.', $this->typeName, $node->title->value));
+    $this->assertSession()->pageTextNotMatches('/has been (updated|successfully saved)/');
   }
 
 }
diff --git a/web/modules/scheduler/tests/src/Functional/SchedulerViewsAccessTest.php b/web/modules/scheduler/tests/src/Functional/SchedulerViewsAccessTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..ded873e6f1e372b857142d6644cd3fee3d3f9ad1
--- /dev/null
+++ b/web/modules/scheduler/tests/src/Functional/SchedulerViewsAccessTest.php
@@ -0,0 +1,236 @@
+<?php
+
+namespace Drupal\Tests\scheduler\Functional;
+
+/**
+ * Tests access to the scheduled content overview page and user tab.
+ *
+ * @group scheduler
+ */
+class SchedulerViewsAccessTest extends SchedulerBrowserTestBase {
+
+  /**
+   * Additional modules required.
+   *
+   * @var array
+   */
+  protected static $modules = ['views'];
+
+  /**
+   * Create users and scheduled content for the entity type being tested.
+   */
+  protected function createScheduledItems($entityTypeId, $bundle) {
+    // For backwards-compatibility the node permission names have to end with
+    // 'nodes' and 'content'. For all other entity types we use $entityTypeId.
+    if ($entityTypeId == 'node') {
+      $edit_key = 'nodes';
+      $view_key = 'content';
+    }
+    else {
+      $edit_key = $view_key = $entityTypeId;
+    }
+    // "view own unpublished $view_key" is needed for Products. It is not
+    // required for Node or Media, and does not exist for Taxonomy terms.
+    $base_permissions = ($entityTypeId == 'commerce_product') ? ["view own unpublished $view_key"] : [];
+
+    $this->webUser = $this->drupalCreateUser();
+    $this->webUser->set('name', 'Webisa the Web User')->save();
+
+    $this->schedulerEditor = $this->drupalCreateUser(array_merge($base_permissions, ["schedule publishing of $edit_key"]));
+    $this->schedulerEditor->set('name', 'Eddie the Scheduler Editor')->save();
+
+    $this->schedulerViewer = $this->drupalCreateUser(array_merge($base_permissions, ["view scheduled $view_key"]));
+    $this->schedulerViewer->set('name', 'Vicenza the Scheduler Viewer')->save();
+
+    $this->addPermissionsToUser($this->adminUser, ['access user profiles']);
+
+    // Create content scheduled for publishing and for unpublishing. The first
+    // two are authored by schedulerEditor, the second two by schedulerViewer.
+    $this->createEntity($entityTypeId, $bundle, [
+      'title' => "$entityTypeId created by Scheduler Editor for publishing",
+      'uid' => $this->schedulerEditor->id(),
+      'status' => FALSE,
+      'publish_on' => strtotime('+1 week'),
+    ]);
+    $this->createEntity($entityTypeId, $bundle, [
+      'title' => "$entityTypeId created by Scheduler Editor for unpublishing",
+      'uid' => $this->schedulerEditor->id(),
+      'status' => TRUE,
+      'unpublish_on' => strtotime('+1 week'),
+    ]);
+    $this->createEntity($entityTypeId, $bundle, [
+      'title' => "$entityTypeId created by Scheduler Viewer for publishing",
+      'uid' => $this->schedulerViewer->id(),
+      'status' => FALSE,
+      'publish_on' => strtotime('+1 week'),
+    ]);
+    $this->createEntity($entityTypeId, $bundle, [
+      'title' => "$entityTypeId created by Scheduler Viewer for unpublishing",
+      'uid' => $this->schedulerViewer->id(),
+      'status' => TRUE,
+      'unpublish_on' => strtotime('+1 week'),
+    ]);
+  }
+
+  /**
+   * Tests the scheduled content tab on the user page.
+   *
+   * @dataProvider dataViewScheduledContentUser()
+   */
+  public function testViewScheduledContentUser($entityTypeId, $bundle) {
+    $this->createScheduledItems($entityTypeId, $bundle);
+    $url_end = ($entityTypeId == 'node') ? 'scheduled' : "scheduled_{$entityTypeId}";
+    $assert = $this->assertSession();
+
+    // Try to access a scheduled content user tab as an anonymous visitor. This
+    // should not be allowed, and will give "403 Access Denied".
+    $this->drupalGet("user/{$this->schedulerEditor->id()}/$url_end");
+    $assert->statusCodeEquals(403);
+
+    // Try to access a user's own scheduled content tab when they do not have
+    // any scheduler permissions. This should give "403 Access Denied".
+    $this->drupalLogin($this->webUser);
+    $this->drupalGet("user/{$this->webUser->id()}/$url_end");
+    $assert->statusCodeEquals(403);
+
+    // Access a user's own scheduled content tab when they have only
+    // 'schedule publishing of {type}' permission. This should give "200 OK".
+    $this->drupalLogin($this->schedulerEditor);
+    $this->drupalGet("user/{$this->schedulerEditor->id()}/$url_end");
+    $assert->statusCodeEquals(200);
+    $assert->pageTextContains("$entityTypeId created by Scheduler Editor for publishing");
+    $assert->pageTextContains("$entityTypeId created by Scheduler Editor for unpublishing");
+    $assert->pageTextNotContains("$entityTypeId created by Scheduler Viewer for publishing");
+    $assert->pageTextNotContains("$entityTypeId created by Scheduler Viewer for unpublishing");
+
+    // Access another user's scheduled content tab. This should not be possible
+    // and will give "403 Access Denied".
+    $this->drupalGet("user/{$this->schedulerViewer->id()}/$url_end");
+    $assert->statusCodeEquals(403);
+
+    // Try to access a user's own scheduled content tab when that user only has
+    // 'view scheduled {type}' and not 'schedule publishing of {type}'. This is
+    // allowed and should give "200 OK" and show the users scheduled items.
+    $this->drupalLogin($this->schedulerViewer);
+    $this->drupalGet("user/{$this->schedulerViewer->id()}/$url_end");
+    $assert->statusCodeEquals(200);
+    $assert->pageTextNotContains("$entityTypeId created by Scheduler Editor for publishing");
+    $assert->pageTextNotContains("$entityTypeId created by Scheduler Editor for unpublishing");
+    $assert->pageTextContains("$entityTypeId created by Scheduler Viewer for publishing");
+    $assert->pageTextContains("$entityTypeId created by Scheduler Viewer for unpublishing");
+
+    // Access another user's scheduled content tab. This should not be possible
+    // and will give "403 Access Denied".
+    $this->drupalGet("user/{$this->schedulerEditor->id()}/$url_end");
+    $assert->statusCodeEquals(403);
+
+    // Log in as Admin who has 'access user profiles' permission and access the
+    // user who can schedule content. This is allowed and the content just for
+    // that user should be listed.
+    $this->drupalLogin($this->adminUser);
+    $this->drupalGet("user/{$this->schedulerEditor->id()}/$url_end");
+    $assert->statusCodeEquals(200);
+    $assert->pageTextContains("$entityTypeId created by Scheduler Editor for publishing");
+    $assert->pageTextContains("$entityTypeId created by Scheduler Editor for unpublishing");
+    $assert->pageTextNotContains("$entityTypeId created by Scheduler Viewer for publishing");
+    $assert->pageTextNotContains("$entityTypeId created by Scheduler Viewer for unpublishing");
+
+    // Try to access the scheduled tab for a user who cannot schedule content
+    // themselves but can view their scheduled content if scheduled by someone
+    // else. This should give "200 OK" and the scheduled items will be shown.
+    $this->drupalGet("user/{$this->schedulerViewer->id()}/$url_end");
+    $assert->statusCodeEquals(200);
+    $assert->pageTextNotContains("$entityTypeId created by Scheduler Editor for publishing");
+    $assert->pageTextNotContains("$entityTypeId created by Scheduler Editor for unpublishing");
+    $assert->pageTextContains("$entityTypeId created by Scheduler Viewer for publishing");
+    $assert->pageTextContains("$entityTypeId created by Scheduler Viewer for unpublishing");
+  }
+
+  /**
+   * Provides test data for user view test.
+   *
+   * There is no user view for scheduled Commerce Products or Taxonomy Terms so
+   * these entity types are removed from the user view test.
+   *
+   * @return array
+   *   Each array item has the values: [entity type id, bundle id].
+   */
+  public function dataViewScheduledContentUser() {
+    $data = $this->dataStandardEntityTypes();
+    unset($data['#commerce_product']);
+    unset($data['#taxonomy_term']);
+    return $data;
+  }
+
+  /**
+   * Tests the scheduled content overview.
+   *
+   * @dataProvider dataStandardEntityTypes()
+   */
+  public function testViewScheduledContentOverview($entityTypeId, $bundle) {
+    $this->createScheduledItems($entityTypeId, $bundle);
+    $scheduled_url = $this->adminUrl('scheduled', $entityTypeId, $bundle);
+    $assert = $this->assertSession();
+
+    // Try to access the scheduled content overview as an anonymous visitor.
+    $this->drupalGet($scheduled_url);
+    $assert->statusCodeEquals(403);
+
+    // Try to access the scheduled content overview as a user who has no
+    // scheduler permissions. This should not be possible.
+    $this->drupalLogin($this->webUser);
+    $this->drupalGet($scheduled_url);
+    $assert->statusCodeEquals(403);
+
+    // Try to access the scheduled content overview as a user with only
+    // 'schedule publishing of {type}' permission. This should not be possible.
+    $this->drupalLogin($this->schedulerEditor);
+    $this->drupalGet($scheduled_url);
+    $assert->statusCodeEquals(403);
+
+    // Access the scheduled content overview as a user who only has
+    // 'view scheduled {type}' permission. This is allowed and they should see
+    // the scheduled content for all users.
+    $this->drupalLogin($this->schedulerViewer);
+    $this->drupalGet($scheduled_url);
+    $assert->statusCodeEquals(200);
+    // Unpublished nodes, media items and taxonomy terms by other users are
+    // listed but products are not. Therefore do not check for the unpublished
+    // product by Scheduler Editor here.
+    if ($entityTypeId != 'commerce_product') {
+      $assert->pageTextContains("$entityTypeId created by Scheduler Editor for publishing");
+    }
+    $assert->pageTextContains("$entityTypeId created by Scheduler Editor for unpublishing");
+    $assert->pageTextContains("$entityTypeId created by Scheduler Viewer for publishing");
+    $assert->pageTextContains("$entityTypeId created by Scheduler Viewer for unpublishing");
+
+    // Disable the scheduled view.
+    $view_ids = [
+      'node' => 'scheduler_scheduled_content',
+      'media' => 'scheduler_scheduled_media',
+      'commerce_product' => 'scheduler_scheduled_commerce_product',
+      'taxonomy_term' => 'scheduler_scheduled_taxonomy_term',
+    ];
+    $view = $this->container->get('entity_type.manager')->getStorage('view')->load($view_ids[$entityTypeId]);
+    $view->disable()->save();
+
+    // Attempt to view the scheduled entity page. Interactively this gives a
+    // '404 page not found' error, but in phpunit it is served with a 200 code.
+    // However the page is empty so we can check that the content is not shown.
+    $this->drupalGet($scheduled_url);
+    $assert->pageTextNotContains("$entityTypeId created by Scheduler Editor for unpublishing");
+
+    // Log in as admin and check that access to the overview page is unaffected.
+    $this->drupalLogin($this->adminUser);
+    $this->drupalGet($this->adminUrl('collection', $entityTypeId, $bundle));
+    $assert->statusCodeEquals(200);
+    $assert->pageTextContains("$entityTypeId created by Scheduler Editor for unpublishing");
+
+    // Delete the view and check again that the overview remains accessible.
+    $view->delete();
+    $this->drupalGet($this->adminUrl('collection', $entityTypeId, $bundle));
+    $assert->statusCodeEquals(200);
+    $assert->pageTextContains("$entityTypeId created by Scheduler Editor for unpublishing");
+  }
+
+}
diff --git a/web/modules/scheduler/tests/src/Functional/SchedulerWorkbenchModerationTest.php b/web/modules/scheduler/tests/src/Functional/SchedulerWorkbenchModerationTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..dcb03f96571243ea1504dc6c3b3cdacfcdf5ed96
--- /dev/null
+++ b/web/modules/scheduler/tests/src/Functional/SchedulerWorkbenchModerationTest.php
@@ -0,0 +1,80 @@
+<?php
+
+namespace Drupal\Tests\scheduler\Functional;
+
+/**
+ * Tests Scheduler with Workbench Moderation installed.
+ *
+ * @group scheduler
+ */
+class SchedulerWorkbenchModerationTest extends SchedulerBrowserTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setUp(): void {
+    parent::setUp();
+    // This test class is "optional" and will be run if the workbench_moderation
+    // modules are available. This allows testing with Drupal 9 but also will
+    // not fail with Drupal 10, where the workbench moderation modules are not
+    // compatible. See https://www.drupal.org/project/scheduler/issues/3314267
+    $modulesList = \Drupal::service('extension.list.module')->getList();
+    if (!isset($modulesList['workbench_moderation']) || !isset($modulesList['workbench_moderation_actions'])) {
+      $this->markTestSkipped('Skipping test because the workbench moderation module(s) are not available.');
+    }
+    else {
+      // The workbench_moderation module is available so install it.
+      // workbench_moderation_actions is installed later.
+      \Drupal::service('module_installer')->install(['workbench_moderation']);
+    }
+  }
+
+  /**
+   * Helper function to test publishing and unpublishing via cron.
+   */
+  public function schedulingWithWorkbenchModeration($type) {
+    $this->drupalLogin($this->schedulerUser);
+
+    // Create a node that is scheduled for publishing.
+    $settings = [
+      'publish_on' => strtotime('-1 day'),
+      'status' => FALSE,
+      'type' => $type,
+      'title' => "{$type} for publishing",
+    ];
+    $node = $this->drupalCreateNode($settings);
+
+    // Run cron and check that the node has been published successfully.
+    scheduler_cron();
+    $this->nodeStorage->resetCache([$node->id()]);
+    $node = $this->nodeStorage->load($node->id());
+    $this->assertTrue($node->isPublished(), "The node should be published after cron");
+
+    // Set a date for unpublishing the node.
+    $node->set('unpublish_on', strtotime('-1 day'))->save();
+
+    // Run cron and check that the node has been unpublished successfully.
+    scheduler_cron();
+    $this->nodeStorage->resetCache([$node->id()]);
+    $node = $this->nodeStorage->load($node->id());
+    $this->assertFalse($node->isPublished(), "The node should be unpublished after cron");
+  }
+
+  /**
+   * Test when only workbench_moderation is installed.
+   */
+  public function testWorkbenchModerationOnly() {
+    // Test with a node type that is not included in a moderation workflow.
+    $this->schedulingWithWorkbenchModeration($this->type);
+  }
+
+  /**
+   * Test when workbench_moderation_actions is also installed.
+   */
+  public function testWorkbenchModerationWithWorkbenchModerationActions() {
+    // Install workbench_moderation_actions and run the same test as above.
+    \Drupal::service('module_installer')->install(['workbench_moderation_actions']);
+    $this->schedulingWithWorkbenchModeration($this->type);
+  }
+
+}
diff --git a/web/modules/scheduler/tests/src/FunctionalJavascript/SchedulerJavascriptDefaultTimeTest.php b/web/modules/scheduler/tests/src/FunctionalJavascript/SchedulerJavascriptDefaultTimeTest.php
index 497a77517d3dab171c6eb5bbc76cd13c1b75b2f9..2c0ae45cd4dcd75e8279ba7afa996c0a352fa847 100644
--- a/web/modules/scheduler/tests/src/FunctionalJavascript/SchedulerJavascriptDefaultTimeTest.php
+++ b/web/modules/scheduler/tests/src/FunctionalJavascript/SchedulerJavascriptDefaultTimeTest.php
@@ -2,13 +2,10 @@
 
 namespace Drupal\Tests\scheduler\FunctionalJavascript;
 
-use DateTime;
-use DateInterval;
-
 /**
  * Tests the JavaScript functionality for default dates.
  *
- * @group scheduler
+ * @group scheduler_js
  */
 class SchedulerJavascriptDefaultTimeTest extends SchedulerJavascriptTestBase {
 
@@ -22,7 +19,7 @@ class SchedulerJavascriptDefaultTimeTest extends SchedulerJavascriptTestBase {
   /**
    * {@inheritdoc}
    */
-  public function setUp() {
+  protected function setUp(): void {
     parent::setUp();
 
     // Determine whether the HTML5 date picker is expecting d/m/Y or m/d/Y
@@ -33,7 +30,7 @@ public function setUp() {
     $this->drupalLogin($this->schedulerUser);
     $this->drupalGet('node/add/' . $this->type);
     $page = $this->getSession()->getPage();
-    $title = 'Date format test ' . $this->randomString(12);
+    $title = "Add a {$this->typeName} to determine the date-picker format";
     $page->fillField('edit-title-0-value', $title);
     $page->clickLink('Scheduling options');
     // Set the date using a day and month which could be correctly interpreted
@@ -44,6 +41,7 @@ public function setUp() {
     $page->fillField('edit-publish-on-0-value-time', '06:00:00pm');
     $page->pressButton('Save');
     $node = $this->drupalGetNodeByTitle($title);
+    $this->drupalGet('node/' . $node->id());
     // If the saved month is 2 then the format is d/m/Y, otherwise it is m/d/Y.
     $this->datepickerFormat = (date('n', $node->publish_on->value) == 2 ? 'd/m/Y' : 'm/d/Y');
   }
@@ -51,70 +49,80 @@ public function setUp() {
   /**
    * Test the default time functionality when scheduling dates are required.
    *
-   * @dataProvider dataDefaultTimeWhenSchedulingIsRequired()
+   * @dataProvider dataTimeWhenSchedulingIsRequired()
    */
-  public function testDefaultTimeWhenSchedulingIsRequired($field) {
+  public function testTimeWhenSchedulingIsRequired($entityTypeId, $bundle, $field) {
     $config = $this->config('scheduler.settings');
+    $titleField = $this->titleField($entityTypeId);
+    $entityType = $this->entityTypeObject($entityTypeId);
 
     // This test is only relevant when the configuration allows a date only with
     // a default time specified. Testing with 'allow_date_only' = false is
     // covered in the browser test SchedulerDefaultTimeTest.
     $config->set('allow_date_only', TRUE)->save();
 
-    // Use a default time of 19:30 (7:30pm).
-    $default_time = '19:30:00';
+    // Use a default time of 19:30:20 (7:30pm and 20 seconds).
+    $default_time = '19:30:20';
     $config->set('default_time', $default_time)->save();
 
     // Create a DateTime object to hold the scheduling date. This is better than
     // using a raw unix timestamp because it caters for daylight-saving.
-    $scheduling_time = new DateTime();
-    $scheduling_time->add(new DateInterval('P1D'))->setTime(19, 30);
+    $scheduling_time = new \DateTime();
+    $scheduling_time->add(new \DateInterval('P1D'))->setTime(19, 30, 20);
+
+    // Node and Media entities are revisionable and the 'Revision Information'
+    // tab is the default active one, so needs a click on 'Scheduling Options'.
+    // Products do not have this link, so the click would fail. A simple way to
+    // resolve this is display the scheduler options as a separate fieldset.
+    $entityType->setThirdPartySetting('scheduler', 'fields_display_mode', 'fieldset')->save();
 
     foreach ([TRUE, FALSE] as $required) {
-      // Set the publish-on/unpublish-on date to the $required setting.
-      $this->nodetype->setThirdPartySetting('scheduler', $field . '_required', $required)->save();
+      // Set the publish_on/unpublish_on required setting.
+      $entityType->setThirdPartySetting('scheduler', $field . '_required', $required)->save();
 
-      // Create a node.
-      $this->drupalGet('node/add/' . $this->type);
+      // Create an entity.
+      $this->drupalGet($this->entityAddUrl($entityTypeId, $bundle));
       $page = $this->getSession()->getPage();
-
-      $title = ucfirst($field) . ($required ? ' required ' : ' not required ') . $this->randomString(12);
-      $page->fillField('edit-title-0-value', $title);
-      $page->fillField('edit-body-0-value', 'datepickerFormat = ' . $this->datepickerFormat);
-      $page->clickLink('Scheduling options');
+      $title = ucfirst($field) . ($required ? ' required' : ' not required') . ', datepickerFormat = ' . $this->datepickerFormat;
+      $page->fillField("edit-{$titleField}-0-value", $title);
       if ($required) {
         // Fill in the date value but do nothing with the time field.
         $page->fillField('edit-' . $field . '-on-0-value-date', $scheduling_time->format($this->datepickerFormat));
       }
       $page->pressButton('Save');
 
-      // Test that the content has saved properly.
-      $this->assertSession()->pageTextContains(sprintf('%s %s has been created', $this->typeName, $title));
+      // Test that the entity has saved properly.
+      $this->assertSession()->pageTextMatches($this->entitySavedMessage($entityTypeId, $title));
 
-      $node = $this->drupalGetNodeByTitle($title);
-      $this->assertNotEmpty($node, 'The node could not be found');
+      $entity = $this->getEntityByTitle($entityTypeId, $title);
+      $this->assertNotEmpty($entity, 'The entity object can be found by title');
       if ($required) {
         // Check that the scheduled date and time are correct.
-        $this->assertEquals($scheduling_time->getTimestamp(), (int) $node->{$field . '_on'}->value);
+        $this->assertEquals($scheduling_time->getTimestamp(), (int) $entity->{$field . '_on'}->value);
       }
       else {
         // Check that no scheduled date was stored.
-        $this->assertEmpty($node->{$field . '_on'}->value);
+        $this->assertEmpty($entity->{$field . '_on'}->value);
       }
     }
   }
 
   /**
-   * Provides data for testDefaultTimeWhenSchedulingIsRequired().
+   * Provides data for testTimeWhenSchedulingIsRequired().
+   *
+   * The data in dataStandardEntityTypes() is expanded to test each entity type
+   * with each of the scheduler date fields.
    *
    * @return array
-   *   The test data.
+   *   Each array item has the values: [entity type id, bundle id, field name].
    */
-  public function dataDefaultTimeWhenSchedulingIsRequired() {
-    return [
-      ['publish'],
-      ['unpublish'],
-    ];
+  public function dataTimeWhenSchedulingIsRequired() {
+    $data = [];
+    foreach ($this->dataStandardEntityTypes() as $key => $values) {
+      $data["$key-1"] = array_merge($values, ['publish']);
+      $data["$key-2"] = array_merge($values, ['unpublish']);
+    }
+    return $data;
   }
 
 }
diff --git a/web/modules/scheduler/tests/src/FunctionalJavascript/SchedulerJavascriptTestBase.php b/web/modules/scheduler/tests/src/FunctionalJavascript/SchedulerJavascriptTestBase.php
index facb9f9122157373feaf3552e64afbadebf4c90d..9747b39c8555900e29e451c62c9aeae247260652 100644
--- a/web/modules/scheduler/tests/src/FunctionalJavascript/SchedulerJavascriptTestBase.php
+++ b/web/modules/scheduler/tests/src/FunctionalJavascript/SchedulerJavascriptTestBase.php
@@ -3,16 +3,20 @@
 namespace Drupal\Tests\scheduler\FunctionalJavascript;
 
 use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
+use Drupal\Tests\scheduler\Traits\SchedulerCommerceProductSetupTrait;
+use Drupal\Tests\scheduler\Traits\SchedulerMediaSetupTrait;
 use Drupal\Tests\scheduler\Traits\SchedulerSetupTrait;
+use Drupal\Tests\scheduler\Traits\SchedulerTaxonomyTermSetupTrait;
 
 /**
  * Base class for Scheduler javascript tests.
- *
- * @group scheduler
  */
 abstract class SchedulerJavascriptTestBase extends WebDriverTestBase {
 
+  use SchedulerCommerceProductSetupTrait;
+  use SchedulerMediaSetupTrait;
   use SchedulerSetupTrait;
+  use SchedulerTaxonomyTermSetupTrait;
 
   /**
    * The standard modules to load for all javascript tests.
@@ -21,7 +25,12 @@ abstract class SchedulerJavascriptTestBase extends WebDriverTestBase {
    *
    * @var array
    */
-  protected static $modules = ['scheduler'];
+  protected static $modules = [
+    'scheduler',
+    'media',
+    'commerce_product',
+    'taxonomy',
+  ];
 
   /**
    * The profile to install as a basis for testing.
@@ -38,10 +47,22 @@ abstract class SchedulerJavascriptTestBase extends WebDriverTestBase {
   /**
    * {@inheritdoc}
    */
-  public function setUp() {
+  protected function setUp(): void {
     parent::setUp();
-    // Call the common set-up function defined in the trait.
+    // Call the common set-up functions defined in the traits.
     $this->schedulerSetUp();
+    // $this->getName() includes the test class and the dataProvider key. We can
+    // use this to save time and resources by avoiding calls to the media and
+    // product setup functions when they are not needed.
+    if (stristr($this->getName(), 'media')) {
+      $this->schedulerMediaSetUp();
+    }
+    if (stristr($this->getName(), 'product')) {
+      $this->SchedulerCommerceProductSetUp();
+    }
+    if (stristr($this->getName(), 'taxonomy')) {
+      $this->SchedulerTaxonomyTermSetup();
+    }
   }
 
   /**
diff --git a/web/modules/scheduler/tests/src/Kernel/MigrateSchedulerConfigTest.php b/web/modules/scheduler/tests/src/Kernel/MigrateSchedulerConfigTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..a3c118828fdc6f13e398ad3658ee29636f5abdac
--- /dev/null
+++ b/web/modules/scheduler/tests/src/Kernel/MigrateSchedulerConfigTest.php
@@ -0,0 +1,46 @@
+<?php
+
+namespace Drupal\Tests\scheduler\Kernel;
+
+/**
+ * Tests the migration of Drupal 7 scheduler configuration.
+ *
+ * @group scheduler_kernel
+ */
+class MigrateSchedulerConfigTest extends MigrateSchedulerTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+    $this->loadFixture(implode(DIRECTORY_SEPARATOR, [
+      DRUPAL_ROOT,
+      \Drupal::service('extension.list.module')->getPath('scheduler'),
+      'tests',
+      'fixtures',
+      'scheduler_config.php',
+    ]));
+    $this->installConfig(['scheduler']);
+  }
+
+  /**
+   * Tests the migration of Scheduler global settings.
+   */
+  public function testGlobalSettingsMigration() {
+    $config_before = $this->config('scheduler.settings');
+    $this->assertFalse($config_before->get('allow_date_only'));
+    $this->assertSame('00:00:00', $config_before->get('default_time'));
+    $this->assertFalse($config_before->get('hide_seconds'));
+
+    // See /migrations/d7_scheduler_settings.yml.
+    $this->executeMigration('d7_scheduler_settings');
+
+    $config_after = $this->config('scheduler.settings');
+    $this->assertTrue($config_after->get('allow_date_only'));
+    $this->assertSame('00:00:38', $config_after->get('default_time'));
+    $this->assertTrue($config_after->get('hide_seconds'));
+
+  }
+
+}
diff --git a/web/modules/scheduler/tests/src/Kernel/MigrateSchedulerDataTest.php b/web/modules/scheduler/tests/src/Kernel/MigrateSchedulerDataTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..5313cf48a9dac3f12a7e757cf24b7fb8b1fbe33d
--- /dev/null
+++ b/web/modules/scheduler/tests/src/Kernel/MigrateSchedulerDataTest.php
@@ -0,0 +1,51 @@
+<?php
+
+namespace Drupal\Tests\scheduler\Kernel;
+
+use Drupal\node\Entity\Node;
+
+/**
+ * Tests the migration of Drupal 7 scheduler data.
+ *
+ * @group scheduler_kernel
+ */
+class MigrateSchedulerDataTest extends MigrateSchedulerTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getFixtureFilePath() {
+    return implode(DIRECTORY_SEPARATOR, [
+      \Drupal::service('extension.list.module')->getPath('scheduler'),
+      'tests',
+      'fixtures',
+      'scheduler_data.php',
+    ]);
+  }
+
+  /**
+   * Tests the migration of Scheduler data into node.
+   */
+  public function testSchedulerDataMigration() {
+    $this->installConfig(['node']);
+    $this->installEntitySchema('node');
+    $this->executeMigrations([
+      'd7_node_type',
+      'd7_node_complete',
+    ]);
+    $nodes = Node::loadMultiple([1, 2, 3]);
+    $this->assertSame(['1647751855', '1647838255'], [
+      $nodes[1]->publish_on->value,
+      $nodes[1]->unpublish_on->value,
+    ]);
+    $this->assertSame(['1647579055', NULL], [
+      $nodes[2]->publish_on->value,
+      $nodes[2]->unpublish_on->value,
+    ]);
+    $this->assertSame([NULL, NULL], [
+      $nodes[3]->publish_on->value,
+      $nodes[3]->unpublish_on->value,
+    ]);
+  }
+
+}
diff --git a/web/modules/scheduler/tests/src/Kernel/MigrateSchedulerNodeTypeConfigTest.php b/web/modules/scheduler/tests/src/Kernel/MigrateSchedulerNodeTypeConfigTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..349e43b3e18a6dd8b8b525db3974bb71545711f3
--- /dev/null
+++ b/web/modules/scheduler/tests/src/Kernel/MigrateSchedulerNodeTypeConfigTest.php
@@ -0,0 +1,68 @@
+<?php
+
+namespace Drupal\Tests\scheduler\Kernel;
+
+use Drupal\node\Entity\NodeType;
+
+/**
+ * Tests the migration of Drupal 7 Scheduler node type settings.
+ *
+ * @group scheduler_kernel
+ */
+class MigrateSchedulerNodeTypeConfigTest extends MigrateSchedulerTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = ['menu_ui'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+    $this->loadFixture(implode(DIRECTORY_SEPARATOR, [
+      DRUPAL_ROOT,
+      \Drupal::service('extension.list.module')->getPath('scheduler'),
+      'tests',
+      'fixtures',
+      'node_type_config.php',
+    ]));
+    $this->installConfig(['scheduler']);
+  }
+
+  /**
+   * Tests the migration of Scheduler settings per node type.
+   */
+  public function testNodeTypeSettingsMigration() {
+    $this->migrateContentTypes();
+    $article_config = NodeType::load('article');
+    $this->assertEquals([
+      'expand_fieldset' => 'when_required',
+      'publish_enable' => TRUE,
+      'publish_past_date' => 'error',
+      'publish_required' => TRUE,
+      'publish_revision' => TRUE,
+      'publish_touch' => FALSE,
+      'unpublish_enable' => TRUE,
+      'unpublish_required' => TRUE,
+      'unpublish_revision' => TRUE,
+      'fields_display_mode' => 'vertical_tab',
+    ], $article_config->get('third_party_settings')['scheduler']);
+
+    $page_config = NodeType::load('page');
+    $this->assertEquals([
+      'expand_fieldset' => 'always',
+      'publish_enable' => TRUE,
+      'publish_past_date' => 'publish',
+      'publish_required' => FALSE,
+      'publish_revision' => FALSE,
+      'publish_touch' => TRUE,
+      'unpublish_enable' => FALSE,
+      'unpublish_required' => FALSE,
+      'unpublish_revision' => FALSE,
+      'fields_display_mode' => 'fieldset',
+    ], $page_config->get('third_party_settings')['scheduler']);
+  }
+
+}
diff --git a/web/modules/scheduler/tests/src/Kernel/MigrateSchedulerTestBase.php b/web/modules/scheduler/tests/src/Kernel/MigrateSchedulerTestBase.php
new file mode 100644
index 0000000000000000000000000000000000000000..2b8da5fdfb13a3e0d995d521d104644e4af1cdb7
--- /dev/null
+++ b/web/modules/scheduler/tests/src/Kernel/MigrateSchedulerTestBase.php
@@ -0,0 +1,24 @@
+<?php
+
+namespace Drupal\Tests\scheduler\Kernel;
+
+use Drupal\Tests\migrate_drupal\Kernel\d7\MigrateDrupal7TestBase;
+
+/**
+ * Base class for testing the migration of Drupal 7 configuration and data.
+ *
+ * @group scheduler_kernel
+ */
+class MigrateSchedulerTestBase extends MigrateDrupal7TestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'node',
+    'scheduler',
+    'text',
+    'views',
+  ];
+
+}
diff --git a/web/modules/scheduler/tests/src/Traits/SchedulerCommerceProductSetupTrait.php b/web/modules/scheduler/tests/src/Traits/SchedulerCommerceProductSetupTrait.php
new file mode 100644
index 0000000000000000000000000000000000000000..924b37128ca79f50493be001d0a844a03697b314
--- /dev/null
+++ b/web/modules/scheduler/tests/src/Traits/SchedulerCommerceProductSetupTrait.php
@@ -0,0 +1,204 @@
+<?php
+
+namespace Drupal\Tests\scheduler\Traits;
+
+/**
+ * Additional setup trait for Scheduler tests that use Commerce Product.
+ */
+trait SchedulerCommerceProductSetupTrait {
+
+  /**
+   * The internal name of the standard product type for testing.
+   *
+   * Use the pre-existing 'default' product type. This is a short-cut.
+   *
+   * @var string
+   */
+  protected $productTypeName = 'test_product';
+
+  /**
+   * The readable label of the standard product type for testing.
+   *
+   * @var string
+   */
+  protected $productTypeLabel = 'Test Product';
+
+  /**
+   * The product type object which is enabled for scheduling.
+   *
+   * @var Drupal\commerce_product\Entity\ProductType
+   */
+  protected $productType;
+
+  /**
+   * The default commerce store to which all products are added.
+   *
+   * @var Drupal\commerce_store\Entity\Store
+   */
+  protected $store;
+
+  /**
+   * The internal name of the product type not enabled for scheduling.
+   *
+   * @var string
+   */
+  protected $nonSchedulerProductTypeName = 'non_enabled_product';
+
+  /**
+   * The readable label of the product type not enabled for scheduling.
+   *
+   * @var string
+   */
+  protected $nonSchedulerProductTypeLabel = 'Non-scheduler Product';
+
+  /**
+   * The product type object which is not enabled for scheduling.
+   *
+   * @var Drupal\commerce_product\Entity\ProductType
+   */
+  protected $nonSchedulerProductType;
+
+  /**
+   * The product entity storage.
+   *
+   * Is this really needed now that we can use $this->entityStorageObject() ?
+   *
+   * @var Drupal\commerce\CommerceContentEntityStorage
+   */
+  protected $productStorage;
+
+  /**
+   * Set common properties, define content types and create users.
+   */
+  public function schedulerCommerceProductSetUp() {
+
+    /** @var Store $store */
+    $this->store = $this->entityStorageObject('commerce_store')->create([
+      'type' => 'online',
+      'name' => 'My Test Store',
+    ]);
+    $this->store->save();
+
+    $product_type_storage = $this->container->get('entity_type.manager')->getStorage('commerce_product_type');
+
+    // Create a test product type that is enabled for scheduling.
+    /** @var Drupal\commerce_product\Entity\ProductType $productType */
+    $this->productType = $product_type_storage->create([
+      'id' => $this->productTypeName,
+      'label' => $this->productTypeLabel,
+      'variationType' => 'default',
+    ]);
+
+    // Add scheduler functionality to the product type, then save.
+    $this->productType->setThirdPartySetting('scheduler', 'publish_enable', TRUE)
+      ->setThirdPartySetting('scheduler', 'unpublish_enable', TRUE)
+      ->save();
+
+    // Enable the scheduler fields in the default form display, mimicking what
+    // would be done if the entity bundle had been enabled via admin UI.
+    $this->container->get('entity_display.repository')
+      ->getFormDisplay('commerce_product', $this->productTypeName)
+      ->setComponent('publish_on', ['type' => 'datetime_timestamp_no_default'])
+      ->setComponent('unpublish_on', ['type' => 'datetime_timestamp_no_default'])
+      ->save();
+
+    // Add the body field using the existing commerce_product function.
+    commerce_product_add_body_field($this->productType);
+
+    // Create a test product type which is not enabled for scheduling.
+    /** @var Drupal\commerce_product\Entity\ProductType $nonSchedulerProductType */
+    $this->nonSchedulerProductType = $product_type_storage->create([
+      'id' => $this->nonSchedulerProductTypeName,
+      'label' => $this->nonSchedulerProductTypeLabel,
+      'variationType' => 'default',
+    ]);
+    // Requires a separate save, not part of the create() above, if not doing
+    // any other save() on the product type.
+    $this->nonSchedulerProductType->save();
+
+    /** @var Drupal\commerce\CommerceContentEntityStorage $productStorage */
+    $this->productStorage = $this->container->get('entity_type.manager')->getStorage('commerce_product');
+
+    // Add extra permisssions to the role assigned to the adminUser.
+    $this->addPermissionsToUser($this->adminUser, [
+      'create ' . $this->productTypeName . ' commerce_product',
+      'update any ' . $this->productTypeName . ' commerce_product',
+      'delete any ' . $this->productTypeName . ' commerce_product',
+      'create ' . $this->nonSchedulerProductTypeName . ' commerce_product',
+      'update any ' . $this->nonSchedulerProductTypeName . ' commerce_product',
+      'delete any ' . $this->nonSchedulerProductTypeName . ' commerce_product',
+      'administer commerce_product_type',
+      // 'administer commerce_store' is needed to see and use any store, i.e
+      // cannot add a product without this. Is it a bug?
+      'administer commerce_store',
+      'access commerce_product overview',
+      'view own unpublished commerce_product',
+      'schedule publishing of commerce_product',
+      'view scheduled commerce_product',
+    ]);
+
+    // Add extra permisssions to the role assigned to the schedulerUser.
+    $this->addPermissionsToUser($this->schedulerUser, [
+      'create ' . $this->productTypeName . ' commerce_product',
+      'update any ' . $this->productTypeName . ' commerce_product',
+      'delete any ' . $this->productTypeName . ' commerce_product',
+      // 'administer commerce_store' is needed to see and use any store, i.e
+      // cannot add a product without this. Is it a bug?
+      'administer commerce_store',
+      'view own unpublished commerce_product',
+      'schedule publishing of commerce_product',
+    ]);
+  }
+
+  /**
+   * Creates a product entity.
+   *
+   * @param array $values
+   *   The values to use for the entity.
+   *
+   * @return Drupal\commerce_product\Entity\ProductInterface
+   *   The created product object.
+   */
+  public function createProduct(array $values = []) {
+    // Provide defaults for the critical values.
+    $values += [
+      'type' => $this->productTypeName,
+      'title' => $this->randomstring(12),
+    ];
+    /** @var \Drupal\commerce_product\ProductInterface $product */
+    $product = $this->productStorage->create($values);
+    $product->save();
+    return $product;
+  }
+
+  /**
+   * Gets a product from storage.
+   *
+   * For nodes, there is drupalGetNodeByTitle() but nothing similar exists to
+   * help Product testing. See getMediaItem for more details.
+   *
+   * @param string $name
+   *   Optional name text to match on. If given and no match, returns NULL.
+   *   If no $name is given then returns the product with the highest id value.
+   *
+   * @return \Drupal\commerce_product\Entity\ProductInterface
+   *   The commerce product object.
+   */
+  public function getProduct(string $name = NULL) {
+    $query = $this->productStorage->getQuery()
+      ->accessCheck(FALSE)
+      ->sort('product_id', 'DESC');
+    if (!empty($name)) {
+      $query->condition('title', $name);
+    }
+    $result = $query->execute();
+    if (count($result)) {
+      $id = reset($result);
+      return $this->productStorage->load($id);
+    }
+    else {
+      return NULL;
+    }
+  }
+
+}
diff --git a/web/modules/scheduler/tests/src/Traits/SchedulerMediaSetupTrait.php b/web/modules/scheduler/tests/src/Traits/SchedulerMediaSetupTrait.php
new file mode 100644
index 0000000000000000000000000000000000000000..926bc12dcba76c39ca798765bc8efc52031e02e7
--- /dev/null
+++ b/web/modules/scheduler/tests/src/Traits/SchedulerMediaSetupTrait.php
@@ -0,0 +1,200 @@
+<?php
+
+namespace Drupal\Tests\scheduler\Traits;
+
+use Drupal\Tests\media\Traits\MediaTypeCreationTrait;
+
+/**
+ * Additional setup trait for Scheduler tests that use Media.
+ *
+ * This builds on the standard SchedulerSetupTrait.
+ */
+trait SchedulerMediaSetupTrait {
+
+  use MediaTypeCreationTrait;
+
+  /**
+   * The internal name of the standard media type created for testing.
+   *
+   * @var string
+   */
+  protected $mediaTypeName = 'test_video';
+
+  /**
+   * The readable label of the standard media type created for testing.
+   *
+   * @var string
+   */
+  protected $mediaTypeLabel = 'Test Video';
+
+  /**
+   * The media type object which is enabled for scheduling.
+   *
+   * @var \Drupal\media\MediaTypeInterface
+   */
+  protected $mediaType;
+
+  /**
+   * The internal name of the media type not enabled for scheduling.
+   *
+   * @var string
+   */
+  protected $nonSchedulerMediaTypeName = 'test_audio_not_enabled';
+
+  /**
+   * The readable label of the media type not enabled for scheduling.
+   *
+   * @var string
+   */
+  protected $nonSchedulerMediaTypeLabel = 'Test Audio - not for scheduling';
+
+  /**
+   * The media type object which is not enabled for scheduling.
+   *
+   * @var \Drupal\media\MediaTypeInterface
+   */
+  protected $nonSchedulerMediaType;
+
+  /**
+   * The media entity storage.
+   *
+   * @var \Drupal\Core\Entity\ContentEntityStorageInterface
+   */
+  protected $mediaStorage;
+
+  /**
+   * Set common properties, define content types and create users.
+   */
+  public function schedulerMediaSetUp() {
+
+    // Create a test media type for video that is enabled for scheduling.
+    /** @var \Drupal\media\Entity\MediaTypeInterface $mediaType */
+    $this->mediaType = $this->createMediaType('video_file', [
+      'id' => $this->mediaTypeName,
+      'label' => $this->mediaTypeLabel,
+    ]);
+
+    // Add scheduler functionality to the video media type.
+    $this->mediaType->setThirdPartySetting('scheduler', 'publish_enable', TRUE)
+      ->setThirdPartySetting('scheduler', 'unpublish_enable', TRUE)
+      ->save();
+
+    // Enable the scheduler fields in the default form display, mimicking what
+    // would be done if the entity bundle had been enabled via admin UI.
+    $this->container->get('entity_display.repository')
+      ->getFormDisplay('media', $this->mediaTypeName)
+      ->setComponent('publish_on', ['type' => 'datetime_timestamp_no_default'])
+      ->setComponent('unpublish_on', ['type' => 'datetime_timestamp_no_default'])
+      ->save();
+
+    // Create a test media type for audio which is not enabled for scheduling.
+    /** @var \Drupal\media\Entity\MediaTypeInterface $nonSchedulerMediaType */
+    $this->nonSchedulerMediaType = $this->createMediaType('audio_file', [
+      'id' => $this->nonSchedulerMediaTypeName,
+      'label' => $this->nonSchedulerMediaTypeLabel,
+    ]);
+
+    // Define mediaStorage for use in many tests.
+    /** @var MediaStorageInterface $mediaStorage */
+    $this->mediaStorage = $this->container->get('entity_type.manager')->getStorage('media');
+
+    // Add extra permisssions to the role assigned to the adminUser.
+    $this->addPermissionsToUser($this->adminUser, [
+      'create ' . $this->mediaTypeName . ' media',
+      'edit any ' . $this->mediaTypeName . ' media',
+      'delete any ' . $this->mediaTypeName . ' media',
+      'create ' . $this->nonSchedulerMediaTypeName . ' media',
+      'edit any ' . $this->nonSchedulerMediaTypeName . ' media',
+      'delete any ' . $this->nonSchedulerMediaTypeName . ' media',
+      'administer media types',
+      'access media overview',
+      'view own unpublished media',
+      'schedule publishing of media',
+      'view scheduled media',
+    ]);
+
+    // Add extra permisssions to the role assigned to the schedulerUser.
+    $this->addPermissionsToUser($this->schedulerUser, [
+      'create ' . $this->mediaTypeName . ' media',
+      'edit own ' . $this->mediaTypeName . ' media',
+      'delete own ' . $this->mediaTypeName . ' media',
+      'view own unpublished media',
+      'schedule publishing of media',
+    ]);
+
+    // By default, media items cannot be viewed directly, and the url media/mid
+    // gives a 404 not found. Changing this setting makes debugging the tests
+    // easier. It is also required for the meta information test.
+    $configFactory = $this->container->get('config.factory');
+    $configFactory->getEditable('media.settings')
+      ->set('standalone_url', TRUE)
+      ->save(TRUE);
+    $this->container->get('router.builder')->rebuild();
+
+    // Set the media file attachments to be optional not required, to simplify
+    // editing and saving media entities.
+    $configFactory->getEditable('field.field.media.test_video.field_media_video_file')
+      ->set('required', FALSE)
+      ->save(TRUE);
+    $configFactory->getEditable('field.field.media.test_audio_not_enabled.field_media_audio_file')
+      ->set('required', FALSE)
+      ->save(TRUE);
+  }
+
+  /**
+   * Creates a media entity.
+   *
+   * @param array $values
+   *   The values to use for the entity.
+   *
+   * @return \Drupal\media\MediaInterface
+   *   The created media object.
+   */
+  public function createMediaItem(array $values) {
+    // Provide defaults for the critical values. The title is stored in the
+    // 'name' field, so use 'title' when the 'name' is not defined, to allow
+    // the same calling $value parameter names as for Node.
+    $values += [
+      'bundle' => $this->mediaTypeName,
+      'name' => $values['title'] ?? $this->randomstring(12),
+    ];
+    /** @var \Drupal\media\MediaInterface $media */
+    $media = $this->mediaStorage->create($values);
+    $media->save();
+    return $media;
+  }
+
+  /**
+   * Gets a media item from storage.
+   *
+   * For nodes, there is drupalGetNodeByTitle() but nothing similar exists to
+   * help Media testing. But this function goes one better - if a name is given,
+   * then a match will be attempted on the name, and fail if none found. But if
+   * no name is supplied then the media entity with the highest id value (the
+   * newest item created) is returned, as this is often what is required.
+   *
+   * @param string $name
+   *   Optional name text to match on. If given and no match, returns NULL.
+   *   If no $name is given then returns the media with the highest id value.
+   *
+   * @return \Drupal\media\MediaInterface
+   *   The media object.
+   */
+  public function getMediaItem(string $name = NULL) {
+    $query = $this->mediaStorage->getQuery()
+      ->accessCheck(FALSE)
+      ->sort('mid', 'DESC');
+    if (!empty($name)) {
+      $query->condition('name', $name);
+    }
+    $result = $query->execute();
+    if (count($result)) {
+      $media_id = reset($result);
+      return $this->mediaStorage->load($media_id);
+    }
+    else {
+      return NULL;
+    }
+  }
+
+}
diff --git a/web/modules/scheduler/tests/src/Traits/SchedulerSetupTrait.php b/web/modules/scheduler/tests/src/Traits/SchedulerSetupTrait.php
index 096548767557a064cb2dd7c072f8e59580cc5748..0ab451227d6cf8764c74946136b76b39411aa3d7 100644
--- a/web/modules/scheduler/tests/src/Traits/SchedulerSetupTrait.php
+++ b/web/modules/scheduler/tests/src/Traits/SchedulerSetupTrait.php
@@ -2,6 +2,9 @@
 
 namespace Drupal\Tests\scheduler\Traits;
 
+use Drupal\Core\Session\AccountInterface;
+use Drupal\Core\Url;
+use Drupal\Tests\node\Traits\NodeCreationTrait;
 use Drupal\Tests\Traits\Core\CronRunTrait;
 
 /**
@@ -13,6 +16,13 @@ trait SchedulerSetupTrait {
 
   use CronRunTrait;
 
+  use NodeCreationTrait {
+    // Allow this trait to be used in Kernel tests (which do not use
+    // BrowserTestBase) and hence will not have these two functions.
+    getNodeByTitle as drupalGetNodeByTitle;
+    createNode as drupalCreateNode;
+  }
+
   // @todo Remove this when core 8.8 is the lowest supported version.
   // @see https://www.drupal.org/project/scheduler/issues/3136744
   use PhpunitCompatibilityCore87Trait;
@@ -36,14 +46,14 @@ trait SchedulerSetupTrait {
    *
    * @var string
    */
-  protected $type;
+  protected $type = 'testpage';
 
   /**
    * The readable name of the standard content type created for testing.
    *
    * @var string
    */
-  protected $typeName;
+  protected $typeName = 'Test Page';
 
   /**
    * The node type object.
@@ -52,12 +62,26 @@ trait SchedulerSetupTrait {
    */
   protected $nodetype;
 
+  /**
+   * The machine name of the content type which is not enabled for scheduling.
+   *
+   * @var string
+   */
+  protected $nonSchedulerType = 'not_for_scheduler';
+
+  /**
+   * The readable name of content type which is not enabled for scheduling.
+   *
+   * @var string
+   */
+  protected $nonSchedulerTypeName = 'Not For Scheduler';
+
   /**
    * The node type object which is not enabled for scheduling.
    *
    * @var \Drupal\node\Entity\NodeType
    */
-  protected $nonSchedulerNodetype;
+  protected $nonSchedulerNodeType;
 
   /**
    * The node storage object.
@@ -92,12 +116,10 @@ trait SchedulerSetupTrait {
    */
   public function schedulerSetUp() {
 
-    // Create a test content type with id 'testpage' and name 'Test Page'.
-    // The tests should use $this->type and $this->typeName and not use
+    // Create a test content type using the type and name constants defined
+    // above. The tests should use $this->type and $this->typeName and not use
     // $this->nodetype->get('type') or $this->nodetype->get('name'), nor have
     // the hard-coded strings 'testpage' or 'Test Page'.
-    $this->type = 'testpage';
-    $this->typeName = 'Test Page';
     /** @var NodeTypeInterface $nodetype */
     $this->nodetype = $this->drupalCreateContentType([
       'type' => $this->type,
@@ -109,11 +131,19 @@ public function schedulerSetUp() {
       ->setThirdPartySetting('scheduler', 'unpublish_enable', TRUE)
       ->save();
 
+    // Enable the scheduler fields in the default form display, mimicking what
+    // would be done if the entity bundle had been enabled via admin UI.
+    $this->container->get('entity_display.repository')
+      ->getFormDisplay('node', $this->type)
+      ->setComponent('publish_on', ['type' => 'datetime_timestamp_no_default'])
+      ->setComponent('unpublish_on', ['type' => 'datetime_timestamp_no_default'])
+      ->save();
+
     // The majority of tests use the standard Scheduler-enabled content type but
     // we also need a content type which is not enabled for Scheduler.
     $this->nonSchedulerNodeType = $this->drupalCreateContentType([
-      'type' => 'not-for-scheduler',
-      'name' => 'Non Scheduler Content',
+      'type' => $this->nonSchedulerType,
+      'name' => $this->nonSchedulerTypeName,
     ]);
 
     // Define nodeStorage for use in many tests.
@@ -124,34 +154,35 @@ public function schedulerSetUp() {
     // rights on the test content type and all of the Scheduler permissions.
     // 'access site reports' is required for admin/reports/dblog.
     // 'administer site configuration' is required for admin/reports/status.
+    // 'administer content types' is required for admin/structure/types/manage.
     $this->adminUser = $this->drupalCreateUser([
       'access content',
       'access content overview',
       'access site reports',
       'administer nodes',
+      'administer content types',
       'administer site configuration',
       'create ' . $this->type . ' content',
       'edit any ' . $this->type . ' content',
       'delete any ' . $this->type . ' content',
-      'create ' . $this->nonSchedulerNodeType->id() . ' content',
-      'edit any ' . $this->nonSchedulerNodeType->id() . ' content',
-      'delete any ' . $this->nonSchedulerNodeType->id() . ' content',
+      'create ' . $this->nonSchedulerType . ' content',
+      'edit any ' . $this->nonSchedulerType . ' content',
       'view own unpublished content',
       'administer scheduler',
       'schedule publishing of nodes',
       'view scheduled content',
     ]);
+    $this->adminUser->set('name', 'Admolly the Admin user')->save();
 
     // Create an ordinary Scheduler user, with permission to create and schedule
     // content but not with administrator permissions.
     $this->schedulerUser = $this->drupalCreateUser([
       'create ' . $this->type . ' content',
       'edit own ' . $this->type . ' content',
-      'delete own ' . $this->type . ' content',
       'view own unpublished content',
       'schedule publishing of nodes',
-      'view scheduled content',
     ]);
+    $this->schedulerUser->set('name', 'Shelly the Scheduler user')->save();
 
     // Store the database connection for re-use in the actual tests.
     $this->database = $this->container->get('database');
@@ -164,4 +195,413 @@ public function schedulerSetUp() {
 
   }
 
+  /**
+   * Adds a set of permissions to an existing user.
+   *
+   * This avoids having to create new users when a test requires additional
+   * permissions, as that leads to having a list of existing permissions which
+   * has to be kept in sync with the standard user permissions.
+   *
+   * Each test user has two roles, 'authenticated' and one other randomly-named
+   * role assigned when the user is created, and unique to that user. This is
+   * the role to which these permissions are added.
+   *
+   * @param \Drupal\Core\Session\AccountInterface $user
+   *   The user object.
+   * @param array $permissions
+   *   The machine names of new permissions to add to the user's unique role.
+   */
+  public function addPermissionsToUser(AccountInterface $user, array $permissions) {
+    /** @var \Drupal\user\Entity\RoleStorageInterface $roleStorage */
+    $roleStorage = $this->container->get('entity_type.manager')->getStorage('user_role');
+    foreach ($user->getRoles() as $rid) {
+      // The user will have two roles, 'authenticated' and one other.
+      if ($rid != 'authenticated') {
+        $role = $roleStorage->load($rid);
+        foreach ($permissions as $permission) {
+          $role->grantPermission($permission);
+        }
+        $role->save();
+      }
+    }
+  }
+
+  /**
+   * Creates a test entity.
+   *
+   * This is called to generate a node, media or product entity, for tests that
+   * process all types of entities, either in loops or via a data provider.
+   *
+   * @param string $entityTypeId
+   *   The entity type - 'node', 'media', 'commerce_product' or 'taxonomy_term'.
+   * @param string $bundle
+   *   The name of the bundle. Optional, will default to $this->type for nodes
+   *   $this->mediaTypeName for media, or $this->productTypeName for products.
+   * @param array $values
+   *   Values for the new entity, passed through to the specific create method.
+   *   'title' can be used for all entity types, and will be converted to the
+   *   necessary property name.
+   *
+   * @return \Drupal\Core\Entity\EntityInterface
+   *   The created entity object.
+   */
+  public function createEntity(string $entityTypeId, string $bundle = NULL, array $values = []) {
+
+    switch ($entityTypeId) {
+      case 'node':
+        // For nodes the field for bundle is called 'type'.
+        $values += ['type' => $bundle ?? $this->type];
+        $entity = $this->drupalCreateNode($values);
+        break;
+
+      case 'media':
+        $values += ['bundle' => $bundle ?? $this->mediaTypeName];
+        $entity = $this->createMediaItem($values);
+        break;
+
+      case 'commerce_product':
+        // For products the bundle field is 'type'.
+        $values += ['type' => $bundle ?? $this->productTypeName];
+        $entity = $this->createProduct($values);
+        break;
+
+      case 'taxonomy_term':
+        // For taxonomy terms, the bundle field is 'vid'.
+        $values += ['vid' => $bundle ?? $this->vocabularyId];
+        $entity = $this->createTaxonomyTerm($values);
+        break;
+
+      default:
+        // Incorrect parameter values.
+        throw new \Exception(sprintf('Unrecognised combination of entityTypeId "%s" and bundle "%s" passed to createEntity()', $entityTypeId, $bundle));
+
+    }
+    return $entity;
+  }
+
+  /**
+   * Gets an entity by title, a direct replacement of drupalGetNodeByTitle().
+   *
+   * This allows the same test code to be run for Nodes, Media and Products.
+   *
+   * @param string $entityTypeId
+   *   The machine id of the entity type - 'node', 'media', 'commerce_product'.
+   * @param string $title
+   *   The title to match with.
+   *
+   * @return mixed
+   *   Either a node object, media object, commerce_product object, or none.
+   */
+  public function getEntityByTitle(string $entityTypeId, string $title) {
+    switch ($entityTypeId) {
+      case 'node':
+        return $this->drupalGetNodeByTitle($title);
+
+      case 'media':
+        return $this->getMediaItem($title);
+
+      case 'commerce_product':
+        return $this->getProduct($title);
+
+      case 'taxonomy_term':
+        return $this->getTaxonomyTerm($title);
+
+      default:
+        // Incorrect parameter value.
+        throw new \Exception(sprintf('Unrecognised entityTypeId value "%s" passed to getEntityByTitle()', $entityTypeId));
+    }
+  }
+
+  /**
+   * Returns the stored entity type object from a type id and bundle id.
+   *
+   * This allows previous usages of $this->nodetype to be replaced by
+   * entityTypeObject($entityTypeId) or entityTypeObject($entityTypeId, $bundle)
+   * when expanding tests to cover Media and Product entities.
+   *
+   * @param string $entityTypeId
+   *   The machine id of the entity type - 'node', 'media', 'commerce_product'.
+   * @param string $bundle
+   *   The machine name of the bundle, for example 'testpage', 'test_video',
+   *   'not_for_scheduler', etc. Optional. Defaults to the enabled bundle. Also
+   *   accepts the fixed string 'non-enabled' to indicate the non-enabled bundle
+   *   for the entity type.
+   *
+   * @return \Drupal\Core\Entity\EntityTypeInterface
+   *   The stored entity type object.
+   */
+  public function entityTypeObject(string $entityTypeId, string $bundle = NULL) {
+    if (empty($bundle) || $bundle == 'non-enabled') {
+      $default_types = [
+        'node' => $this->type,
+        'media' => $this->mediaTypeName,
+        'commerce_product' => $this->productTypeName,
+        'taxonomy_term' => $this->vocabularyId,
+      ];
+      $non_enabled_types = [
+        'node' => $this->nonSchedulerType,
+        'media' => $this->nonSchedulerMediaTypeName,
+        'commerce_product' => $this->nonSchedulerProductTypeName,
+        'taxonomy_term' => $this->nonSchedulerVocabularyId,
+      ];
+      $bundle = (empty($bundle)) ? $default_types[$entityTypeId] : $non_enabled_types[$entityTypeId];
+    }
+    $entityTypeManager = $this->container->get('entity_type.manager');
+    $bundleEntityType = $entityTypeManager->getDefinition($entityTypeId)->getBundleEntityType();
+    if (!$entity_type = $entityTypeManager->getStorage($bundleEntityType)->load($bundle)) {
+      // Incorrect parameter values.
+      throw new \Exception(sprintf('Unrecognised combination of entityTypeId "%s" and bundle "%s" passed to entityTypeObject()', $entityTypeId, $bundle));
+    };
+    return $entity_type;
+  }
+
+  /**
+   * Returns the field name used for the "title" of an entity.
+   *
+   * @param string $entityTypeId
+   *   The machine id of the entity type.
+   *
+   * @return string
+   *   The name of the title field.
+   */
+  public function titleField(string $entityTypeId) {
+    switch ($entityTypeId) {
+      case 'node':
+      case 'commerce_product':
+        return 'title';
+
+      case 'media':
+      case 'taxonomy_term':
+        return 'name';
+
+      default:
+        // Incorrect parameter value.
+        throw new \Exception(sprintf('Unrecognised entityTypeId "%s" passed to titleField()', $entityTypeId));
+    }
+  }
+
+  /**
+   * Returns the field name used for the "body" of an entity.
+   *
+   * @param string $entityTypeId
+   *   The machine id of the entity type.
+   *
+   * @return string
+   *   The name of the body field.
+   */
+  public function bodyField(string $entityTypeId) {
+    switch ($entityTypeId) {
+      case 'node':
+      case 'commerce_product':
+        return 'body';
+
+      case 'taxonomy_term':
+        return 'description';
+
+      default:
+        // Incorrect parameter value.
+        throw new \Exception(sprintf('Unrecognised entityTypeId "%s" passed to bodyField()', $entityTypeId));
+    }
+  }
+
+  /**
+   * Returns the message that is shown when an entity is saved.
+   *
+   * @param string $entityTypeId
+   *   The machine id of the entity type.
+   * @param string $title
+   *   The title of the entity being checked.
+   *
+   * @return string
+   *   The text of the message to check, used in pageTextMatches() assertions.
+   */
+  public function entitySavedMessage(string $entityTypeId, string $title) {
+    switch ($entityTypeId) {
+      case 'node':
+        return '/' . preg_quote($title, '/') . ' has been (created|updated)/';
+
+      case 'media':
+        return '/' . preg_quote($title, '/') . ' has been (created|updated)/';
+
+      case 'commerce_product':
+        return '/The product ' . preg_quote($title, '/') . ' has been successfully saved/';
+
+      case 'taxonomy_term':
+        return '/(Created new|Updated) term ' . preg_quote($title, '/') . '/';
+
+      default:
+        // Incorrect parameter value.
+        throw new \Exception(sprintf('Unrecognised entityTypeId "%s" passed to entitySavedMessage()', $entityTypeId));
+    }
+  }
+
+  /**
+   * Returns the url for adding an entity, for use in drupalGet().
+   *
+   * @param string $entityTypeId
+   *   The machine id of the entity type - 'node', 'media', 'commerce_product'.
+   * @param string $bundle
+   *   The machine name of the bundle, for example 'testpage', 'test_video',
+   *   'not_for_scheduler', etc. Optional. Defaults to the enabled bundle. Also
+   *   accepts the fixed string 'non-enabled' to indicate the non-enabled bundle
+   *   for the entity type.
+   *
+   * @return \Drupal\Core\Url
+   *   The url object for adding the required entity.
+   */
+  public function entityAddUrl(string $entityTypeId, string $bundle = NULL) {
+    switch ($entityTypeId) {
+      case 'node':
+        $bundle = ($bundle == 'non-enabled') ? $this->nonSchedulerType : ($bundle ?? $this->type);
+        $route = 'node.add';
+        $type_parameter = 'node_type';
+        break;
+
+      case 'media':
+        $bundle = ($bundle == 'non-enabled') ? $this->nonSchedulerMediaTypeName : ($bundle ?? $this->mediaTypeName);
+        $route = 'entity.media.add_form';
+        $type_parameter = 'media_type';
+        break;
+
+      case 'commerce_product':
+        $bundle = ($bundle == 'non-enabled') ? $this->nonSchedulerProductTypeName : ($bundle ?? $this->productTypeName);
+        $route = 'entity.commerce_product.add_form';
+        $type_parameter = 'commerce_product_type';
+        break;
+
+      case 'taxonomy_term':
+        $bundle = ($bundle == 'non-enabled') ? $this->nonSchedulerVocabularyId : ($bundle ?? $this->vocabularyId);
+        $route = 'entity.taxonomy_term.add_form';
+        $type_parameter = 'taxonomy_vocabulary';
+        break;
+
+      default:
+        // Incorrect parameter values.
+        throw new \Exception(sprintf('Unrecognised combination of entityTypeId "%s" and bundle "%s" passed to entityAddUrl()', $entityTypeId, $bundle));
+    }
+    if (!$url = Url::fromRoute($route, [$type_parameter => $bundle])) {
+      // Incorrect parameter values.
+      throw new \Exception(sprintf('No url found for entityTypeId "%s" and bundle "%s" with route "%s" in entityAddUrl()', $entityTypeId, $bundle, $route));
+    }
+    return $url;
+  }
+
+  /**
+   * Returns the url for a specified page, entity type and optionally bundle.
+   *
+   * @param string $page
+   *   The page required - 'collection', 'scheduled', 'generate', etc.
+   * @param string $entityTypeId
+   *   The machine id of the entity type - 'node', 'media', 'commerce_product'.
+   * @param string $bundle
+   *   (optional) The machine name of the bundle.
+   *
+   * @return string
+   *   The url for the required page.
+   */
+  public function adminUrl($page, $entityTypeId, $bundle = NULL) {
+    // $bundle_id will be 'node_type', 'media_type', 'commerce_product_type',
+    // 'taxonomy_vocabulary' etc.
+    $bundle_id = $this->container->get('entity_type.manager')->getDefinition($entityTypeId)->getBundleEntityType();
+
+    $urls = [
+      'collection' => [
+        'node' => Url::fromRoute('system.admin_content'),
+        'taxonomy_term' => Url::fromRoute('entity.taxonomy_vocabulary.overview_form', [$bundle_id => $bundle]),
+        'default' => Url::fromRoute("entity.{$entityTypeId}.collection"),
+      ],
+      'scheduled' => [
+        'node' => Url::fromRoute('view.scheduler_scheduled_content.overview'),
+        'default' => Url::fromRoute("view.scheduler_scheduled_{$entityTypeId}.overview"),
+      ],
+      'generate' => [
+        'node' => Url::fromRoute('devel_generate.content'),
+        'media' => Url::fromRoute('devel_generate.media'),
+        'taxonomy_term' => Url::fromRoute('devel_generate.term'),
+      ],
+      'bundle_add' => [
+        'node' => Url::fromRoute('node.type_add'),
+        'default' => Url::fromRoute("entity.{$bundle_id}.add_form"),
+      ],
+      'bundle_edit' => [
+        'default' => Url::fromRoute("entity.{$bundle_id}.edit_form", [$bundle_id => $bundle]),
+      ],
+      'bundle_form_display' => [
+        'default' => Url::fromRoute("entity.entity_form_display.{$entityTypeId}.default", [$bundle_id => $bundle]),
+      ],
+    ];
+
+    $url = $urls[$page][$entityTypeId] ?? ($urls[$page]['default'] ?? NULL);
+    if (empty($url)) {
+      // Incorrect parameter values.
+      throw new \Exception(sprintf('Unrecognised combination of page "%s", entityTypeId "%s" and bundle "%s" passed to adminUrl()', $page, $entityTypeId, $bundle));
+    }
+    return $url;
+  }
+
+  /**
+   * Returns the storage object of the entity type passed by string.
+   *
+   * This allows previous usage of the hard-coded $this->nodeStorage to be
+   * replaced with $this->entityStorageObject($entityTypeId) when expanding the
+   * tests to cover media and product entity types.
+   *
+   * @param string $entityTypeId
+   *   The machine id of the entity type.
+   *
+   * @return \Drupal\Core\Entity\ContentEntityStorageInterface
+   *   The entity storage object.
+   */
+  public function entityStorageObject(string $entityTypeId) {
+    return $this->container->get('entity_type.manager')->getStorage($entityTypeId);
+  }
+
+  /**
+   * Deletes an action that is associated with a scheduler entity type.
+   */
+  public function deleteAction($plugin_id, $process) {
+    $plugin = $this->container->get('plugin.manager.scheduler')->createInstance($plugin_id);
+    $action_id = ($process == 'publish' ? $plugin->publishAction() : $plugin->unpublishAction());
+    if ($loaded_action = $this->container->get('entity_type.manager')->getStorage('action')->load($action_id)) {
+      // To avoid error, only call delete if the action exists and was loaded.
+      $loaded_action->delete();
+    }
+  }
+
+  /**
+   * Provides test data containing the standard entity types.
+   *
+   * @return array
+   *   Each array item has the values: [entity type id, bundle id]. The array
+   *   key is #entity_type_id, to allow easy removal of unwanted rows later.
+   */
+  public function dataStandardEntityTypes() {
+    // The data provider has access to $this where the values are set in the
+    // property definition.
+    $data = [
+      '#node' => ['node', $this->type],
+      '#media' => ['media', $this->mediaTypeName],
+      '#commerce_product' => ['commerce_product', $this->productTypeName],
+      '#taxonomy_term' => ['taxonomy_term', $this->vocabularyId],
+    ];
+    return $data;
+  }
+
+  /**
+   * Provides test data containing the non-enabled entity types.
+   *
+   * @return array
+   *   Each array item has the values: [entity type id, bundle id]. The array
+   *   key is #entity_type_id, to allow easy removal of unwanted rows later.
+   */
+  public function dataNonEnabledTypes() {
+    $data = [
+      '#node' => ['node', $this->nonSchedulerType],
+      '#media' => ['media', $this->nonSchedulerMediaTypeName],
+      '#commerce_product' => ['commerce_product', $this->nonSchedulerProductTypeName],
+      '#taxonomy_term' => ['taxonomy_term', $this->nonSchedulerVocabularyId],
+    ];
+    return $data;
+  }
+
 }
diff --git a/web/modules/scheduler/tests/src/Traits/SchedulerTaxonomyTermSetupTrait.php b/web/modules/scheduler/tests/src/Traits/SchedulerTaxonomyTermSetupTrait.php
new file mode 100644
index 0000000000000000000000000000000000000000..59109016c876bdfa09c168a7a3d0422ec88aa331
--- /dev/null
+++ b/web/modules/scheduler/tests/src/Traits/SchedulerTaxonomyTermSetupTrait.php
@@ -0,0 +1,172 @@
+<?php
+
+namespace Drupal\Tests\scheduler\Traits;
+
+use Drupal\taxonomy\Entity\Term;
+use Drupal\taxonomy\Entity\Vocabulary;
+
+/**
+ * Additional setup trait for Scheduler tests that use Taxonomy.
+ *
+ * This builds on the standard SchedulerSetupTrait.
+ */
+trait SchedulerTaxonomyTermSetupTrait {
+
+  /**
+   * The internal name of the standard taxonomy vocabulary created for testing.
+   *
+   * @var string
+   */
+  protected $vocabularyId = 'test_vocab';
+
+  /**
+   * The readable name of the standard media type created for testing.
+   *
+   * @var string
+   */
+  protected $vocabularyName = 'Test Vocabulary';
+
+  /**
+   * The media type object which is enabled for scheduling.
+   *
+   * @var \Drupal\taxonomy\VocabularyInterface
+   */
+  protected $vocabulary;
+
+  /**
+   * The internal name of the media type not enabled for scheduling.
+   *
+   * @var string
+   */
+  protected $nonSchedulerVocabularyId = 'vocab_not_enabled';
+
+  /**
+   * The readable label of the media type not enabled for scheduling.
+   *
+   * @var string
+   */
+  protected $nonSchedulerVocabularyName = 'Test Vocabulary - not for scheduling';
+
+  /**
+   * The media type object which is not enabled for scheduling.
+   *
+   * @var \Drupal\taxonomy\VocabularyInterface
+   */
+  protected $nonSchedulerVocabulary;
+
+  /**
+   * The taxonomy term storage.
+   *
+   * @var \Drupal\Core\Entity\ContentEntityStorageInterface
+   */
+  protected $taxonomyTermStorage;
+
+  /**
+   * Set common properties, define content types and create users.
+   */
+  public function schedulerTaxonomyTermSetUp() {
+
+    // Create a test vocabulary that is enabled for scheduling.
+    /** @var \Drupal\taxonomy\VocabularyInterface $vocabulary */
+    $this->vocabulary = Vocabulary::create([
+      'vid' => $this->vocabularyId,
+      'name' => $this->vocabularyName,
+    ]);
+    $this->vocabulary->save();
+
+    // Add scheduler functionality to the vocabulary.
+    $this->vocabulary->setThirdPartySetting('scheduler', 'publish_enable', TRUE)
+      ->setThirdPartySetting('scheduler', 'unpublish_enable', TRUE)
+      ->save();
+
+    // Enable the scheduler fields in the default form display, mimicking what
+    // would be done if the entity bundle had been enabled via admin UI.
+    $this->container->get('entity_display.repository')
+      ->getFormDisplay('taxonomy_term', $this->vocabularyId)
+      ->setComponent('publish_on', ['type' => 'datetime_timestamp_no_default'])
+      ->setComponent('unpublish_on', ['type' => 'datetime_timestamp_no_default'])
+      ->save();
+
+    // Create a vocabulary which is not enabled for scheduling.
+    /** @var \Drupal\taxonomy\VocabularyInterface $nonSchedulerVocabulary */
+    $this->nonSchedulerVocabulary = Vocabulary::create([
+      'vid' => $this->nonSchedulerVocabularyId,
+      'name' => $this->nonSchedulerVocabularyName,
+    ]);
+    $this->nonSchedulerVocabulary->save();
+
+    /** @var  taxonomyTermStorage $taxonomyTermStorage */
+    $this->taxonomyTermStorage = $this->container->get('entity_type.manager')->getStorage('taxonomy_term');
+
+    // Add extra permisssions to the role assigned to the adminUser.
+    $this->addPermissionsToUser($this->adminUser, [
+      'create terms in ' . $this->vocabularyId,
+      'edit terms in ' . $this->vocabularyId,
+      'delete terms in ' . $this->vocabularyId,
+      'create terms in ' . $this->nonSchedulerVocabularyId,
+      'edit terms in ' . $this->nonSchedulerVocabularyId,
+      'administer taxonomy',
+      'access taxonomy overview',
+      'schedule publishing of taxonomy_term',
+      'view scheduled taxonomy_term',
+    ]);
+
+    // Add extra permisssions to the role assigned to the schedulerUser.
+    $this->addPermissionsToUser($this->schedulerUser, [
+      'create terms in ' . $this->vocabularyId,
+      'edit terms in ' . $this->vocabularyId,
+      'schedule publishing of taxonomy_term',
+    ]);
+
+  }
+
+  /**
+   * Creates a taxonomy term.
+   *
+   * @param array $values
+   *   The values to use for the entity.
+   *
+   * @return \Drupal\taxonomy\TermInterface
+   *   The created taxonomy term object.
+   */
+  public function createTaxonomyTerm(array $values) {
+    // Provide defaults for the critical values.
+    $values += [
+      'vid' => $this->vocabularyId,
+      // If no name is specified then use title, or default to a random name.
+      'name' => $values['title'] ?? $this->randomMachineName(),
+    ];
+    /** @var \Drupal\taxonomy\TermInterface $term */
+    $term = Term::create($values);
+    $term->save();
+    return $term;
+  }
+
+  /**
+   * Gets a taxonomy term from storage.
+   *
+   * @param string $name
+   *   Optional name text to match on. If given and no match, returns NULL.
+   *   If no $name is given then returns the term with the highest id value.
+   *
+   * @return \Drupal\taxonomy\Entity\Term
+   *   The taxonomy term object.
+   */
+  public function getTaxonomyTerm(string $name = NULL) {
+    $query = $this->taxonomyTermStorage->getQuery()
+      ->accessCheck(FALSE)
+      ->sort('tid', 'DESC');
+    if (!empty($name)) {
+      $query->condition('name', $name);
+    }
+    $result = $query->execute();
+    if (count($result)) {
+      $term_id = reset($result);
+      return $this->taxonomyTermStorage->load($term_id);
+    }
+    else {
+      return NULL;
+    }
+  }
+
+}