Merge branch 'editing', split darkmode to own file

pull/27/head
Ondřej Hruška 6 years ago
commit 84103d6001
Signed by: MightyPork
GPG Key ID: 2C5FD5035250423D
  1. 12
      Makefile
  2. 18
      _json_typehints.php
  3. 3
      app/Exceptions/Handler.php
  4. 14
      app/Exceptions/ViewException.php
  5. 6
      app/Http/Controllers/Controller.php
  6. 33
      app/Http/Controllers/TableController.php
  7. 174
      app/Http/Controllers/TableEditController.php
  8. 4
      app/Models/BaseModel.php
  9. 60
      app/Models/Proposal.php
  10. 4
      app/Models/Revision.php
  11. 2
      app/Models/Row.php
  12. 38
      app/Models/Table.php
  13. 11
      app/Providers/AppServiceProvider.php
  14. 76
      app/Tables/BaseNumerator.php
  15. 467
      app/Tables/Changeset.php
  16. 114
      app/Tables/Column.php
  17. 25
      app/Tables/ColumnNumerator.php
  18. 19
      app/Tables/RowNumerator.php
  19. 37
      app/helpers.php
  20. 1
      config/app.php
  21. 201
      config/debugbar.php
  22. 121
      package-lock.json
  23. 8
      package.json
  24. 46
      porklib/Exceptions/SimpleValidationException.php
  25. 11
      porklib/Utils/Utils.php
  26. 124
      porklib/helpers.php
  27. 111
      resources/assets/js/app.js
  28. 52
      resources/assets/js/base-setup.js
  29. 55
      resources/assets/js/bootstrap.js
  30. 3
      resources/assets/js/components/Icon.vue
  31. 160
      resources/assets/js/components/RowsEditor.vue
  32. 0
      resources/assets/js/lib/url-slug.js
  33. 21
      resources/assets/js/modules/block-collapse.js
  34. 10
      resources/assets/js/modules/flash-messages.js
  35. 19
      resources/assets/js/modules/form-autoalias.js
  36. 12
      resources/assets/js/udash.js
  37. 27
      resources/assets/js/vue-init.js
  38. 17
      resources/assets/sass/_helpers.scss
  39. 2
      resources/assets/sass/app.scss
  40. 20
      resources/assets/sass/bootstrap-customizations/_border.scss
  41. 0
      resources/assets/sass/bootstrap-customizations/_nav.scss
  42. 2
      resources/assets/sass/bootstrap-customizations/_responsive.scss
  43. 9
      resources/assets/sass/bootstrap-customizations/_table.scss
  44. 10
      resources/views/layouts/app.blade.php
  45. 13
      resources/views/layouts/guest-buttons.blade.php
  46. 31
      resources/views/layouts/main-nav.blade.php
  47. 3
      resources/views/layouts/nav-buttons.blade.php
  48. 9
      resources/views/table/_rows.blade.php
  49. 40
      resources/views/table/_view-action-buttons.blade.php
  50. 3
      resources/views/table/conf.blade.php
  51. 24
      resources/views/table/create.blade.php
  52. 52
      resources/views/table/propose/add-rows.blade.php
  53. 32
      resources/views/table/propose/edit-rows.blade.php
  54. 31
      resources/views/table/propose/layout-row-pagination.blade.php
  55. 60
      resources/views/table/propose/layout.blade.php
  56. 6
      resources/views/table/propose/manage-columns.blade.php
  57. 6
      resources/views/table/propose/review.blade.php
  58. 2
      resources/views/table/view.blade.php
  59. 4
      routes/web.php
  60. 20
      webpack.mix.js

@ -0,0 +1,12 @@
.PHONY: build dev watch prod ana
build: dev
dev:
npm run dev
watch:
npm run watch
ana:
npm run dev-analyze
prod:
npm run prod

@ -0,0 +1,18 @@
<?php
/**
* Interface RowData
*
* @property int $_id
*/
interface RowData {}
/**
* Interface DecoratedRow
*
* @property bool $_new - row is new in the changeset
* @property bool $_remove - marked to be removed
* @property mixed[] $_orig - original values before transformation, key by CID
* @property string[] $_changed - values that were changed
*/
interface DecoratedRow extends RowData {}

@ -6,6 +6,7 @@ use Exception;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Validation\ValidationException;
use MightyPork\Exceptions\Exceptions\SimpleValidationException;
class Handler extends ExceptionHandler
{
@ -15,7 +16,7 @@ class Handler extends ExceptionHandler
* @var array
*/
protected $dontReport = [
//
SimpleValidationException::class,
];
/**

@ -1,14 +0,0 @@
<?php
namespace FlowBox\Exceptions;
class ViewException extends FBRuntimeException
{
public $captured;
public function __construct(\Exception $cause, $captured)
{
$this->captured = $captured;
parent::__construct($cause);
}
}

@ -3,6 +3,7 @@
namespace App\Http\Controllers;
use Illuminate\Foundation\Bus\DispatchesJobs;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller as BaseController;
use Illuminate\Foundation\Validation\ValidatesRequests;
@ -55,6 +56,11 @@ class Controller extends BaseController
'mastodon', // mastodon fetching previews
];
protected function jsonResponse($data, $code=200)
{
return new JsonResponse($data, $code);
}
// Hacks to allow recursive nesting of validations in string and array format
public function makeValidator($data, $rules, $messages = array(), $customAttributes = array())

@ -5,6 +5,7 @@ namespace App\Http\Controllers;
use App\Models\Revision;
use App\Models\Table;
use App\Models\User;
use App\Tables\Changeset;
use App\Tables\Column;
use App\Tables\ColumnNumerator;
use App\Tables\CStructArrayExporter;
@ -31,7 +32,7 @@ class TableController extends Controller
->where('name', $table)->first();
if ($tableModel === null) abort(404, "No such table.");
// make it possible to show other revisions
// option to show other revisions
if ($input->has('rev')) {
$rev = (int)$input->rev;
$revision = $tableModel->revisions()->orderBy('created_at')->skip($rev - 1)->first();
@ -206,39 +207,13 @@ class TableController extends Controller
}
// --- DATA ---
$dataCsvLines = array_map('str_getcsv', explode("\n", $input->data));
$dataCsvLines = Utils::csvToArray($input->data);
// Preparing data to insert into the Rows table
$rowsToInsert = null;
$rowNumerator = null;
try {
$rowsToInsert = collect($dataCsvLines)->map(function ($row) use ($columns) {
if (count($row) == 0 || count($row) == 1 && $row[0] == '') return null; // discard empty lines
if (count($row) != count($columns)) {
throw new NotApplicableException("All rows must have " . count($columns) . " fields.");
}
$data = [];
foreach ($row as $i => $val) {
$col = $columns[$i];
if (strlen($val) > 1000) {
// try to stop people inserting unstructured crap / malformed CSV
throw new NotApplicableException("Value for column {$col->name} too long.");
}
$data[$col->id] = $col->cast($val);
}
// return in a format prepared for filling eloquent
return ['data' => $data];
})->filter()->all(); // remove nulls, to array
// attach _id labels to all rows
$rowNumerator = new RowNumerator(count($dataCsvLines));
foreach ($rowsToInsert as &$item) {
$item['data']['_id'] = $rowNumerator->next();
}
$rowsToInsert = Changeset::csvToRowsArray($columns, $dataCsvLines, true)->all();
} catch (\Exception $e) {
return $this->backWithErrors(['data' => $e->getMessage()]);
}

@ -0,0 +1,174 @@
<?php
namespace App\Http\Controllers;
use App\Models\Table;
use App\Models\User;
use App\Tables\Changeset;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Input;
use MightyPork\Exceptions\SimpleValidationException;
use MightyPork\Utils\Utils;
class TableEditController extends Controller
{
/**
* Initialize the session-stored changeset, if not set yet
*
* @param Table $table
* @return Changeset
*/
private function getChangeset(Table $table)
{
if (Input::has('reset')) {
session()->forget($table->draftSessionKey);
}
/** @var Changeset $changeset */
return session()->remember($table->draftSessionKey, function () use ($table) {
$changeset = new Changeset();
$changeset->table = $table;
$changeset->revision = $table->revision;
return $changeset;
});
}
private function storeChangeset(Changeset $chs)
{
session()->put($chs->table->draftSessionKey, $chs);
}
/**
* Discard draft and redirect to table view
*/
public function discard(User $user, string $table)
{
/** @var Table $tableModel */
$tableModel = $user->tables()->where('name', $table)->first();
if ($tableModel === null) abort(404, "No such table.");
session()->forget($tableModel->draftSessionKey);
return redirect($tableModel->viewRoute);
}
public function draft(User $user, string $table, $tab = null)
{
/** @var Table $tableModel */
$tableModel = $user->tables()->where('name', $table)->first();
if ($tableModel === null) abort(404, "No such table.");
if ($tab == null) $tab = 'edit-rows';
$tabs = ['edit-rows', 'add-rows', 'manage-columns', 'review'];
if (!in_array($tab, $tabs)) abort(404, "No such tab: $tab");
$changeset = $this->getChangeset($tableModel);
if (Input::has('dump')) {
dd($changeset);
}
return $this->{camel_case($tab)}($changeset);
}
/** @noinspection PhpUnusedPrivateMethodInspection */
private function editRows(Changeset $changeset)
{
$revision = $changeset->revision;
$columns = $changeset->fetchAndTransformColumns();
$rows = $revision->rowsData($columns, true, false)->paginate(25, []);
return view('table.propose.edit-rows', [
'changeset' => $changeset,
'table' => $changeset->table,
'columns' => collect($columns),
'rows' => $rows,
]);
}
/** @noinspection PhpUnusedPrivateMethodInspection */
private function addRows(Changeset $changeset)
{
$columns = $changeset->fetchAndTransformColumns();
$rows = $changeset->getAddedRows(25);
return view('table.propose.add-rows', [
'changeset' => $changeset,
'table' => $changeset->table,
'columns' => collect($columns),
'rows' => $rows,
]);
}
public function draftUpdate(Request $request, User $user, string $table)
{
/** @var Table $tableModel */
$tableModel = $user->tables()->where('name', $table)->first();
if ($tableModel === null) abort(404, "No such table.");
$changeset = $this->getChangeset($tableModel);
$input = objBag($request->all(), false);
try {
$code = 200;
switch ($input->action) {
case 'row.update':
$data = (object)$input->data;
$changeset->rowUpdate($data);
$resp = $changeset->fetchAndTransformRow($data->_id);
break;
case 'row.remove':
$isNew = $changeset->isNewRow($input->id);
$changeset->rowRemove($input->id);
$resp = $isNew ? null : $changeset->fetchAndTransformRow($input->id);
break;
case 'row.restore':
$changeset->rowRestore($input->id);
$resp = $changeset->fetchAndTransformRow($input->id);
break;
case 'rows.add':
$changeset->addBlankRows($input->count);
// rows.add is sent via a form
if ($input->has('redirect')) {
return redirect($input->redirect);
} else {
$resp = null;
}
break;
case 'rows.add-csv':
try {
$changeset->addFilledRows(Utils::csvToArray($input->data));
} catch (\Exception $e) {
return $this->backWithErrors(['data' => $e->getMessage()]);
}
// rows.add-csv is sent via a form
if ($input->has('redirect')) {
return redirect($input->redirect);
} else {
$resp = null;
}
break;
default:
$resp = "Bad Action";
$code = 400;
break;
}
} catch (SimpleValidationException $e) {
return $this->jsonResponse(['errors' => $e->getMessageBag()->getMessages()], 400);
}
$this->storeChangeset($changeset);
return $this->jsonResponse($resp, $code);
}
}

@ -26,7 +26,9 @@ class BaseModel extends Model
public function getRelationValue($key)
{
if ($this->exists && !method_exists($this, $key)) {
throw new \LogicException("No attribute or relation ".var_export($key, true));
if (!isset($this->original[$key])) {
throw new \LogicException("No attribute or relation " . var_export($key, true));
}
}
return parent::getRelationValue($key);

@ -3,6 +3,8 @@
namespace App\Models;
use App\Models\Concerns\Reportable;
use App\Tables\Changeset;
use MightyPork\Exceptions\NotApplicableException;
/**
* Change proposal
@ -14,7 +16,7 @@ use App\Models\Concerns\Reportable;
* @property int $revision_id
* @property int $author_id
* @property string $note
* @property object $changes - JSONB
* @property Proposal $changes - JSONB
* @property-read User $author
* @property-read Table $table
* @property-read Revision $revision
@ -43,4 +45,60 @@ class Proposal extends BaseModel
{
return $this->belongsTo(Table::class);
}
public function getChangesAttribute($value)
{
$changeset = Changeset::fromObject(fromJSON($value));
$changeset->revision = $this->revision;
$changeset->table = $this->table;
$changeset->note = $this->note;
return $changeset;
}
public function setChangesAttribute($value)
{
if ($value instanceof Changeset) {
$this->attributes['changes'] = toJSON($value->toObject());
} else {
throw new NotApplicableException("Only a Changeset may be set to Proposal->changes");
}
}
/**
* Create a new Proposal instance wrapping this changeset,
* owned by the currently logged in User. The created instance
* is NOT saved yet.
*
* @param Changeset $changeset - changeset to hydrate the proposal with
* @return Proposal
*/
public static function fromChangeset(Changeset $changeset)
{
if (!$changeset->hasAnyChanges()) {
throw new NotApplicableException('No changes to propose.');
}
if ($changeset->note == null) {
throw new NotApplicableException('Proposal note must be filled.');
}
if ($changeset->table == null || !$changeset->table instanceof Table) {
throw new NotApplicableException('Table not assigned to Changeset');
}
if ($changeset->revision == null || !$changeset->revision instanceof Revision) {
throw new NotApplicableException('Revision not assigned to Changeset');
}
return new Proposal([
// relations
'table_id' => $changeset->table->getKey(),
'revision_id' => $changeset->revision->getKey(),
'author_id' => \Auth::user()->getKey(),
// the proposal info
'note' => $changeset->note,
'changes' => $changeset->toObject(),
]);
}
}

@ -40,12 +40,12 @@ class Revision extends BaseModel
* @param Column[] $columns
* @return \Illuminate\Database\Query\Builder|static
*/
public function rowsData($columns, $withId=true)
public function rowsData($columns, $withId=true, $named=true)
{
$selects = $withId ? ["data->>'_id' as _id"] : [];
foreach ($columns as $col) {
$selects[] = "data->>'$col->id' as $col->name";
$selects[] = "data->>'$col->id' as " . ($named ? $col->name : $col->id);
}
return $this->rows()->select([])->selectRaw(implode(', ', $selects));

@ -6,7 +6,7 @@ namespace App\Models;
* Row in a data table
*
* @property int $id
* @property string $data - JSONB, always containing _id
* @property object|\RowData $data - JSONB, always containing _id
*/
class Row extends BaseModel
{

@ -22,7 +22,12 @@ use Illuminate\Database\Eloquent\Collection;
* @property string $origin
* @property int $visits
* @property-read string $viewRoute
* @property-read string $draftRoute
* @property-read string $settingsRoute
* @property-read string $deleteRoute
* @property-read string $draftSessionKey
* @property-read string $draftDiscardRoute
* @property-read string $draftUpdateRoute
* @property-read User $owner
* @property-read Table $parentTable
* @property-read Table[]|Collection $forks
@ -123,21 +128,36 @@ class Table extends BaseModel
public function __get($name)
{
if ($name == 'viewRoute') {
return route('table.view', ['user' => $this->cachedOwner()->name, 'table' => $this->name]);
if (ends_with($name, 'Route')) {
$arg = [
'user' => $this->cachedOwner()->name,
'table' => $this->name
];
switch ($name) {
case 'viewRoute': return route('table.view', $arg);
case 'settingsRoute': return route('table.conf', $arg);
case 'draftRoute': return route('table.draft', $arg);
case 'deleteRoute': return route('table.delete', $arg);
case 'draftDiscardRoute': return route('table.draft-discard', $arg);
case 'draftUpdateRoute': return route('table.draft-update', $arg);
}
}
if ($name == 'settingsRoute') {
return route('table.conf', ['user' => $this->cachedOwner()->name, 'table' => $this->name]);
}
if ($name == 'deleteRoute') {
return route('table.delete', ['user' => $this->cachedOwner()->name, 'table' => $this->name]);
}
if ($name == 'draftSessionKey') return "proposal.{$this->id}";
return parent::__get($name);
}
public function getDraftRoute($tab=null)
{
return route('table.draft', [
'user' => $this->cachedOwner()->name,
'table' => $this->name,
'tab' => $tab,
]);
}
public function scopeForList(Builder $query)
{
return $query->with('revision:id,row_count')->with('owner:id,name,title')

@ -29,8 +29,17 @@ class AppServiceProvider extends ServiceProvider
});
\Blade::directive('tooltip', function($arg) {
$arg = trim($arg);
$placement = '';
if (starts_with($arg, ['top,', 'bottom,', 'left,', 'right,'])) {
list($placement, $arg) = explode(',', $arg);
$arg = trim($arg);
}
$arge = e($arg);
return 'aria-label="' . $arge . '" title="' . $arge . '"';
$html = '';
if ($placement) $html .= 'data-placement="' . $placement . '" ';
return $html . 'data-toggle="tooltip" aria-label="' . $arge . '" title="' . $arge . '"';
});
\Blade::directive('sr', function($arg) {

@ -0,0 +1,76 @@
<?php
namespace App\Tables;
abstract class BaseNumerator
{
/** @var int */
protected $next;
/** @var int */
protected $last;
/**
* BaseNumerator constructor.
*
* @param int|int[] $first - first index, or [first, last]
* @param int|null $last - last index, or null
*/
public function __construct($first, $last = null)
{
if (is_array($first) && $last === null) {
list($first, $last) = $first;
}
$this->next = $first;
$this->last = $last;
}
/**
* Get next key, incrementing the internal state
*
* @return string
*/
public function next()
{
if (!$this->hasMore())
throw new \OutOfBoundsException("Column numerator has run out of allocated GCID slots");
$key = $this->getKey($this->next);
$this->next++;
return $key;
}
/**
* Convert numeric index to a key
*
* @param int $index
* @return mixed
*/
protected function getKey($index)
{
return $index; // simple default
}
/**
* @return bool - true iof there are more keys available
*/
protected function hasMore()
{
return $this->next <= $this->last;
}
/**
* Generate all keys
*
* @return \Generator
*/
public function generate()
{
while ($this->hasMore()) {
yield $this->next();
}
}
}

@ -0,0 +1,467 @@
<?php
namespace App\Tables;
use App\Models\Revision;
use App\Models\Row;
use App\Models\Table;
use Illuminate\Pagination\Paginator;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Collection;
use MightyPork\Exceptions\NotApplicableException;
use MightyPork\Exceptions\NotExistException;
use ReflectionClass;
/**
* Object representing a set of table modifications
*/
class Changeset
{
use SerializesModels;
const object_excluded_properties = [
'revision',
'table',
'note',
];
/**
* @var Revision - base revision this changeset belongs to
*/
public $revision;
/**
* @var Table - table this changeset belongs to
*/
public $table;
/**
* @var string - user's note attached to this changeset (future proposal)
*/
public $note = '';
/**
* Rows whose content changed, identified by _id.
* Only changed values are to be filled. Columns are identified by GCIDs
*
* Key'd by _id
*
* @var array|null - [_id -> [_id:…, cid:…, cid:…], …]
*/
public $rowUpdates = [];
/**
* New rows in the full Row::data format, including GRIDs.
* Values are identified by GCIDs from previously defined, or new columns.
*
* @var array|null - [_id -> [_id:…, cid:…, cid:…], …]
*/
public $newRows = [];
/**
* Rows to be removed
*
* @var int[]|null - GRIDs
*/
public $removedRows = [];
/**
* Values changed in column specifications, such as name, title, etc.
* This does not affect the table rows in any way.
*
* Key'd by id
*
* @var array[] - column specification objects, with GCIDs, key'd by CID
*/
public $columnUpdates = [];
/**
* New columns in the full format, including GCIDs
*
* @var array|null - [id -> [id:…, …], …]
*/
public $newColumns = [];
/**
* When reordering columns, here is the column IDs array
* in the new order. Columns meanwhile removed from the table
* or added to it are to be ignored or appended at the end.
*
* This shall not be filled if merely editing or adding columns,
* unless the order is explicitly adjusted by the user.
*
* @var string[]|null - GCIDs
*/
public $columnOrder = [];
/**
* Columns to be removed
*
* The data associated to those columns may or may not be removed from the Rows.
* It does not matter, other than in regard to the table size.
*
* @var int[]|null - GCIDs
*/
public $removedColumns = [];
private function walkProps()
{
$properties = (new ReflectionClass($this))->getProperties();
foreach ($properties as $property) {
if (in_array($property->name, self::object_excluded_properties)) {
continue;
}
yield $property->name;
}
}
/**
* Reconstruct from a object, such as one found in Proposal.
* Note that the fromProposal() method should be used when the
* proposal is available, as it populates additional fields.
*
* @param \stdClass $changes
* @return Changeset
*/
public static function fromObject($changes)
{
$changeset = new Changeset();
foreach ($changeset->walkProps() as $prop) {
if (isset($changes->$prop)) {
$changeset->$prop = $changes->$prop;
}
}
return $changeset;
}
/**
* Serialize to an object format that may be stored in a Proposal.
*
* @return \stdClass
*/
public function toObject()
{
$object = new \stdClass();
foreach ($this->walkProps() as $prop) {
$object->$prop = $this->$prop;
}
return $object;
}
/**
* Check if there is any change in this changeset
*
* @return bool - any found
*/
public function hasAnyChanges()
{
foreach ($this->walkProps() as $prop) {
if (!empty($this->$prop)) {
return true;
}
}
return false;
}
/**
* Decorate / transform a single row for the editor view
*
* @param object|\DecoratedRow $row - row, must be key'd by column ids
* @param bool $decorate - to add extra underscored info for the editor
* @return \DecoratedRow|object|null - null if not decorating and the row was removed
*/
public function transformRow($row, $decorate)
{
if ($row instanceof Row) $row = (object)$row->getAttributes();
if ($decorate) {
$row->_remove = false;
$row->_changed = [];
$row->_orig = [];
}
if ($this->isNewRow($row->_id)) {
if ($decorate) {
$row->_new = true;
}
return $row;
}
// Removed rows - return as null
if (in_array($row->_id, $this->removedRows)) {
if ($decorate) {
$row->_remove = true;
} else {
return null;
}
}
// if marked for removal, hide changes
if (!$row->_remove) {
// Changed values
if (isset($this->rowUpdates[$row->_id])) {
$newVals = $this->rowUpdates[$row->_id];
if ($decorate) {
$row->_changed = array_keys($newVals);
$row->_orig = array_only((array)$row, $row->_changed);
}
$row = (object)array_merge((array)$row, $newVals);
}
}
// Drop deleted columns
if (!$decorate) {
foreach ($this->removedColumns as $colId) {
unset($row->$colId);
}
}
unset($row->_row_pivot);
return $row;
}
/**
* Decorate / transform columns (loaded from the source revision)
*
* @return Column[]
*/
public function fetchAndTransformColumns()
{
/** @var Column[] - loaded and transformed columns, cached from previous call to transformColumns() */
static $cachedColumns = [];
if ($cachedColumns) return $cachedColumns;
$columns = Column::columnsFromJson($this->revision->columns);
// Modify columns
foreach ($columns as $column) {
if (isset($this->columnUpdates[$column->id])) {
$column->modifyByChangeset($this->columnUpdates[$column->id]);
}
if (in_array($column->id, $this->removedColumns)) {
$column->markForRemoval();
}
}
// Append new columns
foreach ($this->newColumns as $newColumn) {
$columns[] = $c = new Column($newColumn);
$c->markAsNew();
}
// Reorder
$colsById = collect($columns)->keyBy('id')->all();
$newOrder = [];
foreach ($this->columnOrder as $id) {
$newOrder[] = $colsById[$id];
}
$leftover_keys = array_diff(array_keys($colsById), $this->columnOrder);
foreach ($leftover_keys as $id) {
$newOrder[] = $colsById[$id];
}
return $cachedColumns = $newOrder;
}
public function rowRemove(int $id)
{
if ($this->isNewRow($id)) {
unset($this->newRows[$id]);
}
else {
$this->removedRows[] = $id;
}
}
public function rowRestore(int $id)
{
$this->removedRows = array_diff($this->removedRows, [$id]);
}
public function isNewRow(int $id)
{
return isset($this->newRows[$id]);
}
public function fetchAndTransformRow(int $id)
{
$r = $this->fetchRow($id);
$transformed = $this->transformRow($r, true);
return $transformed;
}
public function fetchColumns()
{
return Column::columnsFromJson($this->revision->columns);
}
/**
* Fetch an existing row from DB, or a new row.
*
* @param $id
* @return object
*/
public function fetchRow(int $id)
{
if ($this->isNewRow($id)) {
return (object)$this->newRows[$id];
}
$r = $this->revision->rowsData($this->fetchColumns(), true, false)
->whereRaw("data->>'_id' = ?", $id)->first();
if (!$r) throw new NotExistException("No such row _id = $id in this revision.");
// remove junk
unset($r->pivot_revision_id);
unset($r->pivot_row_id);
return (object)$r->getAttributes();
}
/**
* Apply a row update, adding the row to the list of changes, or removing it
* if all differences were undone.
*
* @param array|object $newVals - values of the new row
*/
public function rowUpdate($newVals)
{
$newVals = (object)$newVals;
$_id = $newVals->_id;
$origRow = $this->fetchRow($_id);
/** @var Column[]|Collection $cols */
$cols = collect($this->fetchAndTransformColumns())->keyBy('id');
$updateObj = [];
foreach ($newVals as $colId => $value) {
if (starts_with($colId, '_')) continue; // internals
$col = $cols[$colId];
$value = $col->cast($value);
$origValue = $col->cast(isset($origRow->$colId) ? $origRow->$colId : null);
if ($value !== $origValue) {
$updateObj[$colId] = $value;
}
}
if ($this->isNewRow($_id)) {
$this->newRows[$_id] = array_merge($this->newRows[$_id], $updateObj);
}
else {
if (!empty($updateObj)) {
$this->rowUpdates[$_id] = $updateObj;
} else {
// remove possible old update record for this row, if nothing changes now
unset($this->rowUpdates[$_id]);
}
}
}
/**
* Get a page of added rows for display in the editor
*
* @param int $perPage
* @return \Illuminate\Pagination\LengthAwarePaginator|Collection|array
*/
public function getAddedRows($perPage = 25)
{
return collection_paginate($this->newRows, $perPage);
}
/**
* @param Column[] $columns
* @param array $csvArray
* @param bool $forTableInsert
* @return \array[][]|Collection
*/
public static function csvToRowsArray($columns, $csvArray, $forTableInsert)
{
/** @var Collection|array[][] $rows */
$rows = collect($csvArray)->map(function ($row) use ($columns, $forTableInsert) {
if (count($row) == 0 || count($row) == 1 && $row[0] == '') return null; // discard empty lines
if (count($row) != count($columns)) {
throw new NotApplicableException("All rows must have " . count($columns) . " fields.");
}
$data = [];
foreach ($row as $i => $val) {
$col = $columns[$i];
if (strlen($val) > 1000) {
// try to stop people inserting unstructured crap / malformed CSV
throw new NotApplicableException("Value for column {$col->name} too long.");
}
$data[$col->id] = $col->cast($val);
}
if ($forTableInsert) {
return ['data' => $data];
} else {
return $data;
}
})->filter();
$rowNumerator = new RowNumerator(count($csvArray));
if ($forTableInsert) {
return $rows->map(function ($row) use (&$rowNumerator) {
$row['data']['_id'] = $rowNumerator->next();
return $row;
});
}
else {
return $rows->map(function ($row) use (&$rowNumerator) {
$row['_id'] = $rowNumerator->next();
return $row;
});
}
}
public function addBlankRows(int $count)
{
$numerator = new RowNumerator($count);
$columns = $this->fetchAndTransformColumns();
$template = [];
foreach ($columns as $column) {
$template[$column->id] = $column->cast(null);
}
foreach ($numerator->generate() as $_id) {
$row = $template;
$row['_id'] = $_id;
$this->newRows[$_id] = $row;
}
}
public function addFilledRows($csvArray)
{
/** @var Column[] $columns */
$columns = array_values($this->fetchAndTransformColumns());
$rows = self::csvToRowsArray($columns, $csvArray, false)
->keyBy('_id');
$this->newRows = array_merge($this->newRows, $rows->all());
}
}

@ -1,32 +1,105 @@
<?php
namespace App\Tables;
use Illuminate\Contracts\Support\Arrayable;
use JsonSerializable;
use MightyPork\Exceptions\SimpleValidationException;
use MightyPork\Exceptions\NotApplicableException;
use MightyPork\Utils\Utils;
/**
* Helper class representing one column in a data table.
*
* @property-read string $id
* @property-read string $type
* @property-read string $name
* @property-read string $title
* @property-read bool $id_modified
* @property-read bool $type_modified
* @property-read bool $name_modified
* @property-read bool $title_modified
*
* @property-read string $id_orig
* @property-read string $type_orig
* @property-read string $name_orig
* @property-read string $title_orig
*/
class Column implements JsonSerializable
class Column implements JsonSerializable, Arrayable
{
const colTypes = [
'int', 'bool', 'float', 'string'
];
private $id;
private $type;
private $name;
private $title;
// marked public to make keyBy() work
public $id;
public $type;
public $name;
public $title;
/** @var bool - column is marked to be deleted by a changeset */
public $toRemove = false;
/** @var bool - column is new in this changeset */
public $isNew = false;
/** @var array - original attrib values if edited in a changeset */
private $orig_attribs = [];
/** @var array - list of attrib names that are modified by a changeset */
private $modified_attribs = [];
/**
* Mark for removal (used in editing GUI)
*/
public function markForRemoval()
{
$this->toRemove = true;
}
/**
* Mark this column as new
*/
public function markAsNew()
{
$this->isNew = true;
}
/**
* Modify by a changeset
*
* @param array $columnObject
*/
public function modifyByChangeset(array $columnObject)
{
foreach ((array)$columnObject as $key => $value) {
if ($value != $this->$key) {
$this->modified_attribs[] = $key;
$this->orig_attribs[] = $this->$key;
$this->$key = $value;
}
}
}
public function __get($name)
{
if (ends_with($name, '_modified')) {
$basename = str_replace('_modified', '', $name);
if (property_exists($this, $basename)) {
return in_array($basename, $this->modified_attribs);
}
}
if (ends_with($name, '_orig')) {
$basename = str_replace('_orig', '', $name);
if (property_exists($this, $basename)) {
return $this->orig_attribs[$basename];
}
}
throw new NotApplicableException("No such column property: $name");
}
/**
* @param $columns
* @return Column[]
*/
public static function columnsFromJson($columns)
{
if (is_string($columns)) {
@ -56,7 +129,7 @@ class Column implements JsonSerializable
{
$b = objBag($obj);
if (!$b->has('name')) throw new NotApplicableException('Missing name in column');
if (!$b->has('name') || $b->name == '') throw new NotApplicableException('Missing name in column');
if (!$b->has('type')) throw new NotApplicableException('Missing type in column');
if ($b->name[0] == '_') { // global row ID
@ -73,15 +146,6 @@ class Column implements JsonSerializable
$this->title = $b->title ?: $b->name;
}
public function __get($name)
{
if (property_exists($this, $name)) {
return $this->$name;
}
throw new NotApplicableException("No such column property: $name");
}
/**
* @return array with keys {name, title, type}
*/
@ -105,20 +169,24 @@ class Column implements JsonSerializable
{
switch ($this->type) {
case 'int':
if (is_null($value)) return 0;
if (is_int($value)) return $value;
if (is_float($value)) return round($value);
if (is_numeric($value)) return intval($value);
throw new NotApplicableException("Could not convert value \"$value\" to int!");
throw new SimpleValidationException($this->id, "Could not convert value \"$value\" to int!");
case 'float':
if (is_null($value)) return 0;
if (is_int($value) || is_float($value)) return (float)$value;
if (is_numeric($value)) return floatval($value);
throw new NotApplicableException("Could not convert value \"$value\" to float!");
throw new SimpleValidationException($this->id, "Could not convert value \"$value\" to float!");
case 'bool':
if (is_null($value)) return false;
return Utils::parseBool($value);
case 'string':
if (is_null($value)) return "";
return "$value";
default:

@ -17,36 +17,23 @@ use MightyPork\Utils\Utils;
* Thanks to this uniqueness, it could even be possible to compare or merge forks
* of the same table.
*/
class ColumnNumerator
class ColumnNumerator extends BaseNumerator
{
const ALPHABET = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm',
'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z'];
/** @var int */
private $next;
/** @var int */
private $last;
/**
* Create numerator for the given number of columns
*
* @param int $capacity - how many
*/
public function __construct($capacity)
{
list($this->next, $this->last) = Row::allocateColIDs($capacity);
parent::__construct(Row::allocateColIDs($capacity));
}
/**
* Get next column name, incrementing the internal state
*
* @return string
*/
public function next()
protected function getKey($index)
{
if ($this->next > $this->last)
throw new \OutOfBoundsException("Column numerator has run out of allocated GCID slots");
$key = Utils::alphabetEncode($this->next, self::ALPHABET);
$this->next++;
return $key;
return Utils::alphabetEncode($index, self::ALPHABET);
}
}

@ -5,28 +5,15 @@ namespace App\Tables;
use App\Models\Row;
class RowNumerator
class RowNumerator extends BaseNumerator
{
/** @var int */
private $next;
/** @var int */
private $last;
/**
* Create a numerator for the given number of rows.
*
* @param int $capacity
* @param int $capacity - how many
*/
public function __construct($capacity)
{
list($this->next, $this->last) = Row::allocateRowIDs($capacity);
}
public function next()
{
if ($this->next > $this->last)
throw new \OutOfBoundsException("Row numerator has run out of allocated GRID slots");
return $this->next++;
parent::__construct(Row::allocateRowIDs($capacity));
}
}

@ -88,10 +88,45 @@ function old_json($name, $default) {
}
// Safe JSON funcs
function toJSON($object) {
function toJSON($object, $emptyObj=false) {
if ($emptyObj) {
if ((empty($object) || ($object instanceof \Illuminate\Support\Collection && $object->count()==0))) {
return '{}';
}
}
if (!$object instanceof JsonSerializable && $object instanceof \Illuminate\Contracts\Support\Arrayable) {
$object = $object->toArray();
}
return \GuzzleHttp\json_encode($object, JSON_UNESCAPED_SLASHES + JSON_UNESCAPED_UNICODE);
}
function fromJSON($object, $assoc=false) {
return \GuzzleHttp\json_decode($object, $assoc);
}
/**
* @param \Illuminate\Support\Collection|array $items
* @param int $per_page
* @param null|\Closure $mapFn
* @return \Illuminate\Pagination\LengthAwarePaginator
*/
function collection_paginate($items, $per_page, $mapFn = null)
{
if (!$items instanceof \Illuminate\Support\Collection) {
$items = collect($items);
}
$page = Request::get('page', 1);
$pageItems = $items->forPage($page, $per_page)->values();
return new Illuminate\Pagination\LengthAwarePaginator(
$mapFn ? $pageItems->map($mapFn) : $pageItems,
$items->count(),
$per_page,
Illuminate\Pagination\Paginator::resolveCurrentPage(),
['path' => Illuminate\Pagination\Paginator::resolveCurrentPath()]
);
}

@ -215,6 +215,7 @@ return [
'URL' => Illuminate\Support\Facades\URL::class,
'Validator' => Illuminate\Support\Facades\Validator::class,
'View' => Illuminate\Support\Facades\View::class,
'Input' => \Illuminate\Support\Facades\Input::class,
// sideload
'SocialAuth' => AdamWathan\EloquentOAuth\Facades\OAuth::class,

@ -0,0 +1,201 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Debugbar Settings
|--------------------------------------------------------------------------
|
| Debugbar is enabled by default, when debug is set to true in app.php.
| You can override the value by setting enable to true or false instead of null.
|
| You can provide an array of URI's that must be ignored (eg. 'api/*')
|
*/
'enabled' => env('DEBUGBAR_ENABLED', null),
'except' => [
//
],
/*
|--------------------------------------------------------------------------
| Storage settings
|--------------------------------------------------------------------------
|
| DebugBar stores data for session/ajax requests.
| You can disable this, so the debugbar stores data in headers/session,
| but this can cause problems with large data collectors.
| By default, file storage (in the storage folder) is used. Redis and PDO
| can also be used. For PDO, run the package migrations first.
|
*/
'storage' => [
'enabled' => true,
'driver' => 'file', // redis, file, pdo, custom
'path' => storage_path('debugbar'), // For file driver
'connection' => null, // Leave null for default connection (Redis/PDO)
'provider' => '' // Instance of StorageInterface for custom driver
],
/*
|--------------------------------------------------------------------------
| Vendors
|--------------------------------------------------------------------------
|
| Vendor files are included by default, but can be set to false.
| This can also be set to 'js' or 'css', to only include javascript or css vendor files.
| Vendor files are for css: font-awesome (including fonts) and highlight.js (css files)
| and for js: jquery and and highlight.js
| So if you want syntax highlighting, set it to true.
| jQuery is set to not conflict with existing jQuery scripts.
|
*/
'include_vendors' => true,
/*
|--------------------------------------------------------------------------
| Capture Ajax Requests
|--------------------------------------------------------------------------
|
| The Debugbar can capture Ajax requests and display them. If you don't want this (ie. because of errors),
| you can use this option to disable sending the data through the headers.
|
| Optionally, you can also send ServerTiming headers on ajax requests for the Chrome DevTools.
*/
'capture_ajax' => true,
'add_ajax_timing' => false,
/*
|--------------------------------------------------------------------------
| Custom Error Handler for Deprecated warnings
|--------------------------------------------------------------------------
|
| When enabled, the Debugbar shows deprecated warnings for Symfony components
| in the Messages tab.
|
*/
'error_handler' => false,
/*
|--------------------------------------------------------------------------
| Clockwork integration
|--------------------------------------------------------------------------
|
| The Debugbar can emulate the Clockwork headers, so you can use the Chrome
| Extension, without the server-side code. It uses Debugbar collectors instead.
|
*/
'clockwork' => false,
/*
|--------------------------------------------------------------------------
| DataCollectors
|--------------------------------------------------------------------------
|
| Enable/disable DataCollectors
|
*/
'collectors' => [
'phpinfo' => true, // Php version
'messages' => true, // Messages
'time' => true, // Time Datalogger
'memory' => true, // Memory usage
'exceptions' => true, // Exception displayer
'log' => true, // Logs from Monolog (merged in messages if enabled)
'db' => true, // Show database (PDO) queries and bindings
'views' => true, // Views with their data
'route' => true, // Current route information
'auth' => true, // Display Laravel authentication status
'gate' => true, // Display Laravel Gate checks
'session' => true, // Display session data
'symfony_request' => true, // Only one can be enabled..
'mail' => true, // Catch mail messages
'laravel' => false, // Laravel version and environment
'events' => false, // All events fired
'default_request' => false, // Regular or special Symfony request logger
'logs' => false, // Add the latest log messages
'files' => false, // Show the included files
'config' => false, // Display config settings
'cache' => false, // Display cache events
],
/*
|--------------------------------------------------------------------------
| Extra options
|--------------------------------------------------------------------------
|
| Configure some DataCollectors
|
*/
'options' => [
'auth' => [
'show_name' => true, // Also show the users name/email in the debugbar
],
'db' => [
'with_params' => true, // Render SQL with the parameters substituted
'backtrace' => true, // Use a backtrace to find the origin of the query in your files.
'timeline' => false, // Add the queries to the timeline
'explain' => [ // Show EXPLAIN output on queries
'enabled' => false,
'types' => ['SELECT'], // ['SELECT', 'INSERT', 'UPDATE', 'DELETE']; for MySQL 5.6.3+
],
'hints' => true, // Show hints for common mistakes
],
'mail' => [
'full_log' => false
],
'views' => [
'data' => false, //Note: Can slow down the application, because the data can be quite large..
],
'route' => [
'label' => true // show complete route on bar
],
'logs' => [
'file' => null
],
'cache' => [
'values' => true // collect cache values
],
],
/*
|--------------------------------------------------------------------------
| Inject Debugbar in Response
|--------------------------------------------------------------------------
|
| Usually, the debugbar is added just before </body>, by listening to the
| Response after the App is done. If you disable this, you have to add them
| in your template yourself. See http://phpdebugbar.com/docs/rendering.html
|
*/
'inject' => true,
/*
|--------------------------------------------------------------------------
| DebugBar route prefix
|--------------------------------------------------------------------------
|
| Sometimes you want to set route prefix to be used by DebugBar to load
| its resources from. Usually the need comes from misconfigured web server or
| from trying to overcome bugs like this: http://trac.nginx.org/nginx/ticket/97
|
*/
'route_prefix' => '_debugbar',
/*
|--------------------------------------------------------------------------
| DebugBar route domain
|--------------------------------------------------------------------------
|
| By default DebugBar route served from the same domain that request served.
| To override default domain, specify it as a non-empty value.
*/
'route_domain' => null,
];

121
package-lock.json generated

@ -365,6 +365,12 @@
"integrity": "sha1-NhIfhFwFeBct5Bmpfb6x0W7DRUI=",
"dev": true
},
"async-limiter": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.0.tgz",
"integrity": "sha512-jp/uFnooOiO+L211eZOoSyzpOITMXx1rBITauYykG3BRYPu8h0UcxsPNB04RR5vo4Tyz3+ay17tR6JVf9qzYWg==",
"dev": true
},
"asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@ -1226,6 +1232,17 @@
"integrity": "sha1-5tXqjF2tABMEpwsiY4RH9pyy+Ak=",
"dev": true
},
"bfj-node4": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/bfj-node4/-/bfj-node4-5.3.1.tgz",
"integrity": "sha512-SOmOsowQWfXc7ybFARsK3C4MCOWzERaOMV/Fl3Tgjs+5dJWyzo3oa127jL44eMbQiAN17J7SvAs2TRxEScTUmg==",
"dev": true,
"requires": {
"bluebird": "^3.5.1",
"check-types": "^7.3.0",
"tryer": "^1.0.0"
}
},
"big.js": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/big.js/-/big.js-3.2.0.tgz",
@ -1823,6 +1840,12 @@
"integrity": "sha1-wKHS86cJLgN3S/qD8UwPxXkKhmc=",
"dev": true
},
"check-types": {
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/check-types/-/check-types-7.4.0.tgz",
"integrity": "sha512-YbulWHdfP99UfZ73NcUDlNJhEIDgm9Doq9GhpyXbF+7Aegi3CVV7qqMCKTTqJxlvEvnQBp9IA+dxsGN6xK/nSg==",
"dev": true
},
"chokidar": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-2.0.4.tgz",
@ -3254,6 +3277,12 @@
"ware": "^1.2.0"
}
},
"duplexer": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.1.tgz",
"integrity": "sha1-rOb/gIwc5mtX0ev5eXessCM0z8E=",
"dev": true
},
"duplexer2": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz",
@ -3307,6 +3336,12 @@
"integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=",
"dev": true
},
"ejs": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/ejs/-/ejs-2.6.1.tgz",
"integrity": "sha512-0xy4A/twfrRCnkhfk8ErDi5DqdAsAqeGxht4xkCUrsvhhbQNs7E+4jV0CN7+NKIY0aHE72+XvqtBIXzD31ZbXQ==",
"dev": true
},
"electron-to-chromium": {
"version": "1.3.51",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.51.tgz",
@ -4043,6 +4078,12 @@
"trim-repeated": "^1.0.0"
}
},
"filesize": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/filesize/-/filesize-3.6.1.tgz",
"integrity": "sha512-7KjR1vv6qnicaPMi1iiTcI85CyYwRO/PSFCu6SvqL8jN2Wjt/NIYQTFtFs7fSDCYOstUkEWIQGFUg5YZQfjlcg==",
"dev": true
},
"fill-range": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz",
@ -4338,14 +4379,12 @@
"balanced-match": {
"version": "1.0.0",
"bundled": true,
"dev": true,
"optional": true
"dev": true
},
"brace-expansion": {
"version": "1.1.11",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@ -4360,20 +4399,17 @@
"code-point-at": {
"version": "1.1.0",
"bundled": true,
"dev": true,
"optional": true
"dev": true
},
"concat-map": {
"version": "0.0.1",
"bundled": true,
"dev": true,
"optional": true
"dev": true
},
"console-control-strings": {
"version": "1.1.0",
"bundled": true,
"dev": true,
"optional": true
"dev": true
},
"core-util-is": {
"version": "1.0.2",
@ -4490,8 +4526,7 @@
"inherits": {
"version": "2.0.3",
"bundled": true,
"dev": true,
"optional": true
"dev": true
},
"ini": {
"version": "1.3.5",
@ -4503,7 +4538,6 @@
"version": "1.0.0",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"number-is-nan": "^1.0.0"
}
@ -4518,7 +4552,6 @@
"version": "3.0.4",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"brace-expansion": "^1.1.7"
}
@ -4526,14 +4559,12 @@
"minimist": {
"version": "0.0.8",
"bundled": true,
"dev": true,
"optional": true
"dev": true
},
"minipass": {
"version": "2.2.4",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"safe-buffer": "^5.1.1",
"yallist": "^3.0.0"
@ -4552,7 +4583,6 @@
"version": "0.5.1",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"minimist": "0.0.8"
}
@ -4633,8 +4663,7 @@
"number-is-nan": {
"version": "1.0.1",
"bundled": true,
"dev": true,
"optional": true
"dev": true
},
"object-assign": {
"version": "4.1.1",
@ -4646,7 +4675,6 @@
"version": "1.4.0",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"wrappy": "1"
}
@ -4768,7 +4796,6 @@
"version": "1.0.2",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"code-point-at": "^1.0.0",
"is-fullwidth-code-point": "^1.0.0",
@ -5398,6 +5425,16 @@
"glogg": "^1.0.0"
}
},
"gzip-size": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-4.1.0.tgz",
"integrity": "sha1-iuCWJX6r59acRb4rZ8RIEk/7UXw=",
"dev": true,
"requires": {
"duplexer": "^0.1.1",
"pify": "^3.0.0"
}
},
"handle-thing": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-1.2.5.tgz",
@ -8076,6 +8113,12 @@
"integrity": "sha1-ofeDj4MUxRbwXs78vEzP4EtO14k=",
"dev": true
},
"opener": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/opener/-/opener-1.4.3.tgz",
"integrity": "sha1-XG2ixdflgx6P+jlklQ+NZnSskLg=",
"dev": true
},
"opn": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/opn/-/opn-5.3.0.tgz",
@ -12957,6 +13000,12 @@
}
}
},
"tryer": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/tryer/-/tryer-1.0.1.tgz",
"integrity": "sha512-c3zayb8/kWWpycWYg87P71E1S1ZL6b6IJxfb5fvsUgsf0S2MVGaDhDXXjDMpdCpfWXqptc+4mXwmiy1ypXqRAA==",
"dev": true
},
"tty-browserify": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.0.tgz",
@ -13703,6 +13752,26 @@
}
}
},
"webpack-bundle-analyzer": {
"version": "2.13.1",
"resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-2.13.1.tgz",
"integrity": "sha512-rwxyfecTAxoarCC9VlHlIpfQCmmJ/qWD5bpbjkof+7HrNhTNZIwZITxN6CdlYL2axGmwNUQ+tFgcSOiNXMf/sQ==",
"dev": true,
"requires": {
"acorn": "^5.3.0",
"bfj-node4": "^5.2.0",
"chalk": "^2.3.0",
"commander": "^2.13.0",
"ejs": "^2.5.7",
"express": "^4.16.2",
"filesize": "^3.5.11",
"gzip-size": "^4.1.0",
"lodash": "^4.17.4",
"mkdirp": "^0.5.1",
"opener": "^1.4.3",
"ws": "^4.0.0"
}
},
"webpack-chunk-hash": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/webpack-chunk-hash/-/webpack-chunk-hash-0.4.0.tgz",
@ -13981,6 +14050,16 @@
"integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=",
"dev": true
},
"ws": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-4.1.0.tgz",
"integrity": "sha512-ZGh/8kF9rrRNffkLFV4AzhvooEclrOH0xaugmqGsIfFgOE/pIz4fMc4Ef+5HSQqTEug2S9JZIWDR47duDSLfaA==",
"dev": true,
"requires": {
"async-limiter": "~1.0.0",
"safe-buffer": "~5.1.0"
}
},
"xtend": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.1.tgz",

@ -2,6 +2,7 @@
"private": true,
"scripts": {
"dev": "npm run development",
"dev-analyze": "WP_ANALYZE=1 npm run development",
"development": "cross-env NODE_ENV=development node_modules/webpack/bin/webpack.js --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js",
"watch": "npm run development -- --watch",
"watch-poll": "npm run watch -- --watch-poll",
@ -12,11 +13,12 @@
"devDependencies": {
"axios": "^0.18",
"bootstrap": "^4.0.0",
"popper.js": "^1.12",
"cross-env": "^5.1",
"jquery": "^3.2",
"laravel-mix": "^2.0",
"lodash": "^4.17.4",
"vue": "^2.5.7"
"lodash": "^4.17.10",
"popper.js": "^1.12",
"vue": "^2.5.7",
"webpack-bundle-analyzer": "^2.13.1"
}
}

@ -0,0 +1,46 @@
<?php
namespace MightyPork\Exceptions;
use Illuminate\Contracts\Support\MessageProvider;
use Illuminate\Support\MessageBag;
class SimpleValidationException extends RuntimeException implements MessageProvider
{
/** @var MessageBag */
private $mb;
/**
* FBValidationException constructor.
*
* @param string|MessageProvider $key
* @param string $message
*/
public function __construct($key, $message = null)
{
if (is_null($message)) {
$this->mb = $key->getMessageBag();
} else {
$mb = new MessageBag();
$mb->add($key, $message);
$this->mb = $mb;
}
$str = '';
foreach ($this->mb->getMessages() as $key => $errors) {
$str .= $key . ': ' . implode(', ', $errors) . "\n";
}
parent::__construct($str);
}
/**
* Get the messages for the instance.
*
* @return MessageBag
*/
public function getMessageBag()
{
return $this->mb;
}
}

@ -873,7 +873,7 @@ class Utils
* @param int|null $timestamp formatted timestamp, or null for current time
* @return string result
*/
public static function fdate($format, $timestamp = null)
public static function fdate(string $format, $timestamp = null)
{
if ($timestamp === null) $timestamp = time();
@ -889,7 +889,7 @@ class Utils
* @param bool $rough get only approximate time (for estimate)
* @return string result
*/
public static function ftime($secs, $rough = false)
public static function ftime(int $secs, $rough = false)
{
$d = (int) ($secs / 86400);
$secs -= $d * 86400;
@ -946,7 +946,7 @@ class Utils
* @param $time
* @return int seconds
*/
public static function strToSeconds($time)
public static function strToSeconds(string $time)
{
// seconds pass through
if (preg_match('/^\d+$/', trim("$time"))) {
@ -1013,4 +1013,9 @@ class Utils
return $key;
}
public static function csvToArray(string $data)
{
return array_map('str_getcsv', explode("\n", $data));
}
}

@ -65,29 +65,45 @@ function unless($cond, $then, $else = '')
* - Undefined keys are returned as null.
* - array and object values are wrapped in objBag when returned.
*/
class objBag implements JsonSerializable, ArrayAccess {
class objBag implements JsonSerializable, ArrayAccess, \Illuminate\Contracts\Support\Arrayable
{
/** @var object */
private $wrapped;
private $wrapped;
private $recursive;
/**
* objBag constructor.
* @param mixed $wrapped - wrapped object/array/class
* @param bool $recursive - return array/object values as objBags too
*/
public function __construct($wrapped, $recursive = true)
{
$this->wrapped = (object)$wrapped;
$this->recursive = $recursive;
}
public function __construct($wrapped)
{
$this->wrapped = (object)$wrapped;
}
/**
* @param $name
* @return null|objBag|mixed
*/
public function __get($name)
{
if ($this->wrapped) {
if (isset($this->wrapped->$name)) {
$x = $this->wrapped->$name;
public function __get($name)
{
if ($this->wrapped) {
if (isset($this->wrapped->$name)) {
$x = $this->wrapped->$name;
if (is_array($x) || is_object($x)) return objBag($x);
return $x;
}
}
return null;
}
if ($this->recursive && (is_array($x) || is_object($x))) {
return objBag($x);
}
public function __set($name, $value)
return $x;
}
}
return null;
}
public function __set($name, $value)
{
if ($this->wrapped) {
$this->wrapped->$name = $value;
@ -102,48 +118,53 @@ class objBag implements JsonSerializable, ArrayAccess {
}
public function __isset($name)
{
return isset($this->wrapped->$name);
}
{
return isset($this->wrapped->$name);
}
public function get($name, $def = null)
{
if (!isset($this->$name)) return $def;
return $this->$name;
}
/**
* @param $name
* @param null $def
* @return null|array|mixed
*/
public function get($name, $def = null)
{
if (!isset($this->$name)) return $def;
return $this->$name;
}
public function has($name)
{
return isset($this->$name) && $this->$name !== null;
}
public function has($name)
{
return isset($this->$name) && $this->$name !== null;
}
public function unpack()
{
return $this->wrapped;
}
public function unpack()
{
return $this->wrapped;
}
public function toArray()
{
return(array)$this->wrapped;
}
return (array)$this->wrapped;
}
public function all()
{
return $this->toArray();
}
/**
* Specify data which should be serialized to JSON
*
* @link http://php.net/manual/en/jsonserializable.jsonserialize.php
* @return mixed data which can be serialized by <b>json_encode</b>,
* which is a value of any type other than a resource.
* @since 5.4.0
*/
public function jsonSerialize()
{
return $this->wrapped;
}
/**
* Specify data which should be serialized to JSON
*
* @link http://php.net/manual/en/jsonserializable.jsonserialize.php
* @return mixed data which can be serialized by <b>json_encode</b>,
* which is a value of any type other than a resource.
* @since 5.4.0
*/
public function jsonSerialize()
{
return $this->wrapped;
}
public function offsetExists($offset)
{
@ -168,10 +189,11 @@ class objBag implements JsonSerializable, ArrayAccess {
/**
* @param $obj
* @param bool $recursive - whether the bag should be recursive
* @return objBag
*/
function objBag($obj) {
return new \objBag($obj);
function objBag($obj, $recursive=true) {
return new \objBag($obj, $recursive);
}
/**

@ -1,111 +1,14 @@
/**
* First we will load all of this project's JavaScript dependencies which
* includes Vue and other libraries. It is a great starting point when
* building robust, powerful web applications using Vue and Laravel.
*/
/* Project entrypoint */
require('./bootstrap')
let url_slug = require('./url-slug')
require('./base-setup')
require('./modules/block-collapse')
require('./modules/flash-messages')
require('./modules/form-autoalias')
require('./vue-init')
$(function () {
// Remove all noscript from forms etc
$('noscript').remove();
// Bootstrap tooltips
$('[data-toggle="tooltip"]').tooltip({
container: 'body'
})
// auto hide flash alerts
let $notifs = $('div.alert').not('.alert-important').addClass('fadeout')
setTimeout(() => {
$notifs.addClass('fade')
setTimeout(() => {
$notifs.addClass('hidden')
}, 500)
}, 2500)
// toggle collapse when clicked outside link, without drag
$('.block-collapse')
.on('mousedown', (e) => {
let $bc = $(e.target).closest('.block-collapse')
$bc.data('mx', e.screenX);
$bc.data('my', e.screenY);
})
.on('mouseup', (e) => {
if (e.target.nodeName === 'A') return
let $bc = $(e.target).closest('.block-collapse')
if (typeof $bc.data('mx') !== 'undefined') {
let x0 = +$bc.data('mx');
let y0 = +$bc.data('my');
if (Math.abs(x0 - e.screenX) > 5 || Math.abs(y0 - e.screenY) > 5) {
// drag
} else {
$(e.target).closest('.block-collapse').toggleClass('reveal')
}
}
})
})
// auto-alias
$(document).on('input keypress paste keyup', 'input[data-autoalias]', function () {
const $this = $(this)
const target_name = $this.data('autoalias')
const delimiter = $this.data('aa-delimiter') || '_'
const new_alias = url_slug($this.val(), {'delimiter': delimiter})
const $target = $(`input[name="${target_name}"]`)
const lastset = $target.data('aa-last-set-val')
// 1. pick up, or 2. continue
if (new_alias === $target.val() || lastset === $target.val()) {
$target.val(new_alias)
$target.data('aa-last-set-val', new_alias)
}
})
$(function () {
// theme switcher without reloading
let themeStyle = $('#theme-style');
const lightURL = themeStyle.data('light-url');
const darkURL = themeStyle.data('dark-url');
const navbar = $('.page-navbar');
const logo = $('#navbar-logo');
window.toggleDarkMode = function () {
let newStyle = document.createElement('link');
newStyle.rel = 'stylesheet';
if (themeStyle.attr('href') === lightURL) {
newStyle.href = darkURL;
navbar.removeClass('navbar-light');
navbar.addClass('navbar-dark');
logo.attr('src', logo.data('src-dark'));
document.cookie = "dark_mode=1";
} else {
newStyle.href = lightURL;
navbar.removeClass('navbar-dark');
navbar.addClass('navbar-light');
logo.attr('src', logo.data('src-light'));
document.cookie = "dark_mode=0;expires=" + new Date().toUTCString();
}
// remove old css after new css has loaded to prevent FOUC
let oldThemeStyle = themeStyle;
themeStyle = $(newStyle);
themeStyle.on('load', () => oldThemeStyle.remove());
$(document.head).append(themeStyle);
};
});
window.Vue = require('vue');
Vue.component('column-editor', require('./components/ColumnEditor.vue'));
Vue.component('v-icon', require('./components/Icon.vue'));
const app = new Vue({
el: '#app'
});

@ -0,0 +1,52 @@
window._ = require('./udash');
window.Popper = require('popper.js').default
/**
* We'll load jQuery and the Bootstrap jQuery plugin which provides support
* for JavaScript based Bootstrap features such as modals and tabs. This
* code may be modified to fit the specific needs of your application.
*/
window.$ = window.jQuery = require('jquery')
require('bootstrap')
/**
* We'll load the axios HTTP library which allows us to easily issue requests
* to our Laravel back-end. This library automatically handles sending the
* CSRF token as a header based on the value of the "XSRF" token cookie.
*/
window.axios = require('axios')
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'
/**
* Next we will register the CSRF Token as a common header with Axios so that
* all outgoing HTTP requests automatically have it attached. This is just
* a simple convenience so we don't have to attach every token manually.
*/
let token = document.head.querySelector('meta[name="csrf-token"]')
if (token) {
window.axios.defaults.headers.common['X-CSRF-TOKEN'] = token.content
} else {
console.error('CSRF token not found: https://laravel.com/docs/csrf#csrf-x-csrf-token')
}
/**
* Echo exposes an expressive API for subscribing to channels and listening
* for events that are broadcast by Laravel. Echo and event broadcasting
* allows your team to easily build robust real-time web applications.
*/
// import Echo from 'laravel-echo'
// window.Pusher = require('pusher-js');
// window.Echo = new Echo({
// broadcaster: 'pusher',
// key: process.env.MIX_PUSHER_APP_KEY,
// cluster: process.env.MIX_PUSHER_APP_CLUSTER,
// encrypted: true
// });

@ -1,55 +0,0 @@
// window._ = require('lodash');
window.Popper = require('popper.js').default;
/**
* We'll load jQuery and the Bootstrap jQuery plugin which provides support
* for JavaScript based Bootstrap features such as modals and tabs. This
* code may be modified to fit the specific needs of your application.
*/
try {
window.$ = window.jQuery = require('jquery');
require('bootstrap');
} catch (e) {}
// /**
// * We'll load the axios HTTP library which allows us to easily issue requests
// * to our Laravel back-end. This library automatically handles sending the
// * CSRF token as a header based on the value of the "XSRF" token cookie.
// */
//
// window.axios = require('axios');
//
// window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
//
// /**
// * Next we will register the CSRF Token as a common header with Axios so that
// * all outgoing HTTP requests automatically have it attached. This is just
// * a simple convenience so we don't have to attach every token manually.
// */
//
// let token = document.head.querySelector('meta[name="csrf-token"]');
//
// if (token) {
// window.axios.defaults.headers.common['X-CSRF-TOKEN'] = token.content;
// } else {
// console.error('CSRF token not found: https://laravel.com/docs/csrf#csrf-x-csrf-token');
// }
/**
* Echo exposes an expressive API for subscribing to channels and listening
* for events that are broadcast by Laravel. Echo and event broadcasting
* allows your team to easily build robust real-time web applications.
*/
// import Echo from 'laravel-echo'
// window.Pusher = require('pusher-js');
// window.Echo = new Echo({
// broadcaster: 'pusher',
// key: process.env.MIX_PUSHER_APP_KEY,
// cluster: process.env.MIX_PUSHER_APP_CLUSTER,
// encrypted: true
// });

@ -1,12 +1,11 @@
<template>
<i :title="tooltipText">
<i :title="tooltipText" @click="$emit('click')">
<span class="sr-only" v-html=srHtml></span>
</i>
</template>
<script>
export default {
inheritAttrs: false,
props: {
alt: String,
srOnly: Boolean

@ -0,0 +1,160 @@
<template>
<table class="table table-hover table-sm table-fixed td-va-middle">
<thead>
<tr>
<th style="width:3rem" class="border-top-0"></th>
<th style="width:3rem" class="border-top-0"></th>
<th v-for="col in columns" :class="colClasses(col)" :title="col.name">{{col.title}}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in rows" :style="rowStyle(row)">
<td>
<a href="" :class="['btn','btn-outline-danger',{'active': row._remove}]"
@click.prevent="toggleRowDelete(row._id)">
<v-icon :class="row._remove ? 'fa-undo' : 'fa-trash-o'"
:alt="row._remove ? 'Undo Remove' : 'Remove'" />
</a>
</td>
<td>
<a href="" :class="['btn','btn-outline-secondary',{'disabled': row._remove}]"
@click.prevent="toggleRowEditing(row._id)">
<v-icon :class="row._editing ? 'fa-save' : 'fa-pencil'"
:alt="row._editing ? 'Save' : 'Edit'" />
</a>
</td>
<template v-if="row._editing">
<td v-for="col in columns" class="pr-0">
<input v-model="row[col.id]" :class="['form-control', { 'is-invalid': row._errors && row._errors[col.id] }]"
:title="(row._errors && row._errors[col.id]) ? row._errors[col.id][0] : null"
type="text" @keyup.enter="toggleRowEditing(row._id)">
</td>
</template>
<template v-else>
<td v-for="col in columns">
<span class="text-danger strike" title="Original value" v-if="isChanged(row, col.id)">{{row._orig[col.id]}}</span>
<span>{{ row[col.id] }}</span>
<v-icon v-if="isChanged(row, col.id)"
@click="revertCell(row, col.id)"
class="fa-undo text-danger pointer"
alt="Revert Change" />
</td>
</template>
</tr>
</tbody>
</table>
</template>
<style lang="scss" scoped>
@import "base";
</style>
<script>
export default {
props: {
route: String,
xRows: Object, // key'd by _id
columns: Array,
lastPage: Boolean,
},
data: function() {
return {
rows: this.xRows,
}
},
methods: {
busy (yes) {
$('#draft-busy').css('opacity', yes ? 1 : 0)
},
query (data, sucfn, erfn) {
this.busy(true)
if (!sucfn) sucfn = ()=>{}
if (!sucfn) erfn = ()=>{}
window.axios.post(this.route, data).then(sucfn).catch((error) => {
console.error(error.message)
erfn(error.response.data)
}).then(() => {
this.busy(false)
})
},
toggleRowDelete(_id) {
if (!_.isDefined(this.rows[_id])) return;
let remove = !this.rows[_id]._remove
this.query({
action: remove ? 'row.remove' : 'row.restore',
id: _id
}, (resp) => {
// if response is null, this was a New row
// and it was discarded without a way back - hard drop
if (_.isEmpty(resp.data)) {
this.$delete(this.rows, _id)
}
else {
this.$set(this.rows, _id, resp.data)
}
})
},
submitRowChange(row) {
this.query({
action: 'row.update',
data: row
}, (resp) => {
this.$set(this.rows, resp.data._id, resp.data);
}, (er) => {
if (!_.isUndefined(er.errors)) {
this.$set(this.rows[row._id], '_errors', er.errors);
}
})
},
toggleRowEditing(_id) {
if (this.rows[_id]._remove) return false; // can't edit row marked for removal
let editing = !this.rows[_id]._editing
if (!editing) {
this.submitRowChange(this.rows[_id])
} else {
this.$set(this.rows[_id], '_editing', true);
}
},
colClasses(col) {
return [
'border-top-0',
{
'text-danger': col._remove,
'strike': col._remove,
'text-success': col._new
}
]
},
rowStyle(row) {
return {
opacity: row._remove ? .8 : 1,
backgroundColor:
row._remove ? '#FFC4CC':
'transparent'
}
},
isChanged (row, colId) {
return row._changed && row._changed.indexOf(colId) > -1
},
revertCell(row, colId) {
this.submitRowChange(_.merge({}, row, { [colId]: row._orig[colId] }))
}
}
}
</script>

@ -0,0 +1,21 @@
// toggle collapse when clicked outside link, without drag
$(document)
.on('mousedown', '.block-collapse', function(e) {
let $bc = $(e.target).closest('.block-collapse')
$bc.data('mx', e.screenX)
$bc.data('my', e.screenY)
})
.on('mouseup', '.block-collapse', function(e) {
if (e.target.nodeName === 'A') return
let $bc = $(e.target).closest('.block-collapse')
if (typeof $bc.data('mx') !== 'undefined') {
let x0 = +$bc.data('mx')
let y0 = +$bc.data('my')
if (Math.abs(x0 - e.screenX) > 5 || Math.abs(y0 - e.screenY) > 5) {
// drag
} else {
$(e.target).closest('.block-collapse').toggleClass('reveal')
}
}
})

@ -0,0 +1,10 @@
$(function() {
// auto hide flash alerts
let $notifs = $('div.alert').not('.alert-important').addClass('fadeout')
setTimeout(() => {
$notifs.addClass('fade')
setTimeout(() => {
$notifs.addClass('hidden')
}, 500)
}, 2500)
})

@ -0,0 +1,19 @@
let url_slug = require('../lib/url-slug')
// auto-alias
$(document).on('input keypress paste keyup', 'input[data-autoalias]', function () {
const $this = $(this)
const target_name = $this.data('autoalias')
const delimiter = $this.data('aa-delimiter') || '_'
const new_alias = url_slug($this.val(), {'delimiter': delimiter})
const $target = $(`input[name="${target_name}"]`)
const lastset = $target.data('aa-last-set-val')
// 1. pick up, or 2. continue
if (new_alias === $target.val() || lastset === $target.val()) {
$target.val(new_alias)
$target.data('aa-last-set-val', new_alias)
}
})

@ -0,0 +1,12 @@
// subset of used lodash modules
export { default as each } from 'lodash/each'
export { default as isUndefined } from 'lodash/isUndefined'
export { default as merge } from 'lodash/merge'
export { default as unset } from 'lodash/unset'
export { default as isEmpty } from 'lodash/isEmpty'
function isDefined(x) {
return typeof(x) !== 'undefined';
}
export { isDefined }

@ -0,0 +1,27 @@
window.Vue = require('vue');
const ColumnEditorCtor = Vue.component('column-editor', require('./components/ColumnEditor.vue'));
const RowsEditorCtor = Vue.component('rows-editor', require('./components/RowsEditor.vue'));
const IconCtor = Vue.component('v-icon', require('./components/Icon.vue'));
// const app = new Vue({
// el: '#app'
// });
window.app = {
ColumnEditor: function(selector, data) {
new ColumnEditorCtor({
propsData: data
}).$mount(selector);
},
RowsEditor: function(selector, data) {
new RowsEditorCtor({
propsData: data
}).$mount(selector);
},
Icon: function(selector, data) {
new IconCtor({
propsData: data
}).$mount(selector);
}
}

@ -16,3 +16,20 @@
.box-shadow {
box-shadow: 0 2px 3px rgba(black, .3);
}
.strike {
text-decoration: line-through;
}
// for busy loaders etc
.opacity-fade {
transition: opacity .3s ease-in-out;
}
.pointer {
cursor: pointer;
}
.noscript-hide {
display: none;
}

@ -20,6 +20,8 @@ html {
@import "bootstrap-customizations/button";
@import "bootstrap-customizations/responsive";
@import "bootstrap-customizations/typography";
@import "bootstrap-customizations/nav";
@import "bootstrap-customizations/table";
@import "infobox";

@ -15,3 +15,23 @@
.border-2 {
border-width: 2px !important;
}
.rounded-top-0 {
border-top-left-radius: 0 !important;
border-top-right-radius: 0 !important;
}
.rounded-left-0 {
border-top-left-radius: 0 !important;
border-bottom-left-radius: 0 !important;
}
.rounded-right-0 {
border-top-right-radius: 0 !important;
border-bottom-right-radius: 0 !important;
}
.rounded-bottom-0 {
border-bottom-left-radius: 0 !important;
border-bottom-right-radius: 0 !important;
}

@ -1,5 +1,5 @@
@media (max-width:767px) {
.mobile-only {
.no-mobile {
display: none;
}

@ -0,0 +1,9 @@
.table-fixed {
table-layout: fixed;
}
.td-va-middle {
td, th {
vertical-align: middle !important;
}
}

@ -12,6 +12,16 @@
<!-- Scripts -->
<script src="{{ asset('js/app.js') }}" defer></script>
<script>
function ready(fn) {
document.addEventListener('DOMContentLoaded', fn);
}
ready(function() {
$('noscript').remove();
$('.noscript-hide').removeClass('noscript-hide');
});
</script>
@stack('scripts')
<!-- Styles -->
<link

@ -0,0 +1,13 @@
<li class="nav-item">
<a class="nav-link" href="{{ route('login') }}">
@icon(fa-sign-in pr-1)
{{ __('Login') }}
</a>
@if(config('app.allow_regs'))
<li class="nav-item">
<a class="nav-link" href="{{ route('register') }}">
@icon(fa-user-plus pr-1)
{{ __('Register') }}
</a>
@endif

@ -16,36 +16,23 @@
--}}datatable.directory
</a>
<button class="navbar-toggler" type="button" data-toggle="collapse"
<button class="navbar-toggler noscript-hide" type="button" data-toggle="collapse"
data-target="#mainNavContent" aria-controls="mainNavContent"
aria-expanded="false" aria-label="{{ __('Toggle navigation') }}">
<span class="sr-only">Toggle navigation</span>
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="mainNavContent">
<div class="collapse navbar-collapse noscript-hide" id="mainNavContent">
<ul class="navbar-nav ml-auto">
<!-- Authentication Links -->
@guest()
<li class="nav-item">
<a class="nav-link" href="{{ route('login') }}">
@icon(fa-sign-in pr-1)
{{ __('Login') }}
</a>
@if(config('app.allow_regs'))
<li class="nav-item">
<a class="nav-link" href="{{ route('register') }}">
@icon(fa-user-plus pr-1)
{{ __('Register') }}
</a>
@endif
@include('layouts.guest-buttons')
@else
{{-- Logged in --}}
@include('layouts.nav-buttons', ['dropdown' => false])
<li class="nav-item dropdown mobile-only">
<li class="nav-item dropdown no-mobile">
<a id="mainNavDropdown" class="nav-link dropdown-toggle" href="#" role="button" data-toggle="dropdown"
aria-haspopup="true" aria-expanded="false" v-pre>
@icon(fa-user-circle-o fa-pr){{--
@ -63,5 +50,15 @@
@endif
</ul>
</div>
<noscript>
<ul class="navbar-nav ml-auto">
@guest
@include('layouts.guest-buttons')
@else
@include('layouts.nav-buttons', ['dropdown' => false, 'noscript' => true])
@endif
</ul>
</noscript>
</div>
</nav>

@ -1,6 +1,7 @@
@php
$li = $dropdown ? '' : '<li class="nav-item ml-1 d-md-none">';
$dmdhide = isset($noscript)?'':'d-md-none';
$li = $dropdown ? '' : "<li class=\"nav-item ml-1 $dmdhide\">";
$aclass = $dropdown ? 'dropdown-item' : 'nav-link';
@endphp

@ -4,24 +4,25 @@
@php
/** @var object[] $columns */
/** @var \App\Tables\Changeset[] $changeset */
/** @var \App\Models\Row[] $rows */
@endphp
<table class="table table-hover table-sm">
<thead>
<tr>
<th>_id</th>
<th>#</th>
@foreach($columns as $col)
<th>{{$col->name}} ("{{ $col->title }}") [ {{$col->id}} ]</th>
<th title="{{$col->name}}">{{ $col->title }}</th>
@endforeach
</tr>
</thead>
<tbody>
@foreach($rows as $row)
<tr>
<td>{{ $row->_id }}</td>
<td>{{$row->_id}}</td>
@foreach($columns as $col)
<td data-id="{{ $row->_id }}">{{ $row->{$col->name} }}</td>
<td>{{ $row->{$col->name} }}</td>
@endforeach
</tr>
@endforeach

@ -9,21 +9,17 @@
<nav aria-label="Table action buttons">
@sr(Table actions)
{{-- Disabled until implemented --}}
@if(guest() || !user()->confirmed || user()->ownsTable($table))
{{-- Guest, unconfirmed, or a table owner --}}
{{-- Passive fork buttons with counter --}}
<a href="" class="btn btn-outline-primary py-1 btn-sm" title="Forks"
data-toggle="tooltip" data-placement="top">
<a href="" class="btn btn-outline-primary py-1 btn-sm" @tooltip(Forks)>
@icon(fa-code-fork, sr:Forks)&nbsp;
{{ $table->forks_count ?: '–' }}
</a>
{{-- Passive favourite buttons with counter --}}
<a href="" class="btn btn-outline-primary py-1 btn-sm"
title="Favourites" data-toggle="tooltip" data-placement="top">
<a href="" class="btn btn-outline-primary py-1 btn-sm" @tooltip(Favourites)>
@icon(fa-star, sr:Favourites)&nbsp;
{{ $table->favourites_count ?: '–' }}
</a>
@ -33,12 +29,10 @@
{{-- Active fork button | counter --}}
<div class="btn-group" role="group" aria-label="Fork">
<a href="" class="btn btn-outline-primary py-1 btn-sm btn-square" title="Fork"
data-toggle="tooltip" data-placement="top">
<a href="" class="btn btn-outline-primary py-1 btn-sm btn-square" @tooltip(Fork)>
@icon(fa-code-fork, sr:Fork)
</a>
<a href="" class="btn btn-outline-primary py-1 btn-sm" title="Fork Count"
data-toggle="tooltip" data-placement="top">
<a href="" class="btn btn-outline-primary py-1 btn-sm" @tooltip(Fork Count)>
{{ $table->forks_count ?: '–' }}
</a>
</div>
@ -46,18 +40,15 @@
{{-- Active favourite button | counter --}}
<div class="btn-group" role="group" aria-label="Favourite">
@if(user()->favouritesTable($table))
<a href="" class="btn btn-outline-primary py-1 btn-sm btn-square" title="Un-favourite"
data-toggle="tooltip" data-placement="top">
<a href="" class="btn btn-outline-primary py-1 btn-sm btn-square" @tooltip(Un-favourite)>
@icon(fa-star, sr:Un-favourite)
</a>
@else
<a href="" class="btn btn-outline-primary py-1 btn-sm btn-square" title="Favourite"
data-toggle="tooltip" data-placement="top">
<a href="" class="btn btn-outline-primary py-1 btn-sm btn-square" @tooltip(Favourite)>
@icon(fa-star-o, sr:Favourite)
</a>
@endif
<a href="" class="btn btn-outline-primary py-1 btn-sm" title="Favourite Count"
data-toggle="tooltip" data-placement="top">
<a href="" class="btn btn-outline-primary py-1 btn-sm" @tooltip(Favourite Count)>
{{ $table->favourites_count ?: '–' }}
</a>
</div>
@ -65,29 +56,25 @@
@endif
{{-- Comments button with counter --}}
<a href="" class="btn btn-outline-primary py-1 btn-sm" title="Comments"
data-toggle="tooltip" data-placement="top">
<a href="" class="btn btn-outline-primary py-1 btn-sm" @tooltip(Comments)>
@icon(fa-comment, sr:Comments)&nbsp;
{{ $table->comments_count ?: '–' }}
</a>
{{-- Active proposals button | counter --}}
<div class="btn-group" role="group" aria-label="Fork">
<a href="" class="btn btn-outline-primary py-1 btn-sm" title="Change Proposals"
data-toggle="tooltip" data-placement="top">
<a href="" class="btn btn-outline-primary py-1 btn-sm" @tooltip(Change Proposals)>
@icon(fa-inbox fa-pr, sr:Change Proposals){{ $table->proposals_count ?: '–' }}
</a>
@auth
@if(user()->ownsTable($table))
{{-- Table owner logged in --}}
<a href="" class="btn btn-outline-primary py-1 btn-sm btn-square" title="Draft Change"
data-toggle="tooltip" data-placement="top">
<a href="{{ $table->draftRoute }}" class="btn btn-outline-primary py-1 btn-sm btn-square" @tooltip(Draft Change)>
@icon(fa-pencil, sr:Draft Change)
</a>
@else
{{-- Not a table owner --}}
<a href="" class="btn btn-outline-primary py-1 btn-sm btn-square" title="Propose Change"
data-toggle="tooltip" data-placement="top">
<a href="{{ $table->draftRoute }}" class="btn btn-outline-primary py-1 btn-sm btn-square" @tooltip(Propose Change)>
@icon(fa-pencil, sr:Propose Change)
</a>
@endif
@ -96,9 +83,8 @@
@if(authed() && user()->ownsTable($table))
{{-- Table opts menu for table owner --}}
<a href="{{ $table->settingsRoute }}" class="btn btn-outline-primary py-1 btn-sm"
title="Table Options" data-toggle="tooltip" data-placement="top">
<a href="{{ $table->settingsRoute }}" class="btn btn-outline-primary py-1 btn-sm" @tooltip(Table Options)>
@icon(fa-wrench, sr:Table Options)
</a>
@endif
@endif
</nav>

@ -17,8 +17,7 @@
<h1 class="mx-3">{{ $table->title }}</h1>
<a href="{{ $table->viewRoute }}" class="btn btn-outline-primary py-1 btn-sm"
title="Back to Table" data-toggle="tooltip" data-placement="top">
<a href="{{ $table->viewRoute }}" class="btn btn-outline-primary py-1 btn-sm" @tooltip(Back to Table)>
@icon(fa-table, sr:Back to Table)
</a>
</div>

@ -30,23 +30,12 @@
->help('If you took the data from some external site, a book, etc., write it here.
URLs in a full format will be clickable.') !!}
{{--!! Widget::textarea('columns', 'Columns')->value($exampleColumns)->height('8em')
->help('
<div class="text-left">
Column parameters in CSV format:
<ul class="pl-3 mb-0">
<li><b>column identifier</b><br>letters, numbers, underscore
<li><b>column data type</b><br>int, string, float, bool
<li><b>column title</b><br>used for display (optional)
</ul>
</div>') !!--}}
<div class="row form-group">
<label for="field-columns" class="col-md-3 col-form-label text-md-right">
Columns
</label>
<div class="col-md-8">
<column-editor name="columns" :initial-columns="{{old('columns', toJSON($columns))}}"></column-editor>
<div id="column-editor"></div>
<noscript>
You have JavaScript disabled; enter columns as JSON array<br>
@ -84,3 +73,14 @@
</div>
</form>
@endsection
@push('scripts')
<script>
ready(function() {
app.ColumnEditor('#column-editor', {
name: 'columns',
initialColumns: {!! old('columns', toJSON($columns)) !!},
})
});
</script>
@endpush

@ -0,0 +1,52 @@
@php
$tab = 'add-rows';
/** @var \App\Tables\Column[] $columns */
/** @var \App\Tables\Changeset $changeset */
/** @var \App\Models\Row[]|Illuminate\Pagination\Paginator $rows */
/** @var \App\Models\Table $table */
@endphp
@extends('table.propose.layout-row-pagination')
@section('header')
<div class="form-inline py-2 px-1 border-bottom mb-3">
{{-- TODO improve layout --}}
<form action="{{$table->draftUpdateRoute}}" method="POST" class="form-inline">
@csrf
<input type="hidden" name="action" value="rows.add-csv">
<input type="hidden" name="redirect" value="{{request()->fullUrl()}}">
<label class="pr-2" for="csv-data">Add CSV:</label>
<textarea name="data" id="csv-data"
title="{{$errors->has('data') ? $errors->first('data') : ''}}"
class="form-control mr-1 {{ $errors->has('data')?'is-invalid':'' }}"
style="width:30em; height:10em">{{old('data')}}</textarea>
<button class="btn btn-outline-success">Append</button>
</form>
<div class="flex-grow-1"></div>
<form action="{{$table->draftUpdateRoute}}" method="POST">
@csrf
<input type="hidden" name="action" value="rows.add">
<input type="hidden" name="redirect" value="{{request()->fullUrl()}}">
<label class="form-inline pr-2" for="newrow-count">Add Empty Rows:</label>
<input name="count" id="newrow-count" type="number" min=1 step=1 value=1 class="form-control mr-1" style="width:10em">
<button class="btn btn-outline-success">Add</button>
</form>
</div>
@stop
@section('rows')
<div id="rows-editor"></div>
@stop
@push('scripts')
<script>
ready(function() {
app.RowsEditor('#rows-editor', {
route: {!! toJSON($table->draftUpdateRoute) !!},
columns: {!! toJSON($columns) !!},
xRows: {!! toJSON($rows->keyBy('_id'), true) !!},
})
});
</script>
@endpush

@ -0,0 +1,32 @@
@php
$tab = 'edit-rows';
/** @var \App\Tables\Column[] $columns */
/** @var \App\Tables\Changeset $changeset */
/** @var \App\Models\Row[]|Illuminate\Pagination\Paginator $rows */
/** @var \App\Models\Table $table */
@endphp
@extends('table.propose.layout-row-pagination')
@section('rows')
@php
$transformed = $rows->keyBy('_id')->map(function($r) use ($changeset) {
/** @var \App\Tables\Changeset $changeset */
return $changeset->transformRow($r, true);
});
@endphp
<div id="rows-editor"></div>
@stop
@push('scripts')
<script>
ready(function() {
app.RowsEditor('#rows-editor', {
route: {!! toJSON($table->draftUpdateRoute) !!},
columns: {!! toJSON($columns) !!},
xRows: {!! toJSON($transformed, true) !!},
})
});
</script>
@endpush

@ -0,0 +1,31 @@
@php
/** @var \App\Models\Row[]|Illuminate\Pagination\Paginator $rows */
@endphp
@extends('table.propose.layout')
@section('tab-content')
<div class="col-12">
@yield('header')
</div>
@if($rows->hasPages())
<div class="col-md-12 d-flex">
<nav class="text-center" aria-label="Pages of the table">
{{ $rows->links(null, ['ulClass' => 'mb-0']) }}
</nav>
</div>
@endif
<div class="col-12">
@yield('rows')
</div>
@if($rows->hasPages())
<div class="col-md-12 d-flex">
<nav class="text-center" aria-label="Pages of the table">
{{ $rows->links(null, ['ulClass' => 'mb-0']) }}
</nav>
</div>
@endif
@stop

@ -0,0 +1,60 @@
{{-- Basic table view --}}
@extends('layouts.app')
@php
/** @var \App\Models\Table $table */
if (!isset($tab) || $tab == '') $tab = 'edit-rows';
@endphp
@section('content')
<div class="row justify-content-start px-3">
<div class="d-flex w-100 align-items-center">
<small class="flex-grow-1" style="font-size: 120%;">
<a href="{{ route('profile.view', $table->owner->name) }}" class="link-no-color">{{ $table->owner->handle }}</a>{{--
--}}<span class="px-1">/</span>{{--
--}}<b><a href="{{ $table->viewRoute }}" class="link-no-color">{{ $table->name }}</a></b>
</small>
<h1 class="mx-3">
<a href="{{ $table->viewRoute }}" class="link-no-color">{{ $table->title }}</a>
</h1>
<a href="{{$table->draftDiscardRoute}}" class="btn btn-outline-danger mr-2" @tooltip(Discard changes)>
@icon(fa-trash-o, sr:Discard)
</a>
@if(user()->ownsTable($table))
<a href="" class="btn btn-outline-success" @tooltip(Save the changes and apply them as a new table revision)>
@icon(fa-save fa-pr)Commit
</a>
@else
<a href="" class="btn btn-outline-success" @tooltip(Submit your changes for review by the table owner)>
@icon(fa-save fa-pr)Submit
</a>
@endif
</div>
</div>
<ul class="nav nav-tabs">
<li class="nav-item">
<a class="nav-link{{ $tab=='edit-rows'?' active':'' }}" href="{{ $table->getDraftRoute('edit-rows') }}">Edit Rows</a>
</li>
<li class="nav-item">
<a class="nav-link{{ $tab=='add-rows'?' active':'' }}" href="{{ $table->getDraftRoute('add-rows') }}">Add Rows</a>
</li>
<li class="nav-item">
<a class="nav-link{{ $tab=='manage-columns'?' active':'' }}" href="{{ $table->getDraftRoute('manage-columns') }}">Columns</a>
</li>
<li class="nav-item">
<a class="nav-link{{ $tab=='review'?' active':'' }}" href="{{ $table->getDraftRoute('review') }}">Note & Review</a>
</li>
<li class="nav-item ml-auto pt-2 pr-2 opacity-fade" style="opacity:0" id="draft-busy">
@icon(fa-hourglass, Working...)
</li>
</ul>
<div class="row justify-content-center mb-2">
@yield('tab-content')
</div>{{-- End of row --}}
@endsection

@ -0,0 +1,6 @@
@php($tab='manage-columns')
@extends('table.propose.layout')
@section('tab-content')
...
@stop

@ -0,0 +1,6 @@
@php($tab='review')
@extends('table.propose.layout')
@section('tab-content')
...
@stop

@ -17,7 +17,7 @@
<h1 class="mx-3">{{ $table->title }}</h1>
@include('table._action-buttons')
@include('table._view-action-buttons')
</div>
</div>

@ -49,6 +49,10 @@ Route::group(['middleware' => ['auth', 'activated']], function () {
Route::get('@{user}/{table}/settings', 'TableController@settings')->name('table.conf');
Route::post('@{user}/{table}/settings', 'TableController@storeSettings')->name('table.storeConf');
Route::post('@{user}/{table}/delete', 'TableController@delete')->name('table.delete');
Route::post('@{user}/{table}/draft/update', 'TableEditController@draftUpdate')->name('table.draft-update');
Route::get('@{user}/{table}/draft/discard', 'TableEditController@discard')->name('table.draft-discard');
Route::get('@{user}/{table}/draft/{tab?}', 'TableEditController@draft')->name('table.draft');
});
// Routes for all authed users

20
webpack.mix.js vendored

@ -1,5 +1,6 @@
let mix = require('laravel-mix');
let webpack = require('webpack');
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
/*
|--------------------------------------------------------------------------
@ -12,20 +13,27 @@ let webpack = require('webpack');
|
*/
let plugins = [
new webpack.ProvidePlugin({
'$': 'jquery',
jQuery: 'jquery',
}),
]
if (process.env.WP_ANALYZE) {
plugins.push(new BundleAnalyzerPlugin())
}
mix.webpackConfig({
// Force jquery slim - https://code.luasoftware.com/tutorials/webpack/force-jquery-slim-import-in-webpack/
resolve: {
extensions: ['.js'],
alias: {
'jquery': 'jquery/dist/jquery.slim.js',
'vue$': 'vue/dist/vue.runtime.js',
}
},
plugins: [
new webpack.ProvidePlugin({
'$': 'jquery',
jQuery: 'jquery',
}),
],
plugins,
});
mix.js('resources/assets/js/app.js', 'public/js')

Loading…
Cancel
Save