Browse Source

Merge branch 'editing', split darkmode to own file

pull/27/head
Ondřej Hruška 4 years ago
parent
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

12
Makefile

@ -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

18
_json_typehints.php

@ -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 {}

3
app/Exceptions/Handler.php

@ -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,
];
/**

14
app/Exceptions/ViewException.php

@ -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);
}
}

6
app/Http/Controllers/Controller.php

@ -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())

33
app/Http/Controllers/TableController.php

@ -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()]);
}

174
app/Http/Controllers/TableEditController.php

@ -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);
}
}

4
app/Models/BaseModel.php

@ -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);

60
app/Models/Proposal.php

@ -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(),
]);
}
}

4
app/Models/Revision.php

@ -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));

2
app/Models/Row.php

@ -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
{

38
app/Models/Table.php

@ -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')

11
app/Providers/AppServiceProvider.php

@ -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) {

76
app/Tables/BaseNumerator.php

@ -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();
}
}
}

467
app/Tables/Changeset.php

@ -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());
}
}

114
app/Tables/Column.php

@ -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:

25
app/Tables/ColumnNumerator.php

@ -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);
}
}

19
app/Tables/RowNumerator.php

@ -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));
}
}

37
app/helpers.php

@ -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()]
);
}

1
config/app.php

@ -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,

201
config/debugbar.php

@ -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
|
*/