Jake Vanderwerf
2025-11-25 2a2303d1dccc120dd7aa5f6b6ade0f89e0064850
inc/managers/CRUDManager.php
@@ -2,7 +2,9 @@
namespace JVBase\managers;
use JVBase\managers\UserTermsManager;
use JVBase\meta\MetaForm;
use JVBase\meta\MetaManager;
use JVBase\utility\Features;
use WP_User;
if (!defined('ABSPATH')) {
@@ -19,12 +21,20 @@
   protected array $filters;
   protected array $bulkActions;
   protected MetaManager $meta;
   protected MetaForm $form;
   protected array $taxonomies;
   protected array $statuses;
   protected array $fields;
   protected array $sections;
   protected array $stuck;
   //For Timeline-specific posts
   protected bool $isTimeline = false;
   protected array $nonTimelineFields = [];
   protected array $timelineSharedFields = [];
   protected array $timelineUniqueFields = [];
   protected bool $userCanPublish = false;
   public function __construct(string $content)
@@ -40,6 +50,7 @@
      $this->plural = $this->config['plural'];
      $this->content = $content;
      $this->fields = jvbGetFields($this->content, 'post');
      $this->maybeSetupTimeline();
      $this->sections = jvbGetSections($this->content, 'post');
      $this->stuck = [
         'post_title',
@@ -47,6 +58,11 @@
      ];
      $this->init();
      if ($this->isTimeline) {
         $this->stuck[] = 'post_thumbnail';
      }
      add_filter('jvbAdditionalActions', [$this, 'createItem']);
   }
   protected function init():void
@@ -56,13 +72,42 @@
      $this->initTaxonomies();
      $this->initFilters();
      $this->meta = new MetaManager(null, 'post', $this->content);
      $this->form = new MetaForm();
      $plural = strtolower($this->config['plural']??$this->content.'s');
      $this->userCanPublish = (jvbUserIsVerified()) ?
               user_can($this->user_id, "publish_{$plural}") : false;
   }
   protected function maybeSetupTimeline():void {
      $this->isTimeline = Features::forContent($this->content)->has('is_timeline');
      if (!$this->isTimeline) {
         return;
      }
      $this->timelineSharedFields = array_keys(array_filter($this->fields, function ($field) {
         if (!array_key_exists('for_all', $field) || $field['for_all'] === false){
            return true;
         }
         return false;
      }));
      array_unshift($this->timelineSharedFields, 'post_thumbnail');
      array_unshift($this->timelineSharedFields, 'post_title');
      array_unshift($this->timelineSharedFields, 'post_status');
      $this->timelineUniqueFields = array_keys(array_filter($this->fields, function ($field) {
         if (array_key_exists('for_all', $field) && $field['for_all'] === true) {
            return true;
         }
         return false;
      }));
      $all = array_merge($this->timelineUniqueFields, $this->timelineSharedFields);
      $this->nonTimelineFields = array_filter($this->fields, function ($field) use ($all) {
         return !in_array($field, $all);
      }, ARRAY_FILTER_USE_KEY);
   }
   protected function initTaxonomies():void
   {
      $this->taxonomies = array_filter(JVB_TAXONOMY, function ($config) {
@@ -80,41 +125,41 @@
            ],
            'future'=> [
               'label'  => 'Upcoming',
               'icon'   => 'future',
               'icon'   => 'clock-clockwise',
            ],
            'past'   => [
               'label'  => 'Past',
               'icon'   => 'past',
               'icon'   => 'clock-counter-clockwise',
            ],
            'repeat'=> [
               'label'  => 'Recurring',
               'icon'   => 'repeat',
            ],
            'draft'  => [
               'icon'   => 'hide',
               'icon'   => 'eye-closed',
               'label'  => 'Hidden',
            ],
            'trash'  => [
               'label'  => 'Scrapped',
               'icon'   => 'delete',
               'icon'   => 'trash',
            ],
         ] :
         [
            'all' => [
               'icon'  => 'all',
               'icon'  => 'infinity',
               'label' => 'Everything',
            ],
            'publish'=> [
               'icon'   => 'publish',
               'icon'   => 'eye',
               'label'  => 'Live',
            ],
            'draft'  => [
               'icon'   => 'hide',
               'icon'   => 'eye-closed',
               'label'  => 'Hidden',
            ],
            'trash'  => [
               'label'  => 'Scrapped',
               'icon'   => 'delete',
               'icon'   => 'trash',
            ],
         ];
   }
@@ -143,7 +188,7 @@
      foreach ($this->taxonomies as $taxonomy=> $config) {
         $this->filters['taxonomy'][$taxonomy] = [
            'label'  => $config['singular'],
            'icon'   => $taxonomy
            'icon'   => $config['icon']??'folder'
         ];
      }
   }
@@ -152,7 +197,7 @@
   {
      ob_start();
      ?>
      <div class="dashboard-page <?= esc_attr($this->content) ?>">
      <div class="dashboard-page <?= esc_attr($this->content) ?>"<?=($this->isTimeline) ? ' data-timeline' : ''?>>
         <?php
         $this->renderHeader();
         $this->renderContent();
@@ -181,7 +226,8 @@
   protected function renderHeaderActions():void
   {
      $uploadConfig = [
         'type'         => 'image',
         'type'         => 'upload',
         'subtype'      => 'image',
         'mode'         => (jvbCheck('single_image', $this->config)) ? 'direct' : 'selection',
         'create_new'   => true,
         'label'        => (array_key_exists('image_title', $this->config)) ? $this->config['image_title'] : 'Upload More '.$this->config['plural'],
@@ -189,14 +235,15 @@
         'singular'     => $this->singular,
         'plural'    => $this->plural,
         'multiple'     => true,
         'destination'  => 'post'
      ];
      if (!jvbCheck('single_image', $this->config)) {
         $uploadConfig['imageType'] = 'groupable';
      if (!array_key_exists('single_image', $this->config) || $this->config['single_image'] === false) {
         $uploadConfig['destination'] = 'post_group';
      }
      $uploadConfig['destination'] = 'post_group';
      if (!jvbCheck('single_image', $this->config)) {
         $uploadConfig['group_title'] = 'Create '.$this->config['plural'];
         $uploadConfig['group_description'] = '<p>Drag images into groups. Each group becomes its own '.$this->singular.'.</p>
         $uploadConfig['label'] = 'Create '.$this->config['plural'];
         $uploadConfig['upload_text'] = '<p>Drag images into groups. Each group becomes its own '.$this->singular.'.</p>
                  <p>You can also select multiple images and click the "Add to Group" button.</p>
                  <p>If a '.$this->singular.' has multiple images, you can select the '.jvbIcon('star').' to set an image as the main one.</p>
                  <p>Images left ungrouped will become individual '.$this->plural.'</p>
@@ -205,7 +252,6 @@
         $uploadConfig['description'] = 'Each image will become its own '.$this->singular.'.';
      }
      ?>
      <button type="button" class="create-item row" title="Create New <?= $this->singular?>"><?=jvbIcon('add') ?><span class="screen-reader-text">Create New <?= $this->singular?></span></button>
      <details open class="uploader">
         <summary class="row btw"><?= $this->config['upload_title'] ?? 'Bulk Upload '.$this->plural?></summary>
         <?php
@@ -243,9 +289,9 @@
      ob_start();
      ?>
      <div class="empty-state">
         <h3><?=jvbIcon($this->content)?>Nothing here<?=jvbIcon($this->content)?></h3>
         <h3><?=jvbIcon($this->config['icon'])?>Nothing here<?=jvbIcon($this->config['icon'])?></h3>
         <p>It doesn't look like you have any <?=$this->config['plural'] ?> yet.</p>
         <p><small><i>Add many by uploading images above.</i>, or click the "<?=jvbIcon('add')?>" button to add one at a time.</small></p>
         <p><small><i>Add many by uploading images above.</i>, or click the "<?=jvbIcon('plus-square')?>" button to add one at a time.</small></p>
      </div>
      <?php
      return ob_get_clean();
@@ -254,7 +300,7 @@
   protected function renderFilters():void
   {
      ?>
      <div class="all-filters col start">
      <div class="all-filters col start" data-ignore>
         <div class="search row start nowrap">
            <span class="label">Search:</span>
            <?= jvbSearch() ?>
@@ -273,7 +319,7 @@
               $this->renderDateFilters();
            ?>
            <button type="button" class="clear-filters row" hidden>
               <?= jvbIcon('close', ['title'    => 'Clear']); ?>
               <?= jvbIcon('x', ['title'    => 'Clear']); ?>
               Clear All Filters
            </button>
         </div>
@@ -294,8 +340,8 @@
                  'alphabetical' => 'Order alphabetically'
               ],
               'order' => [
                  'asc' => 'In ascending order (Z-A, oldest to newest)',
                  'desc' => 'In descending order (A-Z, newest to oldest)'
                  'sort-ascending' => 'In ascending order (Z-A, oldest to newest)',
                  'sort-descending' => 'In descending order (A-Z, newest to oldest)'
               ]
            ];
@@ -306,10 +352,11 @@
               <?php
               $i = 0;
               foreach ($option as $opt => $label) {
                  $icon = $opt === 'date' ? 'calendar' : $opt;
                  ?>
                  <input id="<?=$opt?>" class="btn" type="radio" name="<?=$o?>" data-filter="<?=$o?>" value="<?=$opt?>"<?=$i===0 ? ' checked':''?>>
                  <label for="<?=$opt?>" title="<?=$label?>"><?=jvbIcon($opt)?></label>
                  <label for="<?=$opt?>" title="<?=$label?>"><?=jvbIcon($icon)?></label>
                  <?php
                  $i++;
               }
@@ -355,8 +402,8 @@
         <?php
         $views = [
            'grid' => ['icon' => 'grid', 'label' => 'Grid View'],
            'list' => ['icon' => 'list', 'label' => 'List View'],
            'grid' => ['icon' => 'squares-four', 'label' => 'Grid View'],
            'list' => ['icon' => 'rows', 'label' => 'List View'],
            'table' => ['icon' => 'table', 'label' => 'Table View']
         ];
@@ -583,7 +630,7 @@
   protected function renderModals():void
   {
      $this->renderCreateModal();
//    $this->renderCreateModal();
      $this->renderEditModal();
      $this->renderBulkEditModal();
   }
@@ -600,7 +647,8 @@
   {
      ob_start();
      ?>
      <form class="edit-form" data-save="content" data-form-id="edit-<?=$this->content?>">
      <form class="edit-form" data-save="content" data-form-id="edit-<?=$this->content?>" data-autosave<?= ($this->isTimeline) ? ' data-timeline' : ''?>>
         <?= jvbFormStatus() ?>
         <input type="hidden" name="form-id" value="<?=uniqid('new-')?>" />
         <input type="hidden" name="content" value="<?=$this->content?>" />
         <div class="fields">
@@ -630,20 +678,50 @@
            } else {
               $tabs = false;
            }
            $fields = $this->fields;
            if (!$this->isTimeline) {
               $first = ['post_thumbnail', 'post_title', 'price'];
            $first = ['post_thumbnail', 'post_title', 'price'];
            foreach ($first as $f) {
               if (array_key_exists($f, $fields)) {
                  if ($tabs) {
                     $tabs['basic']['content'] .= $this->meta->render('form', $f, $fields[$f], false, true);
                  } else {
                     $this->meta->render('form', $f, $fields[$f]);
               foreach ($first as $f) {
                  if (array_key_exists($f, $fields)) {
                     if ($tabs) {
                        $tabs['basic']['content'] .= $this->meta->render('form', $f, $fields[$f], false, true);
                     } else {
                        $this->meta->render('form', $f, $fields[$f]);
                     }
                     unset($fields[$f]);
                  }
                  unset($fields[$f]);
               }
            }
            if ($this->isTimeline) {
               $temp = array_filter($fields, function ($field) {
                  return in_array($field, $this->timelineUniqueFields);
               }, ARRAY_FILTER_USE_KEY);
               $config = [
                  'type'      => 'gallery',
                  'subtype'   => 'timeline',
                  'data'      => 'timeline',
                  'label'     => 'Progression',
                  'fields' => $temp
               ];
               $content = '';
               foreach ($fields as $slug=> $field) {
                  if (in_array($slug, $this->timelineSharedFields)) {
                     $content .= $this->form->render($slug, null, $field, false, true);
                  }
               }
               $content .= $this->meta->render('form', 'timeline', $config, false,true);
               $tabs['progression']['content'] = $content;
               $fields = $this->nonTimelineFields;
            }
            foreach ($fields as $n => $config) {
               if ($tabs) {
                  $section = (array_key_exists('section', $config)) ? $config['section'] : 'basic';
@@ -651,10 +729,8 @@
               } else {
                  $this->meta->render('form', $n, $config);
               }
            }
            if ($tabs) {
               jvbRenderTabs($tabs);
            }
@@ -665,6 +741,28 @@
      return ob_get_clean();
   }
   protected function renderRowFields():void
   {
      $fields = $this->fields;
      // Render priority fields first
      $first = ['post_thumbnail', 'post_title', 'price'];
      foreach ($first as $f) {
         if (array_key_exists($f, $fields)) {
            $this->meta->render('form', $f, $fields[$f]);
            unset($fields[$f]);
         }
      }
      // Render remaining fields
      foreach ($fields as $name => $config) {
         if (!array_key_exists('hidden', $config) || !$config['hidden']) {
            $this->meta->render('form', $name, $config);
         }
      }
   }
   protected function getApplicableStatuses(string $prefix) {
      foreach ($this->statuses as $status => $config) {
         if ($status === 'all') {
@@ -674,7 +772,7 @@
            if ($status === 'future') {
               $status = 'publish';
               $config = [
                  'icon'   => 'publish',
                  'icon'   => 'eye',
                  'label'  => 'Live',
               ];
            } else {
@@ -711,6 +809,7 @@
      ob_start();
      ?>
      <form class="bulk-edit-form" data-save="content" data-form-id="bulk-edit-<?=$this->content?>">
         <?= jvbFormStatus() ?>
         <div class="selected"></div>
         <p class="description">You can unselect items by clicking the image here.</p>
         <p class="hint"><strong>IMPORTANT: </strong> Whatever changes you make here will be applied to all selected <?=$this->plural?>.</p>
@@ -774,6 +873,15 @@
      $this->renderGridView();
      $this->renderTableView();
      $this->renderTableRow();
      if ($this->isTimeline) {
         $temp = array_filter($this->fields, function ($field) {
            return in_array($field, $this->timelineUniqueFields);
         }, ARRAY_FILTER_USE_KEY);
         $form = new MetaForm();
         echo '<template class="timelineItem">';
         $form->renderImagePreview(null,['fields' => $temp]);
         echo '</template>';
      }
      echo jvbGetEmptyStateTemplate();
      echo jvbGetGalleryPreviewTemplate();
@@ -809,11 +917,11 @@
      ?>
      <div class="item-actions">
         <button type="button" class="action" data-action="edit" title="Edit <?= $this->singular ?>">
            <?=jvbIcon('edit')?>
            <?=jvbIcon('pencil-simple')?>
            <span class="screen-reader-text">Edit <?= $this->singular ?></span>
         </button>
         <button type="button" class="action" data-action="trash" title="Scrap <?= $this->singular ?>">
            <?=jvbIcon('delete')?>
            <?=jvbIcon('trash')?>
            <span class="screen-reader-text">Scrap <?= $this->singular ?></span>
         </button>
<!--        <button type="button" class="action" data-action="toggle-status">-->
@@ -878,13 +986,17 @@
   protected function renderTableView():void
   {
      if ($this->isTimeline) {
         $this->renderTimelineTableView();
         return;
      }
      ?>
      <template class="contentTable">
         <form class="table"
              data-save="content"
              data-content="<?= esc_attr($this->content) ?>"
              data-form-id="content-table-<?= esc_attr($this->content) ?>">
            <?= jvbFormStatus() ?>
            <?= $this->renderTableActions() ?>
            <table>
@@ -906,13 +1018,17 @@
    * Render table row template
    */
   protected function renderTableRow(): void {
      if ($this->isTimeline) {
         $this->renderTimelineTableGroup();
         return;
      }
      ?>
      <template class="tableView">
         <tr class="item">
            <td class="select">
               <?= $this->renderItemSelect() ?>
            </td>
            <td class="status">
            <td class="status" data-field="post_status">
               <?= $this->renderStatusRadios() ?>
            </td>
            <?php
@@ -938,6 +1054,102 @@
      </template>
      <?php
   }
   protected function renderTimelineTableView():void
   {
      ?>
      <template class="contentTable">
         <form class="table"
              data-save="content"
              data-content="<?= esc_attr($this->content) ?>"
              data-form-id="content-table-<?= esc_attr($this->content) ?>">
            <?= jvbFormStatus() ?>
            <?= $this->renderTableActions() ?>
            <table>
               <thead>
               <?= $this->renderTimelineTableHeader() ?>
               </thead>
               <!-- Rows are inserted as tbody groups -->
               <tfoot>
               <?= $this->renderTimelineTableHeader() ?>
               </tfoot>
            </table>
         </form>
      </template>
      <?php
   }
   protected function renderTimelineTableGroup():void
   {
      $makeDetails = [
         'group',
         'repeater',
         'checkbox',
         'radio'
      ];
      ?>
      <template class="tableView">
         <tbody class="item">
            <tr class="shared">
               <td class="select">
                  <?= $this->renderItemSelect() ?>
               </td>
               <td class="show-post_status field" data-field="post_status">
                  <?= $this->renderStatusRadios() ?>
               </td>
               <?php
               foreach ($this->fields as $name => $config) {
                  if(array_key_exists('hidden', $config) || $name === 'post_status') {
                     continue;
                  }
                  if (!in_array($name, $this->timelineSharedFields)) {
                     echo '<td></td>';
                     continue;
                  }
                     $makeThisDetailed = (in_array($config['type'], $makeDetails));
                  ?>
                  <td class="field show-<?= esc_attr($name) ?>" data-field="<?= esc_attr($name) ?>" data-field-type="<?=$config['type']?>"<?=(in_array($name, $this->stuck)) ? ' data-stuck':''?>>
                     <?= $makeThisDetailed ? '<details><summary class="row btw">See Value</summary>' : '' ?>
                     <?php $this->meta->render('form', $name, $config); ?>
                     <?= $makeThisDetailed ? '</details>' : '' ?>
                  </td>
                  <?php
               }
               ?>
            </tr>
            <tr class="timeline-point">
               <td class="select">
                  <button class="drag-handle" title="Drag to reorder" aria-label="Drag to reorder this timeline point"><?= jvbIcon('dots-six') ?></button>
               </td>
               <td class="show-post_status field" data-field="post_status">
                  <?= $this->renderStatusRadios() ?>
               </td>
               <?php
               foreach ($this->fields as $name => $config) {
                  if(array_key_exists('hidden', $config) || $name === 'post_status') {
                     continue;
                  }
                  if (!in_array($name, $this->timelineUniqueFields)) {
                     echo '<td></td>';
                     continue;
                  }
                  $makeThisDetailed = (in_array($config['type'], $makeDetails));
                  ?>
                  <td class="field show-<?= esc_attr($name) ?>" data-field="<?= esc_attr($name) ?>" data-field-type="<?=$config['type']?>"<?=(in_array($name, $this->stuck)) ? ' data-stuck':''?>>
                     <?= $makeThisDetailed ? '<details><summary class="row btw">See Value</summary>' : '' ?>
                     <?php $this->meta->render('form', $name, $config); ?>
                     <?= $makeThisDetailed ? '</details>' : '' ?>
                  </td>
                  <?php
               }
               ?>
            </tr>
         </tbody>
      </template>
      <?php
   }
   /**
    * Render status radio buttons
    */
@@ -952,7 +1164,7 @@
            if ($status === 'future') {
               $status = 'publish';
               $config = [
                  'icon' => 'publish',
                  'icon' => 'eye',
                  'label' => 'Live'
               ];
            } elseif ($status === 'past') {
@@ -999,6 +1211,32 @@
      return ob_get_clean();
   }
   protected function renderTimelineTableHeader(): string {
      ob_start();
      ?>
      <tr>
         <th scope="col" class="select-header">
            <input type="checkbox" id="select-all" name="select-all">
            <label for="select-all">All</label>
         </th>
         <th scope="col" class="show-post_status">
            Status
         </th>
         <?php foreach ($this->fields as $name => $config):
            if (array_key_exists('hidden', $config) || $name === 'post_status'){
               continue;
            }
            ?>
            <th scope="col" class="show-<?= esc_attr($name) ?>"<?= (in_array($name, $this->stuck)) ? ' data-stuck':''?>>
               <?= esc_html($config['label']) ?>
            </th>
         <?php endforeach; ?>
      </tr>
      <?php
      return ob_get_clean();
   }
   /**
    * Render table action controls
    */
@@ -1010,16 +1248,29 @@
            'vertical',
            'TAB NAV:',
            '',
            jvbIcon('down'),
            jvbIcon('right')
            jvbIcon('caret-double-down'),
            jvbIcon('caret-double-right')
         ) ?>
         <button type="button" class="add-row" title="Add new row">
            <?= jvbIcon('add') ?>
            <?= jvbIcon('plus-square') ?>
            <span>Add Row</span>
         </button>
      </div>
      <?php
      return ob_get_clean();
   }
   public function createItem(array $actions):array
   {
      ob_start();
      $this->renderCreateModal();
      $content = ob_get_clean();
      $create = [
         'button' => '<button type="button" class="create-item row" title="Create New '.$this->singular.'">'.jvbIcon('plus-square').'<span class="screen-reader-text">Create New '.$this->singular.'</span></button>',
         'content'   => $content,
      ];
      $actions[] = $create;
      return $actions;
   }
}