Reorganize the dashboard functionality such that everything can be configured in app/Models/Dashboard.php and a given model, add support for user-bound lists, improve security, fix some style issues, and update the readme

This commit is contained in:
Kevin MacMartin 2018-04-18 00:38:11 -04:00
parent 55db186a2c
commit a676c91370
18 changed files with 746 additions and 550 deletions

View file

@ -1,6 +1,6 @@
DEFAULT_LANGUAGE=en DEFAULT_LANGUAGE=en
APP_NAME='Hypothetical Template' APP_NAME='Hypothetical'
APP_DESC='A website template' APP_DESC='A website template'
APP_ENV=local APP_ENV=local
APP_KEY= APP_KEY=

View file

@ -6,9 +6,7 @@ use File;
use Image; use Image;
use PhpOffice\PhpSpreadsheet\Spreadsheet; use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx; use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use App\Models\Dashboard;
use App\Models\Contact;
use App\Models\Subscriptions;
class DashboardController extends Controller { class DashboardController extends Controller {
@ -37,52 +35,94 @@ class DashboardController extends Controller {
/** /**
* Dashboard View * Dashboard View
*/ */
public function getContact() public function getView($model)
{ {
return view('dashboard.view', [ $model_class = Dashboard::getModel($model, 'view');
'heading' => 'Contact Form Submissions',
'model' => 'contact',
'rows' => Contact::getContactSubmissions(),
'columns' => Contact::$dashboard_columns
]);
}
public function getSubscriptions() if ($model_class != null) {
{
return view('dashboard.view', [ return view('dashboard.view', [
'heading' => 'Subscriptions', 'heading' => $model_class::getDashboardHeading($model),
'model' => 'subscriptions', 'column_headings' => $model_class::getDashboardColumnData('headings'),
'rows' => Subscriptions::getSubscriptions(), 'model' => $model,
'columns' => Subscriptions::$dashboard_columns 'rows' => $model_class::getDashboardData(),
'columns' => $model_class::$dashboard_columns
]); ]);
} else {
abort(404);
}
} }
/** /**
* Dashboard Edit * Dashboard Edit List
*/ */
public function getEditList($model)
{
$model_class = Dashboard::getModel($model, 'edit');
if ($model_class != null) {
return view('dashboard.edit-list', [
'heading' => $model_class::getDashboardHeading($model),
'model' => $model,
'rows' => $model_class::getDashboardData(),
'display' => $model_class::$dashboard_display,
'button' => $model_class::$dashboard_button,
'sortcol' => $model_class::$dashboard_reorder ? $model_class::$dashboard_sort_column : false,
'create' => $model_class::$create,
'delete' => $model_class::$delete,
'filter' => $model_class::$filter,
'export' => $model_class::$export
]);
} else {
abort(404);
}
}
/**
* Dashboard Edit Item
*/
public function getEditItem($model, $id = 'new')
{
$model_class = Dashboard::getModel($model, 'edit');
if ($model_class != null) {
if ($id == 'new') {
$item = null;
} else {
if ($model_class::where('id', $id)->exists()) {
$item = $model_class::find($id);
if (is_null($item) || !$item->userCheck()) {
return view('errors.no-such-record');
}
} else {
return view('errors.no-such-record');
}
}
return view('dashboard.edit-item', [
'heading' => $model_class::getDashboardHeading($model),
'model' => $model,
'id' => $id,
'item' => $item,
'help_text' => $model_class::$dashboard_help_text,
'columns' => $model_class::$dashboard_columns
]);
} else {
abort(404);
}
}
/** /**
* Dashboard Export: Export data as a spreadsheet * Dashboard Export: Export data as a spreadsheet
*/ */
public function getExport($model) public function getExport($model)
{ {
// set the filename of the spreadsheet $model_class = Dashboard::getModel($model);
if ($model_class != null && $model_class::$export) {
$filename = preg_replace([ '/\ /', '/[^a-z0-9\-]/' ], [ '-', '' ], strtolower(env('APP_NAME'))) . '-' . $model . '-' . date('m-d-Y'); $filename = preg_replace([ '/\ /', '/[^a-z0-9\-]/' ], [ '-', '' ], strtolower(env('APP_NAME'))) . '-' . $model . '-' . date('m-d-Y');
$headings = $model_class::getDashboardColumnData('headings', false);
// set the model using the 'model' request argument $items = $model_class::select($model_class::getDashboardColumnData('names', false))->get()->toArray();
switch ($model) {
case 'contact':
$headings = [ 'Date', 'Name', 'Email', 'Message' ];
$items = Contact::select('created_at', 'name', 'email', 'message')->get()->toArray();
break;
case 'subscriptions':
$headings = [ 'Date', 'Email', 'Name' ];
$items = Subscriptions::select('created_at', 'email', 'name')->get()->toArray();
break;
default:
abort(404);
}
array_unshift($items, $headings); array_unshift($items, $headings);
$spreadsheet = new Spreadsheet(); $spreadsheet = new Spreadsheet();
$spreadsheet->getActiveSheet()->getDefaultColumnDimension()->setWidth(25); $spreadsheet->getActiveSheet()->getDefaultColumnDimension()->setWidth(25);
@ -92,6 +132,80 @@ class DashboardController extends Controller {
header('Content-Disposition: attachment;filename="' . $filename . '"'); header('Content-Disposition: attachment;filename="' . $filename . '"');
header('Cache-Control: max-age=0'); header('Cache-Control: max-age=0');
$writer->save('php://output'); $writer->save('php://output');
} else {
abort(404);
}
}
/**
* Dashboard Reorder: Reorder rows
*/
public function postReorder(Request $request)
{
$this->validate($request, [
'order' => 'required',
'column' => 'required',
'model' => 'required'
]);
$model_class = Dashboard::getModel($request['model'], 'edit');
if ($model_class != null) {
$order = $request['order'];
$column = $request['column'];
// update each row with the new order
foreach (array_keys($order) as $order_id) {
$item = $model_class::find($order_id);
$item->$column = $order[$order_id];
$item->save();
}
return 'success';
} else {
return 'model-access-fail';
}
}
/**
* Dashboard Update: Create and update rows
*/
public function postUpdate(Request $request)
{
$this->validate($request, [
'id' => 'required',
'model' => 'required',
'columns' => 'required'
]);
$model_class = Dashboard::getModel($request['model'], 'edit');
if ($model_class != null) {
if ($request['id'] == 'new') {
$item = new $model_class;
} else {
$item = $model_class::find($request['id']);
if (is_null($item)) {
return 'record-access-fail';
} else if (!$item->userCheck()) {
return 'permission-fail';
}
}
// populate the eloquent object with the remaining items in $request
foreach ($request['columns'] as $column) {
$item->$column = $request[$column];
}
// save the new or updated item
$item->save();
// return the id number in the format '^id:[0-9][0-9]*$' on success
return 'id:' . $item->id;
} else {
return 'model-access-fail';
}
} }
/** /**
@ -105,7 +219,16 @@ class DashboardController extends Controller {
'name' => 'required' 'name' => 'required'
]); ]);
if ($request->hasFile('file')) { $model_class = Dashboard::getModel($request['model'], 'edit');
if ($model_class != null) {
$item = $model_class::find($request['id']);
if (is_null($item)) {
return 'record-access-fail';
} else if (!$item->userCheck()) {
return 'permission-fail';
} else if ($request->hasFile('file')) {
$directory = base_path() . '/public/uploads/' . $request['model'] . '/img/'; $directory = base_path() . '/public/uploads/' . $request['model'] . '/img/';
file::makeDirectory($directory, 0755, true, true); file::makeDirectory($directory, 0755, true, true);
$image = Image::make($request->file('file')); $image = Image::make($request->file('file'));
@ -115,6 +238,9 @@ class DashboardController extends Controller {
} }
return 'success'; return 'success';
} else {
return 'model-access-fail';
}
} }
/** /**
@ -129,7 +255,16 @@ class DashboardController extends Controller {
'ext' => 'required' 'ext' => 'required'
]); ]);
if ($request->hasFile('file')) { $model_class = Dashboard::getModel($request['model'], 'edit');
if ($model_class != null) {
$item = $model_class::find($request['id']);
if (is_null($item)) {
return 'record-access-fail';
} else if (!$item->userCheck()) {
return 'permission-fail';
} else if ($request->hasFile('file')) {
$directory = base_path() . '/public/uploads/' . $request['model'] . '/files/'; $directory = base_path() . '/public/uploads/' . $request['model'] . '/files/';
file::makeDirectory($directory, 0755, true, true); file::makeDirectory($directory, 0755, true, true);
$request->file('file')->move($directory, $request['id'] . '-' . $request['name'] . '.' . $request['ext']); $request->file('file')->move($directory, $request['id'] . '-' . $request['name'] . '.' . $request['ext']);
@ -138,68 +273,9 @@ class DashboardController extends Controller {
} }
return 'success'; return 'success';
} } else {
/**
* Dashboard Edit: Create and edit rows
*/
public function postEdit(Request $request)
{
$this->validate($request, [
'id' => 'required',
'model' => 'required',
'columns' => 'required'
]);
// store the id request variable for easy access
$id = $request['id'];
// set the model using the 'model' request argument
switch ($request['model']) {
default:
return 'model-access-fail'; return 'model-access-fail';
} }
// populate the eloquent object with the remaining items in $request
foreach ($request['columns'] as $column) {
$item->$column = $request[$column];
}
// save the new or updated item
$item->save();
// return the id number in the format '^id:[0-9][0-9]*$' on success
return 'id:' . $item->id;
}
/**
* Dashboard Reorder: Reorder rows
*/
public function postReorder(Request $request)
{
$this->validate($request, [
'order' => 'required',
'column' => 'required',
'model' => 'required'
]);
$order = $request['order'];
$column = $request['column'];
// set the model using the 'model' request argument
switch ($request['model']) {
default:
return 'model-access-fail';
}
// update each row with the new order
foreach (array_keys($order) as $order_id) {
$item = $items::find($order_id);
$item->$column = $order[$order_id];
$item->save();
}
return 'success';
} }
/** /**
@ -212,21 +288,22 @@ class DashboardController extends Controller {
'model' => 'required' 'model' => 'required'
]); ]);
// set the model using the 'model' request argument $model_class = Dashboard::getModel($request['model'], 'edit');
switch ($request['model']) {
default: if ($model_class != null) {
return 'model-access-fail'; $item = $model_class::find($request['id']);
if (is_null($item)) {
return 'record-access-fail';
} else if (!$item->userCheck()) {
return 'permission-fail';
} }
// delete the row with the id using the 'id' request argument // delete the row
if ($items::where('id', $request['id'])->exists()) { $item->delete();
$items::where('id', $request['id'])->delete();
} else {
return 'row-delete-fail';
}
// delete associated files if they exist // delete associated files if they exist
foreach ($items::$dashboard_columns as $column) { foreach ($model_class::$dashboard_columns as $column) {
if ($column['type'] == 'image') { if ($column['type'] == 'image') {
$image = base_path() . '/public/uploads/' . $request['model'] . '/img/' . $request['id'] . '-' . $column['name'] . '.jpg'; $image = base_path() . '/public/uploads/' . $request['model'] . '/img/' . $request['id'] . '-' . $column['name'] . '.jpg';
@ -244,6 +321,9 @@ class DashboardController extends Controller {
// Return a success // Return a success
return 'success'; return 'success';
} else {
return 'model-access-fail';
}
} }
/** /**
@ -257,15 +337,26 @@ class DashboardController extends Controller {
'name' => 'required' 'name' => 'required'
]); ]);
$image = base_path() . '/public/uploads/' . $request['model'] . '/img/' . $request['id'] . '-' . $request['name'] . '.jpg'; $model_class = Dashboard::getModel($request['model'], 'edit');
if (!file_exists($image)) { if ($model_class != null) {
$image = base_path() . '/public/uploads/' . $request['model'] . '/img/' . $request['id'] . '-' . $request['name'] . '.jpg';
$item = $model_class::find($request['id']);
if (is_null($item)) {
return 'record-access-fail';
} else if (!$item->userCheck()) {
return 'permission-fail';
} else if (!file_exists($image)) {
return 'image-not-exists-fail'; return 'image-not-exists-fail';
} else if (!unlink($image)) { } else if (!unlink($image)) {
return 'image-delete-fail'; return 'image-delete-fail';
} }
return 'success'; return 'success';
} else {
return 'model-access-fail';
}
} }
/** /**
@ -280,15 +371,26 @@ class DashboardController extends Controller {
'ext' => 'required' 'ext' => 'required'
]); ]);
$file = base_path() . '/public/uploads/' . $request['model'] . '/files/' . $request['id'] . '-' . $request['name'] . '.' . $request['ext']; $model_class = Dashboard::getModel($request['model'], 'edit');
if (!file_exists($file)) { if ($model_class != null) {
$file = base_path() . '/public/uploads/' . $request['model'] . '/files/' . $request['id'] . '-' . $request['name'] . '.' . $request['ext'];
$item = $model_class::find($request['id']);
if (is_null($item)) {
return 'record-access-fail';
} else if (!$item->userCheck()) {
return 'permission-fail';
} else if (!file_exists($file)) {
return 'file-not-exists-fail'; return 'file-not-exists-fail';
} else if (!unlink($file)) { } else if (!unlink($file)) {
return 'file-delete-fail'; return 'file-delete-fail';
} }
return 'success'; return 'success';
} else {
return 'model-access-fail';
}
} }
} }

25
app/Models/Blog.php Normal file
View file

@ -0,0 +1,25 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Blog extends DashboardModel
{
protected $table = 'blog';
public static $dashboard_type = 'edit';
public static $dashboard_help_text = '<strong>NOTE</strong>: Tags should be separated by semicolons';
public static $dashboard_display = [ 'title', 'created_at' ];
public static $dashboard_columns = [
[ 'name' => 'user_id', 'type' => 'user' ],
[ 'name' => 'created_at', 'title' => 'Date', 'type' => 'display' ],
[ 'name' => 'title', 'type' => 'text' ],
[ 'name' => 'body', 'type' => 'mkd' ],
[ 'name' => 'tags', 'type' => 'text' ],
[ 'name' => 'header-image', 'title' => 'Header Image', 'type' => 'image', 'delete' => true ]
];
}

View file

@ -4,34 +4,18 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
class Contact extends Model class Contact extends DashboardModel
{ {
/**
* The contact table
*
* @var string
*/
protected $table = 'contact'; protected $table = 'contact';
/** public static $dashboard_heading = 'Contact Form Submissions';
* Dashboard columns
*
* @var array
*/
public static $dashboard_columns = [
[ 'Date', 'created_at' ],
[ 'Name', 'name' ],
[ 'Email', 'email' ],
[ 'Message', 'message' ]
];
/** public static $export = true;
* Returns the list of all contact submissions
* public static $dashboard_columns = [
* @return array [ 'name' => 'created_at', 'title' => 'Date' ],
*/ [ 'name' => 'name' ],
public static function getContactSubmissions() [ 'name' => 'email' ],
{ [ 'name' => 'message' ]
return self::orderBy('created_at', 'desc')->get(); ];
}
} }

58
app/Models/Dashboard.php Normal file
View file

@ -0,0 +1,58 @@
<?php
namespace App\Models;
class Dashboard
{
/**
* Dashboard Menu
*
* @return array
*/
public static $menu = [
[
'title' => 'Blog',
'type' => 'edit',
'model' => 'blog'
],
[
'title' => 'Form Submissions',
'submenu' => [
[
'title' => 'Contact',
'type' => 'view',
'model' => 'contact'
],
[
'title' => 'Subscriptions',
'type' => 'view',
'model' => 'subscriptions'
]
]
]
];
/**
* Retrieve a Dashboard Model
*
* @return model
*/
public static function getModel($model, $type = null)
{
$model_name = ucfirst($model);
if (file_exists(app_path() . '/Models/' . $model_name . '.php')) {
$model_class = 'App\\Models\\' . ucfirst($model);
if ($type != null && $type != $model_class::$dashboard_type) {
return null;
}
return $model_class;
} else {
return null;
}
}
}

View file

@ -1,30 +0,0 @@
<?php
namespace App\Models;
class DashboardMenu
{
/**
* Dashboard Menu
*
* @return array
*/
public static $menu = [
[
'title' => 'Submissions',
'submenu' => [
[
'title' => 'Contact',
'type' => 'view',
'model' => 'contact'
],
[
'title' => 'Subscriptions',
'type' => 'view',
'model' => 'subscriptions'
]
]
]
];
}

View file

@ -0,0 +1,171 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Auth;
class DashboardModel extends Model
{
/*
* The dashboard page type
*
* @var array
*/
public static $dashboard_type = 'view';
/*
* Dashboard heading
*
* @var string
*/
public static $dashboard_heading = null;
/*
* Whether the model can be exported
*
* @var boolean
*/
public static $export = false;
/*
* Whether new rows can be created
*
* @var boolean
*/
public static $create = true;
/*
* Whether new rows can be deleted
*
* @var boolean
*/
public static $delete = true;
/*
* Whether rows can be filtered
*
* @var boolean
*/
public static $filter = true;
/*
* Dashboard help text
*
* @var string
*/
public static $dashboard_help_text = '';
/*
* Array of columns to display in the dashboard edit list
*
* @var array
*/
public static $dashboard_display = [];
/**
* Whether to allow click-and-drag reordering
*
* @var boolean
*/
public static $dashboard_reorder = false;
/**
* The dashboard sort column
*
* @var array
*/
public static $dashboard_sort_column = 'created_at';
/**
* The dashboard sort direction (only when $dashboard_reorder == false)
*
* @var array
*/
public static $dashboard_sort_direction = 'desc';
/**
* The dashboard buttons
*
* @var array
*/
public static $dashboard_button = [];
/**
* Returns the dashboard heading
*
* @return string
*/
public static function getDashboardHeading($model)
{
return static::$dashboard_heading == null ? ucfirst($model) : static::$dashboard_heading;
}
/**
* Returns an array of column 'headings' or 'names'
*
* @return array
*/
public static function getDashboardColumnData($type, $all_columns = true)
{
$column_data = [];
foreach (static::$dashboard_columns as $column) {
if ($all_columns || !array_key_exists('type', $column) || !preg_match('/^(hidden|user|image|file)$/', $column['type'])) {
if ($type == 'headings') {
if (array_key_exists('title', $column)) {
array_push($column_data, $column['title']);
} else {
array_push($column_data, ucfirst($column['name']));
}
} else if ($type == 'names') {
array_push($column_data, $column['name']);
}
}
}
return $column_data;
}
/**
* Returns data for the dashboard
*
* @return array
*/
public static function getDashboardData()
{
$sort_direction = static::$dashboard_reorder ? 'desc' : static::$dashboard_sort_direction;
$query = self::orderBy(static::$dashboard_sort_column, 'desc');
foreach (static::$dashboard_columns as $column) {
if (array_key_exists('type', $column) && $column['type'] == 'user') {
$query->where($column['name'], Auth::id());
break;
}
}
return $query->get();
}
/**
* Determines whether a user column exists and whether it matches the current user if it does
*
* @return boolean
*/
public function userCheck() {
$user_check = true;
foreach (static::$dashboard_columns as $column) {
if (array_key_exists('type', $column) && $column['type'] == 'user') {
if ($this->{$column['name']} != Auth::id()) {
$user_check = false;
}
break;
}
}
return $user_check;
}
}

View file

@ -4,33 +4,15 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
class Subscriptions extends Model class Subscriptions extends DashboardModel
{ {
/**
* The subscriptions table
*
* @var string
*/
protected $table = 'subscriptions'; protected $table = 'subscriptions';
/** public static $export = true;
* Dashboard columns
*
* @var array
*/
public static $dashboard_columns = [
[ 'Date', 'created_at' ],
[ 'Email', 'email' ],
[ 'Name', 'name' ]
];
/** public static $dashboard_columns = [
* Returns the list of all subscriptions [ 'title' => 'Date', 'name' => 'created_at' ],
* [ 'name' => 'email' ],
* @return array [ 'name' => 'name' ]
*/ ];
public static function getSubscriptions()
{
return self::orderBy('created_at', 'desc')->get();
}
} }

View file

@ -0,0 +1,36 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class AddBlogTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('blog', function(Blueprint $table) {
$table->increments('id');
$table->integer('user_id')->unsigned();
$table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
$table->string('title')->nullable();
$table->text('body')->nullable();
$table->text('tags')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::drop('blog');
}
}

341
readme.md
View file

@ -54,283 +54,96 @@ Other information about database interaction, routing, controllers, etc can be v
## Dashboard ## Dashboard
Unless otherwise stated all examples in this section are to be added to `app/Http/Controllers/DashboardController.php`. ### Updating the dashboard menu
### Adding a Viewable Model to the Dashboard The dashboard menu can be edited by changing the `$menu` array in `app/Models/Dashboard.php`.
#### Viewable List of Rows The each item in the array is itself an array, containing either a menu item or a dropdown of menu items.
First add a function to generate the page: Dropdowns should contain the following keys:
```php * `title`: The text that appears on the dropdown item
public function getContact() * `submenu`: This is an array of menu items.
{
return view('dashboard.view', [
'heading' => 'Contact Form Submissions',
'model' => 'contact',
'rows' => Contact::getContactSubmissions(),
'columns' => Contact::$dashboard_columns
]);
}
```
* `heading`: The title that will appear for this page Menu items should contain the following keys:
* `model`: The model that will be accessed on this page
* `rows`: A function returning an array containing the data to be shown on this page * `title`: The text that appears on the menu item
* `columns`: Expects a variable called `$dashboard_columns` in the respective model that contains an array: * `type`: The dashboard type (this can be `view` for a viewable table or `edit` for an editable list)
* `model`: The lowercase name of the database model
### Adding a new model to the dashboard
Create a model that extends the `DashboardModel` class and override variables that don't fit the defaults.
#### DashboardModel variables
* `$dashboard_type`: The dashboard type (this can be `view` for a viewable table or `edit` for an editable list)
* `$dashboard_heading`: This sets the heading that appears on the dashboard page; not setting this will use the model name
* `$export`: This enables a button that allows the table to be exported as a spreadsheet
##### Edit variables
These are variables that only function when the `$dashboard_type` variable is set to `edit`.
* `$create`: A boolean determining whether to enable a button that allows new records to be created
* `$delete`: A boolean determining whether to enable a button that allows records to be deleted
* `$filter`: A boolean determining whether to enable an input field that allows records to be searched
* `$dashboard_help_text`: An html string that will add a help box to the top of the edit-item page
* `$dashboard_display`: An array to configure what column data to show on each item in the edit-list
* `$dashboard_reorder`: A boolean determining whether to render drag handles to reorder the items in the list
* `$dashboard_sort_column`: A string containing the column used to sort the list (this should be an `integer` when `$dashboard_reorder` is true)
* `$dashboard_sort_direction`: When `$dashboard_reorder` is false this determines the sort direction (this can be `desc` for descending or `asc` ascending)
* `$dashboard_button`: An array containing the following items in this order:
* The title
* Confirmation text asking the user to confirm
* A "success" message to display when the response is `success`
* A "failure" message to display when the response is not `success`
* The URL to send the POST request to with the respective `id` in the request variable
##### Configuring the columns
All `DashboardModel` models require a `$dashboard_columns` array that declares which columns to show and how to treat them.
All models use the following attributes:
* `name`: The name of the model
* `title`: (optional) The title that should be associated with the model; when unset this becomes the model name with its first letter capitalized
Models with their `$dashboard_type` set to `edit` also use:
* `type`: The column type which can be any of the following:
* `text`: Text input field for text data
* `mkd`: Markdown editor for text data containing markdown
* `date`: Date and time selection tool for date/time data
* `select`: Text input via option select with possible options in an `options` array
* `hidden`: Fields that will contain values to pass to the update function but won't appear on the page (this must be used for the sort column)
* `image`: Fields that contain image uploads
* `file`: Fields that contains file uploads
* `display`: Displayed information that can't be edited
* `user`: This should point to a foreign key that references the id on the users table; setting this will bind items to the user that created them
* `name`: (required by `file` and `image`) Used along with the record id to determine the filename
* `delete`: (optional for `file` and `image`) Enables a delete button for the upload when set to true
* `ext`: (required by `file`) Configures the file extension of the upload
An example of the `$dashboard_columns` array in a model with its `$dashboard_type` set to `view`:
```php ```php
public static $dashboard_columns = [ public static $dashboard_columns = [
[ 'Date', 'created_at' ], [ 'title' => 'Date', 'name' => 'created_at' ],
[ 'Name', 'name' ], [ 'name' => 'email' ],
[ 'Email', 'email' ], [ 'name' => 'name' ]
[ 'Message', 'message' ]
]; ];
``` ```
### Adding an Editable Model to the Dashboard An example of the `$dashboard_columns` array in a model with its `$dashboard_type` set to `edit`:
#### Editable List of Rows
##### Editable List for Unsortable Model
```php
public function getShows()
{
return view('dashboard.edit-list', [
'heading' => 'Shows',
'model' => 'shows',
'path' => 'shows-page',
'rows' => Shows::getShowsList(),
'column' => 'title',
'button' => [ 'Email Show', 'Are you sure you want to send an email?', 'Email successfully sent', 'Failed to send email', '/email-show' ],
'sortcol' => false,
'delete' => true,
'create' => true,
'export' => true,
'filter' => true
]);
}
```
##### Editable List for Sortable Model
**NOTE**: Sortable models must have an entry configured in the `postReorder` function (details below)
```php
public function getNews()
{
return view('dashboard.edit-list', [
'heading' => 'News',
'model' => 'news',
'rows' => News::getNewsList(),
'column' => 'title',
'button' => [ 'Email Show', 'Are you sure you want to send an email?', 'Email successfully sent', 'Failed to send email', '/email-show' ],
'sortcol' => 'order',
'delete' => false,
'create' => true,
'export' => true,
'filter' => true
]);
}
```
* `heading`: The title that will appear for this page
* `model`: The model that will be accessed on this page
* `path`: (optional) This can be used to set a different URL path than the default of the model name
* `rows`: A function returning an array containing the data to be shown on this page
* `column`: The column name in the array that contains the data to display in each row (an array can be used to specify multiple columns)
* `button`: Add a button with a title, confirmation, success and error messages, and a post request path that takes an id and returns `success` on success
* `sortcol`: The name of the column containing the sort order or `false` to disable
* `delete`: A `delete` button will appear in the list if this is set to `true`
* `create`: A `new` button will appear in the heading if this is set to `true`
* `export`: An `export` button will appear in the heading if this is set to `true`
* `filter`: An input box will appear below the heading that can filter rows by input if this is set to `true`
#### Editable Item
This function should be named the same as the one above except with `Edit` at the end
##### Editable Item for Unsortable Model
```php
public function getShowsEdit($id = 'new')
{
if ($id != 'new') {
if (Shows::where('id', $id)->exists()) {
$item = Shows::where('id', $id)->first();
} else {
return view('errors.no-such-record');
}
} else {
$item = null;
}
return view('dashboard.edit-item', [
'heading' => 'Shows',
'model' => 'shows',
'id' => $id,
'item' => $item,
'help_text' => '<strong>NOTE:</strong> This is some help text for the current page',
'columns' => $dashboard_columns
]);
}
```
##### Editable Item for Sortable Model
```php
public function getNewsEdit($id = 'new')
{
if ($id != 'new') {
if (News::where('id', $id)->exists()) {
$item = News::where('id', $id)->first();
} else {
return view('errors.no-such-record');
}
} else {
$item = new News();
$item['order'] = News::count();
}
return view('dashboard.edit-item', [
'heading' => 'News',
'model' => 'news',
'id' => $id,
'item' => $item,
'columns' => News::$dashboard_columns
]);
}
```
* `heading`: The title that will appear for this page
* `model`: The model that will be accessed on this page
* `id`: Always set this to `$id`
* `item`: Always set this to `$item`
* `help_text`: An optional value that will add a box containing help text above the form if set
* `columns`: Expects a variable called `$dashboard_columns` in the respective model that contains an array:
* `name` is the name of the column to be edited
* `type` is the type of column (details below)
* `label` is an optional value that overrides the visible column name
```php ```php
public static $dashboard_columns = [ public static $dashboard_columns = [
[ 'name' => 'title', 'type' => 'text', 'label' => 'The Title' ], [ 'name' => 'user_id', 'type' => 'user' ],
[ 'name' => 'iframe', 'type' => 'text' ], [ 'name' => 'created_at', 'title' => 'Date', 'type' => 'display' ],
[ 'name' => 'halign', 'type' => 'select', 'options' => [ 'left', 'center', 'right' ] ], [ 'name' => 'title', 'type' => 'text' ],
[ 'name' => 'story', 'type' => 'mkd' ], [ 'name' => 'body', 'type' => 'mkd' ],
[ 'label' => 'Header Image', 'name' => 'headerimage', 'type' => 'image' ], [ 'name' => 'tags', 'type' => 'text' ],
[ 'name' => 'order', 'type' => 'hidden' ], [ 'name' => 'header-image', 'title' => 'Header Image', 'type' => 'image', 'delete' => true ]
[ 'label' => 'PDF File', 'name' => 'pdf', 'type' => 'file', 'ext' => 'pdf' ]
]; ];
``` ```
###### Editable Column Types
The following is a list of possible `types` in the `columns` array for Editable Items:
* `text`: Text input field for text data
* `mkd`: Markdown editor for text data containing markdown
* `date`: Date and time selection tool for date/time data
* `select`: Text input via option select with possible options in an `options` array
* `hidden`: Fields that will contain values to pass to the update function but won't appear on the page (this must be used for the sort column)
* `image`: Fields that contain image uploads
* `name`: not part of the database and is instead used in the filename
* `delete`: (optional) if true then uploaded images can be deleted
* `file`: Fields that contains file uploads
* `name`: not part of the database and is instead used in the filename
* `ext` required key containing the file extension
* `delete`: (optional) if true then uploaded files can be deleted
* `display`: Displayed information that can't be edited
#### Edit Item Functionality
Editable models must have an entry in the switch statement of the `postEdit` function to make create and edit functionality work:
```php
switch ($request['model']) {
case 'shows':
$item = $id == 'new' ? new Shows : Shows::find($id);
break;
case 'news':
$item = $id == 'new' ? new News : News::find($id);
break;
default:
return 'model-access-fail';
}
```
#### Additional Requirement for Sortable Models
Sortable models must have an entry in the switch statement of the `postReorder` function to make sorting functionality work:
```php
switch ($request['model']) {
case 'news':
$items = new News();
break;
default:
return 'model-access-fail';
}
```
#### Additional Requirements for Image Upload
If the value of `imgup` has been set to `true`, ensure `public/uploads/model_name` exists (where `model_name` is the name of the given model) and contains a `.gitkeep` that exists in version control.
By default, uploaded images are saved in JPEG format with the value of the `id` column of the respective row as its name and `.jpg` as its file extension.
When a row is deleted, its respective image will be deleted as well if it exists.
### Adding to the Dashboard Menu
Edit the `$menu` array in `app/Models/DashboardMenu.php` where the first column of each item is the title and the second is either a path, or an array of submenu items.
```php
public static $menu = [
[ 'Contact', 'contact' ],
[ 'Subscriptions', 'subscriptions' ],
[
'Projects', [
[ 'Residential', 'projects-residential' ],
[ 'Commercial', 'projects-commercial' ]
]
]
];
```
#### Additional Requirement for Delete Functionality
Editable models with `delete` set to `true` must have an entry in the switch statement of the `deleteDelete` function to make deletion functionality work:
```php
switch ($request['model']) {
case 'shows':
$items = new Shows();
break;
case 'news':
$items = new News();
break;
default:
return 'model-access-fail';
}
```
#### Additional Requirement for Export Functionality
Viewable models and editable models with `export` set to `true` must have an entry in the switch statement of the `getExport` function to make the export button work:
```php
switch ($model) {
case 'contact':
$headings = [ 'Date', 'Name', 'Email', 'Message' ];
$items = Contact::select('created_at', 'name', 'email', 'message')->get()->toArray();
break;
default:
abort(404);
}
```
* `$headings`: The visible column names in the same order as the array containing the items to be exported
* `$items`: A function returning an array containing the data to be exported

View file

@ -113,15 +113,14 @@ function editListInit() {
const editList = document.getElementById("edit-list"), const editList = document.getElementById("edit-list"),
$editList = $(editList), $editList = $(editList),
$token = $("#token"), $token = $("#token"),
model = $editList.data("model"), model = $editList.data("model");
path = $editList.data("path");
// initialize new button functionality // initialize new button functionality
const newButtonInit = function() { const newButtonInit = function() {
const $newButton = $(".btn.new-button"); const $newButton = $(".btn.new-button");
$newButton.on("click", function() { $newButton.on("click", function() {
window.location.href = "/dashboard/" + path + "-edit/new"; window.location.href = "/dashboard/edit-item/" + model + "/new";
}); });
}; };
@ -135,7 +134,7 @@ function editListInit() {
itemId = $listItem.data("id"); itemId = $listItem.data("id");
// go to the edit page // go to the edit page
window.location.href = "/dashboard/" + path + "-edit/" + itemId; window.location.href = "/dashboard/edit-item/" + model + "/" + itemId;
}); });
}; };
@ -281,7 +280,6 @@ function editItemInit() {
$spinner = $("#loading-modal"), $spinner = $("#loading-modal"),
fadeTime = 250, fadeTime = 250,
model = $editItem.data("model"), model = $editItem.data("model"),
path = $editItem.data("path"),
id = $editItem.data("id"), id = $editItem.data("id"),
operation = id === "new" ? "create" : "update"; operation = id === "new" ? "create" : "update";
@ -363,7 +361,7 @@ function editItemInit() {
// functionality to run on success // functionality to run on success
const returnSuccess = function() { const returnSuccess = function() {
hideLoadingModal(); hideLoadingModal();
window.location.href = "/dashboard/" + path; window.location.href = "/dashboard/edit/" + model;
}; };
// add the file from the file upload box for file-upload class elements // add the file from the file upload box for file-upload class elements
@ -582,10 +580,10 @@ function editItemInit() {
if (!submitting) { if (!submitting) {
if (changes) { if (changes) {
askConfirmation("Cancel changes and return to the list?", function() { askConfirmation("Cancel changes and return to the list?", function() {
window.location.href = "/dashboard/" + path; window.location.href = "/dashboard/edit/" + model;
}); });
} else { } else {
window.location.href = "/dashboard/" + path; window.location.href = "/dashboard/edit/" + model;
} }
} }
}); });
@ -604,7 +602,7 @@ function editItemInit() {
// submit the update // submit the update
$.ajax({ $.ajax({
type: "POST", type: "POST",
url: "/dashboard/edit", url: "/dashboard/update",
data: formData data: formData
}).always(function(response) { }).always(function(response) {
if (/^id:[0-9][0-9]*$/.test(response)) { if (/^id:[0-9][0-9]*$/.test(response)) {

View file

@ -37,6 +37,7 @@ body {
margin-bottom: $grid-gutter-width; margin-bottom: $grid-gutter-width;
border: 0; border: 0;
background-color: $c-dashboard-dark; background-color: $c-dashboard-dark;
user-select: none;
@include media-breakpoint-down(md) { @include media-breakpoint-down(md) {
padding-right: 8px; padding-right: 8px;
@ -51,8 +52,13 @@ body {
@include font-sans-bold; @include font-sans-bold;
overflow: hidden; overflow: hidden;
max-width: calc(100vw - 60px); max-width: calc(100vw - 60px);
color: $c-text-light;
white-space: nowrap; white-space: nowrap;
&:hover, &:focus, &:active {
color: $c-text-light;
}
@include media-breakpoint-down(sm) { @include media-breakpoint-down(sm) {
font-size: 12px; font-size: 12px;
} }
@ -67,6 +73,45 @@ body {
@include media-breakpoint-up(md) { @include media-breakpoint-up(md) {
top: 10px; top: 10px;
} }
&-icon {
position: relative;
&-bar {
$v-inset: 6px;
$h-inset: 3px;
position: absolute;
left: $h-inset;
width: calc(100% - #{$h-inset * 2});
height: 2px;
background-color: $c-text-light;
&:nth-child(1) {
top: $v-inset;
}
&:nth-child(2) {
top: 50%;
transform: translateY(-50%);
}
&:nth-child(3) {
bottom: $v-inset;
}
}
}
}
.nav-link {
color: fade-out($c-text-light, 0.25);
&.active {
color: $c-text-light;
}
&.dropdown-toggle {
cursor: pointer;
}
} }
.dropdown-menu { .dropdown-menu {
@ -84,10 +129,18 @@ body {
} }
.dropdown-item { .dropdown-item {
background-color: transparent;
transition: background-color 150ms;
@include media-breakpoint-down(md) { @include media-breakpoint-down(md) {
padding-right: 8px; padding-right: 8px;
padding-left: 8px; padding-left: 8px;
} }
&:hover, &:focus, &:active {
background-color: fade-out(#000, 0.95);
color: $c-text;
}
} }
} }
} }

View file

@ -1,7 +1,7 @@
@extends('dashboard.core') @extends('dashboard.core')
@section('dashboard-body') @section('dashboard-body')
@if(!empty($help_text)) @if($help_text != '')
<div class="container-fluid"> <div class="container-fluid">
<div class="row"> <div class="row">
<div class="col-12"> <div class="col-12">
@ -13,7 +13,7 @@
</div> </div>
@endif @endif
<form id="edit-item" class="edit-item" data-id="{{ $id }}" data-model="{{ $model }}" data-path="{{ isset($path) ? $path : $model }}"> <form id="edit-item" class="edit-item" data-id="{{ $id }}" data-model="{{ $model }}">
<input type="hidden" id="token" value="{{ csrf_token() }}" /> <input type="hidden" id="token" value="{{ csrf_token() }}" />
<div class="container-fluid"> <div class="container-fluid">
@ -23,9 +23,11 @@
@if($column['type'] == 'hidden') @if($column['type'] == 'hidden')
<input class="text-input" type="hidden" name="{{ $column['name'] }}" id="{{ $column['name'] }}" value="{{ $value }}" /> <input class="text-input" type="hidden" name="{{ $column['name'] }}" id="{{ $column['name'] }}" value="{{ $value }}" />
@elseif($column['type'] == 'user')
<input class="text-input" type="hidden" name="{{ $column['name'] }}" id="{{ $column['name'] }}" value="{{ Auth::id() }}" />
@elseif($column['type'] != 'display' || $id != 'new') @elseif($column['type'] != 'display' || $id != 'new')
<div class="col-12 col-md-2"> <div class="col-12 col-md-2">
<label for="{{ $column['name'] }}">{{ empty($column['label']) ? ucfirst($column['name']) : $column['label'] }}:</label> <label for="{{ $column['name'] }}">{{ array_key_exists('title', $column) ? $column['title'] : ucfirst($column['name']) }}:</label>
</div> </div>
<div class="col-12 col-md-10"> <div class="col-12 col-md-10">
@ -46,13 +48,12 @@
@endforeach @endforeach
</select> </select>
@elseif($column['type'] == 'image') @elseif($column['type'] == 'image')
<input class="image-upload" type="file" name="{{ $column['name'] }}" id="{{ $column['name'] }}" />
@set('current_image', "/uploads/$model/img/$id-" . $column['name'] . '.jpg') @set('current_image', "/uploads/$model/img/$id-" . $column['name'] . '.jpg')
<input class="image-upload" type="file" name="{{ $column['name'] }}" id="{{ $column['name'] }}" />
@if(file_exists(base_path() . '/public' . $current_image)) @if(file_exists(base_path() . '/public' . $current_image))
<div id="current-image-{{ $column['name'] }}"> <div id="current-image-{{ $column['name'] }}">
<img class="current-image" src="{{ $current_image }}" /> <img class="current-image" src="{{ $current_image }}?version={{ env('CACHE_BUST') }}" />
@if(array_key_exists('delete', $column) && $column['delete']) @if(array_key_exists('delete', $column) && $column['delete'])
<span class="edit-button delete image" data-name="{{ $column['name'] }}"> <span class="edit-button delete image" data-name="{{ $column['name'] }}">

View file

@ -18,7 +18,7 @@
<input id="filter-input" class="search" placeholder="Filter" /> <input id="filter-input" class="search" placeholder="Filter" />
@endif @endif
<ul id="edit-list" class="list-group edit-list list" data-model="{{ $model }}" data-path="{{ isset($path) ? $path : $model }}" {{ $sortcol != false ? "data-sort=$sortcol" : '' }}> <ul id="edit-list" class="list-group edit-list list" data-model="{{ $model }}" {{ $sortcol != false ? "data-sort=$sortcol" : '' }}>
@foreach($rows as $row) @foreach($rows as $row)
<li class="list-group-item {{ $sortcol != false ? 'sortable' : '' }}" data-id="{{ $row['id'] }}"> <li class="list-group-item {{ $sortcol != false ? 'sortable' : '' }}" data-id="{{ $row['id'] }}">
<div class="title-column"> <div class="title-column">
@ -32,23 +32,19 @@
</div> </div>
@endif @endif
@if(is_array($column)) @foreach($display as $display_column)
@foreach($column as $col) @if($row[$display_column] != '')
@if($row[$col] != '') <div class="column">{{ $row[$display_column] }}</div>
<div class="column">{{ $row[$col] }}</div>
@if(!$loop->last) @if(!$loop->last)
<div class="spacer">|</div> <div class="spacer">|</div>
@endif @endif
@endif @endif
@endforeach @endforeach
@else
{{ $row[$column] }}
@endif
</div> </div>
<div class="button-column"> <div class="button-column">
@if(isset($button) && is_array($button)) @if(!empty($button))
<button type="button" class="action-button btn btn-secondary" data-confirmation="{{ $button[1] }}" data-success="{{ $button[2] }}" data-error="{{ $button[3] }}" data-url="{{ $button[4] }}">{{ $button[0] }}</button> <button type="button" class="action-button btn btn-secondary" data-confirmation="{{ $button[1] }}" data-success="{{ $button[2] }}" data-error="{{ $button[3] }}" data-url="{{ $button[4] }}">{{ $button[0] }}</button>
@endif @endif

View file

@ -2,7 +2,7 @@
@section('dashboard-body') @section('dashboard-body')
<div class="list-group menu-list"> <div class="list-group menu-list">
@foreach(App\Models\DashboardMenu::$menu as $menu_item) @foreach(App\Models\Dashboard::$menu as $menu_item)
@if(array_key_exists('submenu', $menu_item)) @if(array_key_exists('submenu', $menu_item))
@foreach($menu_item['submenu'] as $submenu_item) @foreach($menu_item['submenu'] as $submenu_item)
<li class="list-group-item"> <li class="list-group-item">

View file

@ -1,4 +1,4 @@
<nav class="navbar navbar-expand-lg navbar-dark"> <nav class="navbar navbar-expand-lg">
@set('current_page', preg_replace([ '/^.*\/dashboard\/?/', '/\/.*/' ], [ '', '' ], Request::url())) @set('current_page', preg_replace([ '/^.*\/dashboard\/?/', '/\/.*/' ], [ '', '' ], Request::url()))
<a class="navbar-brand" href="{{ url('/dashboard') }}"> <a class="navbar-brand" href="{{ url('/dashboard') }}">
@ -6,7 +6,11 @@
</a> </a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#dashboard-navbar" aria-controls="dashboard-navbar" aria-expanded="false" aria-label="Toggle navigation"> <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#dashboard-navbar" aria-controls="dashboard-navbar" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span> <span class="navbar-toggler-icon">
<span class="navbar-toggler-icon-bar"></span>
<span class="navbar-toggler-icon-bar"></span>
<span class="navbar-toggler-icon-bar"></span>
</span>
</button> </button>
<div id="dashboard-navbar" class="collapse navbar-collapse"> <div id="dashboard-navbar" class="collapse navbar-collapse">
@ -18,14 +22,16 @@
<li class="nav-item"><a class="nav-link" href="{{ url('/register') }}">Register</a></li> <li class="nav-item"><a class="nav-link" href="{{ url('/register') }}">Register</a></li>
@endif @endif
@else @else
@foreach(App\Models\DashboardMenu::$menu as $menu_item) @foreach(App\Models\Dashboard::$menu as $menu_item)
@if(array_key_exists('submenu', $menu_item)) @if(array_key_exists('submenu', $menu_item))
<li class="nav-item dropdown"> @set('dropdown_id', preg_replace([ '/\ \ */', '/[^a-z\-]/' ], [ '-', '' ], strtolower($menu_item['title'])))
<a id="menu-dropdown" class="nav-link dropdown-toggle" href="#" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
{{ $menu_item['title'] }} <span class="caret"></span>
</a>
<div class="dropdown-menu dropdown-menu-right" aria-labelledby="menu-dropdown"> <li class="nav-item dropdown">
<span id="menu-dropdown-{{ $dropdown_id }}" class="nav-link dropdown-toggle" href="#" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
{{ $menu_item['title'] }} <span class="caret"></span>
</span>
<div class="dropdown-menu dropdown-menu-right" aria-labelledby="menu-dropdown-{{ $dropdown_id }}">
@foreach($menu_item['submenu'] as $submenu_item) @foreach($menu_item['submenu'] as $submenu_item)
<a class="dropdown-item" href="{{ url('/dashboard/' . $submenu_item['type'] . '/' . $submenu_item['model']) }}">{{ $submenu_item['title'] }}</a> <a class="dropdown-item" href="{{ url('/dashboard/' . $submenu_item['type'] . '/' . $submenu_item['model']) }}">{{ $submenu_item['title'] }}</a>
@endforeach @endforeach

View file

@ -11,8 +11,8 @@
<table class="table"> <table class="table">
<thead> <thead>
<tr class="heading-row"> <tr class="heading-row">
@foreach($columns as $column) @foreach($columns as $index => $column)
<th>{{ $column[0] }}</th> <th>{{ $column_headings[$index] }}</th>
@endforeach @endforeach
</tr> </tr>
</thead> </thead>
@ -20,8 +20,8 @@
<tbody> <tbody>
@foreach($rows as $row) @foreach($rows as $row)
<tr> <tr>
@foreach($columns as $column) @foreach($columns as $index => $column)
<td><strong class="mobile-heading">{{ $column[0] }}: </strong>{{ $row[$column[1]] }}</td> <td><strong class="mobile-heading">{{ $column_headings[$index] }}: </strong>{{ $row[$column['name']] }}</td>
@endforeach @endforeach
</tr> </tr>
@endforeach @endforeach

View file

@ -30,12 +30,13 @@ Route::get('/logout', 'Auth\LoginController@logout');
Route::group([ 'prefix' => 'dashboard' ], function() { Route::group([ 'prefix' => 'dashboard' ], function() {
Route::get('/', 'DashboardController@index'); Route::get('/', 'DashboardController@index');
Route::get('/contact', 'DashboardController@getContact'); Route::get('/view/{model}', 'DashboardController@getView');
Route::get('/subscriptions', 'DashboardController@getSubscriptions'); Route::get('/edit/{model}', 'DashboardController@getEditList');
Route::get('/edit-item/{model}/{id}', 'DashboardController@getEditItem');
Route::get('/export/{model}', 'DashboardController@getExport'); Route::get('/export/{model}', 'DashboardController@getExport');
Route::post('/image-upload', 'DashboardController@postImageUpload'); Route::post('/image-upload', 'DashboardController@postImageUpload');
Route::post('/file-upload', 'DashboardController@postFileUpload'); Route::post('/file-upload', 'DashboardController@postFileUpload');
Route::post('/edit', 'DashboardController@postEdit'); Route::post('/update', 'DashboardController@postUpdate');
Route::post('/reorder', 'DashboardController@postReorder'); Route::post('/reorder', 'DashboardController@postReorder');
Route::delete('/delete', 'DashboardController@deleteDelete'); Route::delete('/delete', 'DashboardController@deleteDelete');
Route::delete('/image-delete', 'DashboardController@deleteImageDelete'); Route::delete('/image-delete', 'DashboardController@deleteImageDelete');