From 3ea6cb03c3d765dd172010191cc07e161884f4e7 Mon Sep 17 00:00:00 2001
From: Arlen Johnson <arlen@sphericalcowgroup.com>
Date: Fri, 13 Jan 2023 15:40:46 -0500
Subject: [PATCH] Improve access to related models (CO-2229) (#57)

* Provide tabbed subnavigation for related models. Deprecate Noty.js in favor of Boostrap Alerts. (CO-2229)

* Ensure flash messages are available on all pages. (CO-2229)

* Clean up information alert on reconcile page. (CO-2229)
---
 app/resources/locales/en_US/default.po        |   3 +
 app/src/View/Helper/AlertHelper.php           |  96 ++++++++++++++
 app/templates/AttributeMappings/columns.inc   |   5 +
 .../AttributeMappings/fields-nav.inc          |  34 +++++
 app/templates/AttributeMaps/fields-nav.inc    |  34 +++++
 app/templates/Matchgrids/configure.php        |  68 ++++++----
 app/templates/Matchgrids/delete.php           |   2 +-
 app/templates/Matchgrids/manage.php           |  13 +-
 app/templates/Matchgrids/pending.php          |  13 +-
 app/templates/Matchgrids/reconcile.php        |  12 +-
 app/templates/Matchgrids/select.php           |   2 +-
 app/templates/RuleAttributes/columns.inc      |   5 +
 app/templates/RuleAttributes/fields-nav.inc   |  34 +++++
 app/templates/Rules/fields-nav.inc            |  34 +++++
 app/templates/Standard/add-edit-view.php      |  69 +++++++---
 app/templates/Standard/index.php              |  61 ++++++---
 app/templates/element/flash.php               |  44 +++++++
 app/templates/element/flash/default.php       |  22 +---
 app/templates/element/flash/error.php         |  14 +-
 app/templates/element/flash/information.php   |  15 +--
 app/templates/element/flash/success.php       |  16 +--
 app/templates/element/subnavigation.php       | 120 ++++++++++++++++++
 app/webroot/css/co-base.css                   |  90 +++++++++++--
 app/webroot/css/co-color.css                  |  17 ++-
 24 files changed, 690 insertions(+), 133 deletions(-)
 create mode 100644 app/src/View/Helper/AlertHelper.php
 create mode 100644 app/templates/AttributeMappings/fields-nav.inc
 create mode 100644 app/templates/AttributeMaps/fields-nav.inc
 create mode 100644 app/templates/RuleAttributes/fields-nav.inc
 create mode 100644 app/templates/Rules/fields-nav.inc
 create mode 100644 app/templates/element/flash.php
 create mode 100644 app/templates/element/subnavigation.php

diff --git a/app/resources/locales/en_US/default.po b/app/resources/locales/en_US/default.po
index 93d7b2464..c23fc6236 100644
--- a/app/resources/locales/en_US/default.po
+++ b/app/resources/locales/en_US/default.po
@@ -810,3 +810,6 @@ msgstr "Matchgrid Configuration"
 
 msgid "match.ti.matchgrid"
 msgstr "Matchgrid: {0}"
+
+msgid "match.ti.properties"
+msgstr "Properties"
diff --git a/app/src/View/Helper/AlertHelper.php b/app/src/View/Helper/AlertHelper.php
new file mode 100644
index 000000000..3cb511fac
--- /dev/null
+++ b/app/src/View/Helper/AlertHelper.php
@@ -0,0 +1,96 @@
+<?php
+/**
+ * COmanage Match Alert Helper
+ *
+ * Portions licensed to the University Corporation for Advanced Internet
+ * Development, Inc. ("UCAID") under one or more contributor license agreements.
+ * See the NOTICE file distributed with this work for additional information
+ * regarding copyright ownership.
+ *
+ * UCAID licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with the
+ * License. You may obtain a copy of the License at:
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * @link          https://www.internet2.edu/comanage COmanage Project
+ * @package       registry
+ * @since         COmanage Match v1.1.0
+ * @license       Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0)
+ */
+
+declare(strict_types=1);
+
+namespace App\View\Helper;
+
+use \Cake\View\Helper;
+use phpDocumentor\Reflection\Types\Boolean;
+
+/**
+ * Helper which will produce Bootstrap based alert
+ *
+ * @param string $message          Alert message
+ * @param string $type             Define the type of Alert. The value should be one of
+ *                                 [success,warning,danger,info]. Defaults to 'warning'
+ * @param boolean $dismissable     Can the Alert be dismissed? Defaults to false.
+ * @param string|null $title       Title to display (typically "Success", "Error", or "Warning"). Defaults to null.
+ * @param boolean $dis_text_dark   Disable dark-text fonts for light|info color mode.
+ * @return mixed - a constructed HTML block
+ * @since  COmanage Registry v5.0.0
+ */
+class AlertHelper extends Helper {
+  
+  public $helpers = ['Html'];
+  
+  public function alert(
+    string $message,
+    string $type = 'warning',
+    bool $dismissable = false,
+    string $title = null ) {
+    
+    $closeButton = '';
+    $dismissableClass = '';
+    if($dismissable) {
+      $closeButton = '
+      <span class="alert-button">
+        <button type="button" class="btn-close nospin" data-bs-dismiss="alert" aria-label="Close"></button>
+      </span>
+    ';
+      $dismissableClass = ' alert-dismissible';
+    }
+    
+    $titleMarkup = '';
+    if(!empty($title)) {
+      $titleMarkup = '<span class="alert-title-text">' . $title . '</span>';
+    }
+    
+    return '
+    <div class="alert alert-' . $type . $dismissableClass . ' co-alert" role="alert">
+      <div class="alert-body d-flex align-items-center">
+        <span class="alert-title d-flex align-items-center">
+          ' . $this->getAlertIcon($type) . $titleMarkup .  '
+        </span>
+        <span class="alert-message">        
+          ' . $message . '
+        </span>
+        ' . $closeButton . '
+      </div>
+    </div>
+  ';
+  }
+  
+  public function getAlertIcon(string $type) {
+    switch($type) {
+      case('success'): return '<span class="material-icons-outlined alert-icon">check_circle</span>';
+      case('info'): return '<span class="material-icons-outlined alert-icon">info</span>';
+      default: return '<span class="material-icons-outlined alert-icon">report_problem</span>';
+    }
+  }
+  
+}
\ No newline at end of file
diff --git a/app/templates/AttributeMappings/columns.inc b/app/templates/AttributeMappings/columns.inc
index 648857d6e..f07c34ebd 100644
--- a/app/templates/AttributeMappings/columns.inc
+++ b/app/templates/AttributeMappings/columns.inc
@@ -46,4 +46,9 @@ $topLinks = [
     'icon'   => 'file_download',
     'label'  => __('match.op.AttributeMappings.install.nicknames.en')
   ]
+];
+  
+$subnav = [
+  'name' => 'attribute_map',
+  'active' => 'mappings'
 ];
\ No newline at end of file
diff --git a/app/templates/AttributeMappings/fields-nav.inc b/app/templates/AttributeMappings/fields-nav.inc
new file mode 100644
index 000000000..0010d4300
--- /dev/null
+++ b/app/templates/AttributeMappings/fields-nav.inc
@@ -0,0 +1,34 @@
+<?php
+  /**
+   * COmanage Match Attribute Mappings Edit Navigation
+   *
+   * Portions licensed to the University Corporation for Advanced Internet
+   * Development, Inc. ("UCAID") under one or more contributor license agreements.
+   * See the NOTICE file distributed with this work for additional information
+   * regarding copyright ownership.
+   *
+   * UCAID licenses this file to you under the Apache License, Version 2.0
+   * (the "License"); you may not use this file except in compliance with the
+   * License. You may obtain a copy of the License at:
+   *
+   * http://www.apache.org/licenses/LICENSE-2.0
+   *
+   * Unless required by applicable law or agreed to in writing, software
+   * distributed under the License is distributed on an "AS IS" BASIS,
+   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   * See the License for the specific language governing permissions and
+   * limitations under the License.
+   *
+   * @link          https://www.internet2.edu/comanage COmanage Project
+   * @package       registry
+   * @since         COmanage Match v1.1.0
+   * @license       Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0)
+   */
+
+// XXX: If fields.inc can become configuration only, move the contents of this file into fields.inc
+$topLinks = [];
+
+$subnav = [
+  'name' => 'attribute_map',
+  'active' => 'mappings'
+];
\ No newline at end of file
diff --git a/app/templates/AttributeMaps/fields-nav.inc b/app/templates/AttributeMaps/fields-nav.inc
new file mode 100644
index 000000000..87743f4a3
--- /dev/null
+++ b/app/templates/AttributeMaps/fields-nav.inc
@@ -0,0 +1,34 @@
+<?php
+  /**
+   * COmanage Match Attribute Maps Edit Navigation
+   *
+   * Portions licensed to the University Corporation for Advanced Internet
+   * Development, Inc. ("UCAID") under one or more contributor license agreements.
+   * See the NOTICE file distributed with this work for additional information
+   * regarding copyright ownership.
+   *
+   * UCAID licenses this file to you under the Apache License, Version 2.0
+   * (the "License"); you may not use this file except in compliance with the
+   * License. You may obtain a copy of the License at:
+   *
+   * http://www.apache.org/licenses/LICENSE-2.0
+   *
+   * Unless required by applicable law or agreed to in writing, software
+   * distributed under the License is distributed on an "AS IS" BASIS,
+   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   * See the License for the specific language governing permissions and
+   * limitations under the License.
+   *
+   * @link          https://www.internet2.edu/comanage COmanage Project
+   * @package       registry
+   * @since         COmanage Match v1.1.0
+   * @license       Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0)
+   */
+
+// XXX: If fields.inc can become configuration only, move the contents of this file into fields.inc
+$topLinks = [];
+
+$subnav = [
+  'name' => 'attribute_map',
+  'active' => 'properties'
+];
\ No newline at end of file
diff --git a/app/templates/Matchgrids/configure.php b/app/templates/Matchgrids/configure.php
index bd1cc45c8..b60485e26 100644
--- a/app/templates/Matchgrids/configure.php
+++ b/app/templates/Matchgrids/configure.php
@@ -1,38 +1,50 @@
 <?php
-  /**
-   * COmanage Match Matchgrid Configure Template
-   *
-   * Portions licensed to the University Corporation for Advanced Internet
-   * Development, Inc. ("UCAID") under one or more contributor license agreements.
-   * See the NOTICE file distributed with this work for additional information
-   * regarding copyright ownership.
-   *
-   * UCAID licenses this file to you under the Apache License, Version 2.0
-   * (the "License"); you may not use this file except in compliance with the
-   * License. You may obtain a copy of the License at:
-   *
-   * http://www.apache.org/licenses/LICENSE-2.0
-   *
-   * Unless required by applicable law or agreed to in writing, software
-   * distributed under the License is distributed on an "AS IS" BASIS,
-   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-   * See the License for the specific language governing permissions and
-   * limitations under the License.
-   *
-   * @link          http://www.internet2.edu/comanage COmanage Project
-   * @package       match
-   * @since         COmanage Match v1.0.0
-   * @license       Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0)
-   */
-
-  declare(strict_types = 1);
+/**
+ * COmanage Match Matchgrid Configure Template
+ *
+ * Portions licensed to the University Corporation for Advanced Internet
+ * Development, Inc. ("UCAID") under one or more contributor license agreements.
+ * See the NOTICE file distributed with this work for additional information
+ * regarding copyright ownership.
+ *
+ * UCAID licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with the
+ * License. You may obtain a copy of the License at:
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * @link          http://www.internet2.edu/comanage COmanage Project
+ * @package       match
+ * @since         COmanage Match v1.0.0
+ * @license       Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0)
+ */
+
+declare(strict_types = 1);
+
+// $flashArgs pass banner messages to the flash element container
+$flashArgs = [];
+if(!empty($indexBanners)) {
+  $flashArgs['vv_index_banners'] = $indexBanners;
+}
+if(!empty($banners)) {
+  $flashArgs['vv_banners'] = $banners;
+}
 ?>
-<div class="titleNavContainer">
+
+<div class="pageTitleContainer">
   <div class="pageTitle">
     <h1><?= $vv_title; ?></h1>
   </div>
 </div>
 
+<?= $this->element('flash', $flashArgs); ?>
+
 <?= __('match.meta.version', [chop(file_get_contents(CONFIG . DS . "VERSION"))]); ?>
 
 <section class="inner-content">
diff --git a/app/templates/Matchgrids/delete.php b/app/templates/Matchgrids/delete.php
index 72ec888df..96fba000b 100644
--- a/app/templates/Matchgrids/delete.php
+++ b/app/templates/Matchgrids/delete.php
@@ -28,7 +28,7 @@
 declare(strict_types = 1);
 
 ?>
-<div class="titleNavContainer">
+<div class="pageTitleContainer">
   <div class="pageTitle">
     <h1><?= $vv_title; ?></h1>
   </div>
diff --git a/app/templates/Matchgrids/manage.php b/app/templates/Matchgrids/manage.php
index 9ca7c5cc6..e6f62b9d1 100644
--- a/app/templates/Matchgrids/manage.php
+++ b/app/templates/Matchgrids/manage.php
@@ -26,8 +26,17 @@
  */
 
 declare(strict_types = 1);
+  
+// $flashArgs pass banner messages to the flash element container
+$flashArgs = [];
+if(!empty($indexBanners)) {
+  $flashArgs['vv_index_banners'] = $indexBanners;
+}
+if(!empty($banners)) {
+  $flashArgs['vv_banners'] = $banners;
+}
 ?>
-<div class="titleNavContainer">
+<div class="pageTitleContainer">
   <div class="pageTitle">
     <h1><?= $vv_title; ?></h1>
   </div>
@@ -63,6 +72,8 @@
     </p>
   <?php endif; ?>
   
+  <?= $this->element('flash', $flashArgs); ?>
+  
   <!-- Matchgrid Management -->
   <div id="matchgrid-management" class="call-to-action-blocks">
     <div class="call-to-action">
diff --git a/app/templates/Matchgrids/pending.php b/app/templates/Matchgrids/pending.php
index bb3e6aacb..95d3e3c72 100644
--- a/app/templates/Matchgrids/pending.php
+++ b/app/templates/Matchgrids/pending.php
@@ -26,14 +26,25 @@
  */
 
 declare(strict_types = 1);
+  
+// $flashArgs pass banner messages to the flash element container
+$flashArgs = [];
+if(!empty($indexBanners)) {
+  $flashArgs['vv_index_banners'] = $indexBanners;
+}
+if(!empty($banners)) {
+  $flashArgs['vv_banners'] = $banners;
+}
 ?>
 
-<div class="titleNavContainer">
+<div class="pageTitleContainer">
   <div class="pageTitle">
     <h1><?= $vv_title; ?></h1>
   </div>
 </div>
 
+<?= $this->element('flash', $flashArgs); ?>
+
 <h2><?= __('match.rs.pending', [count($vv_pending)]); ?></h2>
 
 <table>
diff --git a/app/templates/Matchgrids/reconcile.php b/app/templates/Matchgrids/reconcile.php
index 0f401fd0a..98672707e 100644
--- a/app/templates/Matchgrids/reconcile.php
+++ b/app/templates/Matchgrids/reconcile.php
@@ -84,13 +84,23 @@
     }
   }
   $canAttr = array_merge($canAttr, $tempArr);
+  
+  // $flashArgs pass banner messages to the flash element container
+  $flashArgs = [];
+  if(!empty($indexBanners)) {
+    $flashArgs['vv_index_banners'] = $indexBanners;
+  }
+  if(!empty($banners)) {
+    $flashArgs['vv_banners'] = $banners;
+  }
 ?>
 
-<div class="titleNavContainer">
+<div class="pageTitleContainer">
   <div class="pageTitle">
     <h1><?= __('match.op.reconcile.requests'); ?></h1>
   </div>
 </div>
+<?= $this->element('flash', $flashArgs); ?>
 <div class="view-controls">
   <span class="view-controls-title"><?= __('match.op.highlight'); ?></span>
   <div class="form-check form-check-inline">
diff --git a/app/templates/Matchgrids/select.php b/app/templates/Matchgrids/select.php
index fbe116a5d..b4b7ff3eb 100644
--- a/app/templates/Matchgrids/select.php
+++ b/app/templates/Matchgrids/select.php
@@ -29,7 +29,7 @@
 
 use \App\Lib\Enum\PermissionEnum;
 ?>
-<div class="titleNavContainer">
+<div class="pageTitleContainer">
   <div class="pageTitle">
     <h1><?= $vv_title; ?></h1>
   </div>
diff --git a/app/templates/RuleAttributes/columns.inc b/app/templates/RuleAttributes/columns.inc
index d9788b62e..bace8c84e 100644
--- a/app/templates/RuleAttributes/columns.inc
+++ b/app/templates/RuleAttributes/columns.inc
@@ -42,4 +42,9 @@ $indexColumns = [
     'class' => 'SearchTypeEnum',
     'sortable' => true
   ]
+];
+  
+$subnav = [
+  'name' => 'rule',
+  'active' => 'attributes'
 ];
\ No newline at end of file
diff --git a/app/templates/RuleAttributes/fields-nav.inc b/app/templates/RuleAttributes/fields-nav.inc
new file mode 100644
index 000000000..448d29bf6
--- /dev/null
+++ b/app/templates/RuleAttributes/fields-nav.inc
@@ -0,0 +1,34 @@
+<?php
+  /**
+   * COmanage Match Rules Attributes Edit Navigation
+   *
+   * Portions licensed to the University Corporation for Advanced Internet
+   * Development, Inc. ("UCAID") under one or more contributor license agreements.
+   * See the NOTICE file distributed with this work for additional information
+   * regarding copyright ownership.
+   *
+   * UCAID licenses this file to you under the Apache License, Version 2.0
+   * (the "License"); you may not use this file except in compliance with the
+   * License. You may obtain a copy of the License at:
+   *
+   * http://www.apache.org/licenses/LICENSE-2.0
+   *
+   * Unless required by applicable law or agreed to in writing, software
+   * distributed under the License is distributed on an "AS IS" BASIS,
+   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   * See the License for the specific language governing permissions and
+   * limitations under the License.
+   *
+   * @link          https://www.internet2.edu/comanage COmanage Project
+   * @package       registry
+   * @since         COmanage Match v1.1.0
+   * @license       Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0)
+   */
+
+// XXX: If fields.inc can become configuration only, move the contents of this file into fields.inc
+$topLinks = [];
+
+$subnav = [
+  'name' => 'rule',
+  'active' => 'attributes'
+];
\ No newline at end of file
diff --git a/app/templates/Rules/fields-nav.inc b/app/templates/Rules/fields-nav.inc
new file mode 100644
index 000000000..dc914bfec
--- /dev/null
+++ b/app/templates/Rules/fields-nav.inc
@@ -0,0 +1,34 @@
+<?php
+  /**
+   * COmanage Match Rules Edit Navigation
+   *
+   * Portions licensed to the University Corporation for Advanced Internet
+   * Development, Inc. ("UCAID") under one or more contributor license agreements.
+   * See the NOTICE file distributed with this work for additional information
+   * regarding copyright ownership.
+   *
+   * UCAID licenses this file to you under the Apache License, Version 2.0
+   * (the "License"); you may not use this file except in compliance with the
+   * License. You may obtain a copy of the License at:
+   *
+   * http://www.apache.org/licenses/LICENSE-2.0
+   *
+   * Unless required by applicable law or agreed to in writing, software
+   * distributed under the License is distributed on an "AS IS" BASIS,
+   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   * See the License for the specific language governing permissions and
+   * limitations under the License.
+   *
+   * @link          https://www.internet2.edu/comanage COmanage Project
+   * @package       registry
+   * @since         COmanage Match v1.1.0
+   * @license       Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0)
+   */
+
+// XXX: If fields.inc can become configuration only, move the contents of this file into fields.inc
+$topLinks = [];
+
+$subnav = [
+  'name' => 'rule',
+  'active' => 'properties'
+];
\ No newline at end of file
diff --git a/app/templates/Standard/add-edit-view.php b/app/templates/Standard/add-edit-view.php
index 69176818a..e0c8d235c 100644
--- a/app/templates/Standard/add-edit-view.php
+++ b/app/templates/Standard/add-edit-view.php
@@ -32,10 +32,57 @@
 $action = $this->request->getParam('action');
 // $this->name = Models
 $modelsName = $this->name;
+
+// Include subnavigation structures on add/edit/view pages
+// XXX: if fields.inc is made configuration only, move the contents of fields-nav.inc into fields.inc
+// Include subnav on all but the top-level object's Add view when subnav exists.
+if((!empty($vv_primary_link_model) && $vv_primary_link_model != 'Matchgrids') || $action != 'add') {
+  if(file_exists(ROOT . DS . "templates" . DS . $modelsName . DS . "fields-nav.inc")) {
+    include(ROOT . DS . "templates" . DS . $modelsName . DS . "fields-nav.inc");
+  }
+}
+  
+// $flashArgs pass banner messages to the flash element container
+$flashArgs = [];
+if(!empty($banners)) {
+  // XXX this doesn't work yet because we don't include fields.inc until later
+  //     either create a second file to include earlier, or use a function to emit
+  //     the fields (which would be more consistent with how Views render...)
+  $flashArgs['vv_banners'] = $banners;
+}
 ?>
-<div class="titleNavContainer">
+
+<?php if(!empty($subnav)): ?>
+  <div id="subnavigation">
+    <div class="supertitle">
+      <h1>
+        <?php
+          if(!empty($vv_primary_link_obj)
+            && !empty($vv_primary_link_model)
+            && $vv_primary_link_model != 'Matchgrids') {
+            print $vv_primary_link_obj->name;
+          } else {
+            // we are editing the top-level object
+            print $vv_obj-> name;
+          }
+        ?>
+      </h1>
+    </div>
+    
+    <?php /* Flash Messages are placed below supertitle when subnavigation exists. */ ?>
+    <?= $this->element('flash', $flashArgs); ?>
+    
+    <?= $this->element('subnavigation', $subnav); ?>
+  </div>
+<?php endif; ?>
+  
+<div class="pageTitleContainer">
   <div class="pageTitle">
-    <h1><?= $vv_title; ?></h1>
+    <?php if(empty($subnav)): ?>
+      <h1><?= $vv_title; ?></h1>
+    <?php else: ?>
+      <h2><?= $vv_title; ?></h2>
+    <?php endif; ?>
   </div>
   
   <?php if(
@@ -180,20 +227,10 @@
   <?php endif; ?>
 </div>
 
-<?php
-// XXX this doesn't work yet because we don't include fields.inc until later
-//     either create a second file to include earlier, or use a function to emit
-//     the fields (which would be more consistent with how Views render...)
-?>
-<?php if(!empty($banners)): ?>
-  <?php foreach($banners as $b): ?>
-    <div class="co-info-topbox">
-      <em class="material-icons">info</em>
-      <?php print $b; ?>
-    </div>
-  <?php endforeach; // $banners ?>
-<?php endif; // $banners ?>
-
+<?php if(empty($subnav)): ?>
+  <?php /* Flash Messages are placed below the main title when there's no subnavigation. */ ?>
+  <?= $this->element('flash', $flashArgs); ?>
+<?php endif; ?>
 
 <?php    
 // By default, the form will POST to the current controller
diff --git a/app/templates/Standard/index.php b/app/templates/Standard/index.php
index edaf51630..592154357 100644
--- a/app/templates/Standard/index.php
+++ b/app/templates/Standard/index.php
@@ -83,10 +83,45 @@ function _column_key($modelsName, $c, $tz=null) {
   // Otherwise look for the general key
   return __('match.fd.'.$c);
 }
+
+// $flashArgs pass banner messages to the flash element container
+$flashArgs = [];
+if(!empty($indexBanners)) {
+  $flashArgs['vv_index_banners'] = $indexBanners;
+}
+if(!empty($banners)) {
+  $flashArgs['vv_banners'] = $banners;
+}
 ?>
-<div class="titleNavContainer">
+
+<?php if(!empty($subnav)): ?>
+  <div id="subnavigation">
+    <div class="supertitle">
+      <h1>
+        <?php
+          if(!empty($vv_primary_link_obj)
+            && !empty($vv_primary_link_model)
+            && $vv_primary_link_model != 'Matchgrids') {
+            print $vv_primary_link_obj->name;
+          }
+        ?>
+      </h1>
+    </div>
+    
+    <?php /* Flash Messages are placed below supertitle when subnavigation exists. */ ?>
+    <?= $this->element('flash', $flashArgs); ?>
+    
+    <?= $this->element('subnavigation', $subnav); ?>
+  </div>
+<?php endif; ?>  
+  
+<div class="pageTitleContainer">
   <div class="pageTitle">
-    <h1><?= $vv_title; ?></h1>
+    <?php if(empty($subnav)): ?>
+      <h1><?= $vv_title; ?></h1>
+    <?php else: ?>
+      <h2><?= $vv_title; ?></h2>
+    <?php endif; ?>
   </div>
   
   <?php if($vv_permissions['add']): ?>
@@ -203,24 +238,12 @@ function _column_key($modelsName, $c, $tz=null) {
     ?>
   <?php endif; ?>
 </div>
-<?php if(!empty($indexBanners)): ?>
-  <?php foreach($indexBanners as $b): ?>
-    <div class="co-info-topbox">
-      <em class="material-icons">info</em>
-      <?= $b; ?>
-    </div>
-  <?php endforeach; // $indexBanners ?>
-<?php endif; // $indexBanners ?>
-
-<?php if(!empty($banners)): ?>
-  <?php foreach($banners as $b): ?>
-    <div class="co-info-topbox">
-      <em class="material-icons">info</em>
-      <?= $b; ?>
-    </div>
-  <?php endforeach; // $banners ?>
-<?php endif; // $banners ?>
 
+<?php if(empty($subnav)): ?>
+  <?php /* Flash Messages are placed below the main title when there's no subnavigation. */ ?>
+  <?= $this->element('flash', $flashArgs); ?>
+<?php endif; ?>
+  
 <!-- Search block -->
 <?php if(!empty($enableSearch)): ?>
   <?= $this->element('search'); ?>
diff --git a/app/templates/element/flash.php b/app/templates/element/flash.php
new file mode 100644
index 000000000..d8e2e3b0f
--- /dev/null
+++ b/app/templates/element/flash.php
@@ -0,0 +1,44 @@
+<?php
+  /*
+   * COmanage Match Flash Message Container
+   *
+   * Portions licensed to the University Corporation for Advanced Internet
+   * Development, Inc. ("UCAID") under one or more contributor license agreements.
+   * See the NOTICE file distributed with this work for additional information
+   * regarding copyright ownership.
+   *
+   * UCAID licenses this file to you under the Apache License, Version 2.0
+   * (the "License"); you may not use this file except in compliance with the
+   * License. You may obtain a copy of the License at:
+   *
+   * http://www.apache.org/licenses/LICENSE-2.0
+   *
+   * Unless required by applicable law or agreed to in writing, software
+   * distributed under the License is distributed on an "AS IS" BASIS,
+   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   * See the License for the specific language governing permissions and
+   * limitations under the License.
+   *
+   * @link          https://www.internet2.edu/comanage COmanage Project
+   * @package       registry
+   * @since         COmanage Registry v1.1.0
+   * @license       Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0)
+   */
+?>
+
+<!-- Flash Messages and defined Info Banners -->
+<div class="alert-container" id="flash-messages">
+  <?= $this->Flash->render() ?>
+  
+  <?php if(!empty($vv_index_banners)): ?>
+    <?php foreach($vv_index_banners as $b): ?>
+      <?=  $this->Alert->alert($b, 'warning') ?>
+    <?php endforeach; // $vv_index_banners ?>
+  <?php endif; // $vv_index_banners ?>
+  
+  <?php if(!empty($vv_banners)): ?>
+    <?php foreach($vv_banners as $b): ?>
+      <?=  $this->Alert->alert($b, 'warning') ?>
+    <?php endforeach; // $vv_banners ?>
+  <?php endif; // $vv_banners ?>
+</div>
\ No newline at end of file
diff --git a/app/templates/element/flash/default.php b/app/templates/element/flash/default.php
index 9f1622fb8..e4cf46da5 100644
--- a/app/templates/element/flash/default.php
+++ b/app/templates/element/flash/default.php
@@ -1,4 +1,3 @@
-
 <?php
 /*
  * COmanage Match Default Flash Template
@@ -26,26 +25,11 @@
  * @license       Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0)
  */
 
-  // Reference examples from Cake 3
-  $class = 'message';
-  if (!empty($params['class'])) {
-    $class .= ' ' . $params['class'];
-  }
   if (!isset($params['escape']) || $params['escape'] !== false) {
     $message = h($message);
   }
 ?>
-<?php /*
-<div class="<?= h($class) ?>" onclick="this.classList.add('hidden');"><?= $message ?></div>
-*/ ?>
-
-<?php
-  if(!empty($message)) {
-    // Strip tags then escape quotes before handing Flash message to noty.js
-    $filteredMessage = filter_var(filter_var($message,FILTER_SANITIZE_STRING,FILTER_FLAG_NO_ENCODE_QUOTES),FILTER_SANITIZE_ADD_SLASHES);
-    // Replace all newlines with html breaks
-    $filteredMessage = str_replace(array("\r", "\n"), '<br/>', $filteredMessage);
-    print "<script>generateFlash('" . $filteredMessage . "', '" . $class . "');</script>";
-  }
-?>
 
+<?php if(!empty($message)): ?>
+  <?= $this->Alert->alert($message, 'warning', true) ?>
+<?php endif; ?>
diff --git a/app/templates/element/flash/error.php b/app/templates/element/flash/error.php
index 70ef60569..0e964314c 100644
--- a/app/templates/element/flash/error.php
+++ b/app/templates/element/flash/error.php
@@ -26,15 +26,11 @@
  */
 
   if (!isset($params['escape']) || $params['escape'] !== false) {
-    $message = h($message); // XXX probably redundant
-  }
-
-  if(!empty($message)) {
-    // Strip tags then escape quotes before handing Flash message to noty.js
-    $filteredMessage = filter_var(filter_var($message,FILTER_SANITIZE_STRING,FILTER_FLAG_NO_ENCODE_QUOTES),FILTER_SANITIZE_ADD_SLASHES);
-    // Replace all newlines with html breaks
-    $filteredMessage = str_replace(array("\r", "\n"), '<br/>', $filteredMessage);
-    print "<script>generateFlash('<span class=\"material-icons\">error</span>" . $filteredMessage . "', 'error');</script>";
+    $message = h($message);
   }
 ?>
 
+<?php if(!empty($message)): ?>
+  <?= $this->Alert->alert($message, 'danger', true) ?>
+<?php endif; ?>
+
diff --git a/app/templates/element/flash/information.php b/app/templates/element/flash/information.php
index 62bdcd5bd..a2aa4cb2a 100644
--- a/app/templates/element/flash/information.php
+++ b/app/templates/element/flash/information.php
@@ -24,17 +24,12 @@
  * @since         COmanage Match v1.0.0
  * @license       Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0)
  */
-
+  
   if (!isset($params['escape']) || $params['escape'] !== false) {
-    $message = h($message); // XXX probably redundant
-  }
-
-  if(!empty($message)) {
-    // Strip tags then escape quotes before handing Flash message to noty.js
-    $filteredMessage = filter_var(filter_var($message,FILTER_SANITIZE_STRING,FILTER_FLAG_NO_ENCODE_QUOTES),FILTER_SANITIZE_ADD_SLASHES);
-    // Replace all newlines with html breaks
-    $filteredMessage = str_replace(array("\r", "\n"), '<br/>', $filteredMessage);
-    print "<script>generateFlash('<span class=\"material-icons\">info</span>" . $filteredMessage . "', 'information');</script>";
+    $message = h($message);
   }
 ?>
 
+<?php if(!empty($message)): ?>
+  <?= $this->Alert->alert($message, 'information', true) ?>
+<?php endif; ?>
\ No newline at end of file
diff --git a/app/templates/element/flash/success.php b/app/templates/element/flash/success.php
index 6ac190ddf..43bbe2af1 100644
--- a/app/templates/element/flash/success.php
+++ b/app/templates/element/flash/success.php
@@ -24,16 +24,12 @@
  * @since         COmanage Match v1.0.0
  * @license       Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0)
  */
-
+  
   if (!isset($params['escape']) || $params['escape'] !== false) {
-    $message = h($message); // XXX probably redundant
-  }
-
-  if(!empty($message)) {
-    // Strip tags then escape quotes before handing Flash message to noty.js
-    $filteredMessage = filter_var(filter_var($message,FILTER_SANITIZE_STRING,FILTER_FLAG_NO_ENCODE_QUOTES),FILTER_SANITIZE_ADD_SLASHES);
-    // Replace all newlines with html breaks
-    $filteredMessage = str_replace(array("\r", "\n"), '<br/>', $filteredMessage);
-    print "<script>generateFlash('<span class=\"material-icons\">check_circle</span>" . $filteredMessage . "', 'success');</script>";
+    $message = h($message);
   }
 ?>
+
+<?php if(!empty($message)): ?>
+  <?= $this->Alert->alert($message, 'success', true) ?>
+<?php endif; ?>
diff --git a/app/templates/element/subnavigation.php b/app/templates/element/subnavigation.php
new file mode 100644
index 000000000..6120d0058
--- /dev/null
+++ b/app/templates/element/subnavigation.php
@@ -0,0 +1,120 @@
+<?php
+/*
+ * COmanage Match Subnavigation Tabs Element
+ *
+ * Portions licensed to the University Corporation for Advanced Internet
+ * Development, Inc. ("UCAID") under one or more contributor license agreements.
+ * See the NOTICE file distributed with this work for additional information
+ * regarding copyright ownership.
+ *
+ * UCAID licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with the
+ * License. You may obtain a copy of the License at:
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * 
+ * @link          http://www.internet2.edu/comanage COmanage Project
+ * @package       match
+ * @since         COmanage Match v1.1.0
+ * @license       Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0)
+ */
+  use Cake\View\Helper;
+  
+  $linkFilter = [];
+  $curId = NULL;
+  $curController = $this->request->getParam('controller');
+  $curAction = $this->request->getParam('action');
+  
+  if(!empty($vv_primary_link) && !empty($this->request->getQuery($vv_primary_link))) {
+    // This will work for most top-level index views
+    $curId = $this->request->getQuery($vv_primary_link);
+    $linkFilter = [$vv_primary_link => $curId];
+  } elseif (!empty($vv_obj)) {
+    // This will work for most top-level edit views
+    $curId = $vv_obj->id;
+    // XXX This is brittle. If we extend the subnav more generally, support this better from the controller.
+    if ($curController == 'Rules' && $curAction == 'edit') {
+      $linkFilter = ['rule_id' => $vv_obj->id];
+    } elseif ($curController == 'AttributeMaps' && $curAction == 'edit') {
+      $linkFilter = ['attribute_map_id' => $vv_obj->id];
+    } else {
+      if(!empty($vv_primary_link) && !empty($vv_primary_link_obj->id)) {
+        $curId = $vv_primary_link_obj->id;
+        $linkFilter = [$vv_primary_link => $vv_primary_link_obj->id];
+      } 
+    }
+  }
+?>
+  
+<!-- Top-Level Subnavigation Tabs -->
+<nav id="cm-<?= $name ?>-subnav-tabs" class="cm-subnav-tabs">
+  <ul class="nav nav-tabs">
+    
+    <?php if ($name == 'rule'): ?>
+      <!-- Rule Subnavigation -->
+      <li class="nav-item">
+        <?php
+          $linkClass = ($active == 'properties') ? 'nav-link active' : 'nav-link';
+          print $this->Html->link(
+            __('match.ti.properties'),
+            [ 'controller' => 'rules',
+              'action' => 'edit',
+              $curId
+            ],
+            ['class' => $linkClass]
+          );
+        ?>
+      </li>
+      <li class="nav-item">
+        <?php
+          $linkClass = ($active == 'attributes') ? 'nav-link active' : 'nav-link';
+          print $this->Html->link(
+            __('match.ct.RuleAttributes', 2),
+            [ 'controller' => 'rule_attributes',
+              'action' => 'index',
+              '?' => $linkFilter
+            ],
+            ['class' => $linkClass]
+          );
+        ?>
+      </li>
+    <?php endif; ?>
+  
+    <?php if ($name == 'attribute_map'): ?>
+      <!-- Attribute Maps Subnavigation -->
+      <li class="nav-item">
+        <?php
+          $linkClass = ($active == 'properties') ? 'nav-link active' : 'nav-link';
+          print $this->Html->link(
+            __('match.ti.properties'),
+            [ 'controller' => 'attribute_maps',
+              'action' => 'edit',
+              $curId
+            ],
+            ['class' => $linkClass]
+          );
+        ?>
+      </li>
+      <li class="nav-item">
+        <?php
+          $linkClass = ($active == 'mappings') ? 'nav-link active' : 'nav-link';
+          print $this->Html->link(
+            __('match.ct.AttributeMappings', 2),
+            [ 'controller' => 'attribute_mappings',
+              'action' => 'index',
+              '?' => $linkFilter
+            ],
+            ['class' => $linkClass]
+          );
+        ?>
+      </li>
+    <?php endif; ?>
+    
+  </ul>
+</nav>
\ No newline at end of file
diff --git a/app/webroot/css/co-base.css b/app/webroot/css/co-base.css
index d17351bd5..47c5fc25b 100644
--- a/app/webroot/css/co-base.css
+++ b/app/webroot/css/co-base.css
@@ -341,20 +341,61 @@ body.logged-in #top-menu {
   margin: 0 4px 0 -10px;
   vertical-align: text-bottom;
 }
+/* ALERT MESSAGES */
+#flash-messages {
+  margin: 0;
+}
+.co-alert {
+  margin: 0 auto 1em;
+  border-radius: 0;
+}
+.co-alert.alert-success {
+  background-color: var(--cmg-color-green-008);
+  border-color: var(--cmg-color-green-009);
+}
+.co-alert.alert-warning {
+  background-color: var(--cmg-color-yellow-001);
+  color: var(--cmg-color-yellow-002);
+  border-color: var(--cmg-color-yellow-003);
+}
+.co-alert.alert-danger {
+  background-color: var(--cmg-color-red-001);
+  color: var(--cmg-color-red-006);
+  border-color: var(--cmg-color-red-007);
+}
+.co-alert.alert-information {
+  background-color: var(--cmg-color-blue-006);
+  color: var(--cmg-color-blue-007);
+  border-color: var(--cmg-color-blue-001);
+}
+.co-alert .alert-icon {
+  margin-right: 0.1rem;
+}
+.co-alert .alert-title-text {
+  margin-right: 0.25em;
+}
+.co-alert .alert-body {
+  gap: 1em;
+}
+/* Alerts in the add-edit form: */
+ul.form-list li.alert-banner {
+  display: block;
+  padding: 0;
+}
+ul.form-list li.alert-banner .co-alert {
+  margin: 0;
+  border: none;
+}
 /* General icon and box styling */
 .co-info { /* info icon */
   float: left;
   margin: 0.3em 0.3em 0 0;
 }
-.co-alert { /* alert icon */
-  float:left;
-  margin:0 7px 20px 0;
-}
 .co-info-topbox {
   clear: both;
   padding: 1em;
-  background-color: var(--cmg-color-yellow-002);
-  border: 1px solid var(--cmg-color-lightgray-005);
+  background-color: var(--cmg-color-yellow-001);
+  border: 1px solid var(--cmg-color-yellow-003);
   margin-bottom: 1em;
 }
 #lastLogin p {
@@ -480,7 +521,7 @@ body.logged-in #top-menu {
 #breadcrumbs {
   font-size: 0.9em;
 }
-.titleNavContainer {
+.pageTitleContainer {
   display: flex;
   justify-content: space-between;
   margin: 1em 0 0.5em;
@@ -502,6 +543,31 @@ body.logged-in #top-menu {
 .pageTitle .archived {
   background-color: var(--cmg-color-gray-003);
 }
+/* SUBNAVIGATION & TABS */
+.supertitle {
+  padding: 1em 0;
+}
+#subnavigation nav {
+  padding: 0.5em 0;
+}
+.cm-subnav-tabs .nav-link {
+  text-transform: uppercase;
+  padding: 1em 1.5em;
+  color: var(--cmg-color-blue-001);
+}
+.cm-subnav-links .nav-link.active {
+  color: var(--cmg-color-gray-001);
+}
+.cm-subnav-links ul.list-inline {
+  margin: 0.5em 0 0 0;
+  font-size: 0.9em;
+}
+.cm-subnav-links .list-inline-item {
+  margin: 0;
+}
+.cm-subnav-links .list-inline-item a.nav-link {
+  padding: 0 1.5em 0.5em 0;
+}
 /* TOP CONTENT LINKS (contextual) */
 #topLinks {
   margin: 1.5em 0 -1.5em 0;
@@ -608,7 +674,7 @@ body.logged-in #top-menu {
 }
 .top-search input[type=text]:focus,
 .side-search input[type=text]:focus {
-  background-color: var(--cmg-color-yellow-003);
+  background-color: var(--cmg-color-yellow-004);
 }
 .top-search .submit-button,
 .top-search .clear-button,
@@ -731,7 +797,7 @@ body.logged-in #top-menu {
 }
 #reconcile-table.view-mode-diff td.diff,
 #reconcile-table.view-mode-both td.diff {
-  background-color: var(--cmg-color-yellow-003);
+  background-color: var(--cmg-color-yellow-004);
 }
 #reconcile-table tr.defined-attr th {
   background-color: var(--cmg-color-lightgray-001);
@@ -993,7 +1059,7 @@ ul.form-list input[type="password"] {
 ul.form-list input[type="text"]:focus,
 ul.form-list input[type="number"]:focus,
 ul.form-list input[type="password"]:focus {
-  background-color: var(--cmg-color-yellow-003);
+  background-color: var(--cmg-color-yellow-004);
 }
 ul.form-list select {
   font-size: 0.9em;
@@ -1409,8 +1475,8 @@ td.indented {
   border: 1px solid var(--cmg-color-blue-006);
 }
 .bg-outline-danger {
-  color: var(--cmg-color-red-005);;
-  border: 1px solid var(--cmg-color-red-005);;
+  color: var(--cmg-color-red-005);
+  border: 1px solid var(--cmg-color-red-005);
 }
 .bg-outline-success {
   color: var(--cmg-color-green-007);
diff --git a/app/webroot/css/co-color.css b/app/webroot/css/co-color.css
index 6f44f538c..9ca85e9d9 100644
--- a/app/webroot/css/co-color.css
+++ b/app/webroot/css/co-color.css
@@ -35,7 +35,8 @@
   --cmg-color-blue-003: #0c75c0;     /* submit buttons, .btn-primary */
   --cmg-color-blue-004: #9fc6e2;     /* spinner  */
   --cmg-color-blue-005: #53B1F4;     /* link focus borders for keyboard nav */
-  --cmg-color-blue-006: #17a2b8;     /* info bagde */
+  --cmg-color-blue-006: #d4ecff;     /* alert: info backgoround */
+  --cmg-color-blue-007: #0B3556;     /* alert: info text color */
 
   --cmg-color-gray-001: #222;        /* body text */
   --cmg-color-gray-002: #555;        /* headings text */
@@ -60,16 +61,22 @@
   --cmg-color-green-005: #030;       /* pagination border color */
   --cmg-color-green-006: #040;       /* pagination button color */
   --cmg-color-green-007: #28a745;    /* success badge */
+  --cmg-color-green-008: #b4ffba;    /* alert: success */
+  --cmg-color-green-009: #28a745;    /* alert: success text color, badge outline */
+  --cmg-color-green-010: #acf4b2;    /* alert: success border color */
 
-  --cmg-color-yellow-001: #f5f5bb;    /* yellow warning */
-  --cmg-color-yellow-002: #fffeb4;    /* infobox informational area */
-  --cmg-color-yellow-003: #ffd;       /* forms: focused input; diffing fields for reconciliation */
+  --cmg-color-yellow-001: #fffeb4;    /* alert: warning */
+  --cmg-color-yellow-002: #41412e;    /* alert: warning text color */
+  --cmg-color-yellow-003: #f6f5ae;    /* alert: warning border color */
+  --cmg-color-yellow-004: #ffd;       /* forms: focused input */
   
-  --cmg-color-red-001: #fcc;          /* red warning */
+  --cmg-color-red-001: #ffd4d4;       /* alert: danger */
   --cmg-color-red-002: #c00;          /* forms: error icons */
   --cmg-color-red-003: #e33;          /* title for deleted/archived */
   --cmg-color-red-004: #c33;          /* button */
   --cmg-color-red-005: #dc3545;       /* danger badge */
+  --cmg-color-red-006: #842029;       /* alert: danger text color */
+  --cmg-color-red-007: #f8cece;       /* alert: danger border color */
 
   --cmg-color-white: #fff;            /* white */
   --cmg-color-black: #000;            /* black */