diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..72f3b85 --- /dev/null +++ b/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 diff --git a/_json_typehints.php b/_json_typehints.php new file mode 100644 index 0000000..6565ac6 --- /dev/null +++ b/_json_typehints.php @@ -0,0 +1,18 @@ +captured = $captured; - parent::__construct($cause); - } -} diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php index e05d058..383db2b 100644 --- a/app/Http/Controllers/Controller.php +++ b/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()) diff --git a/app/Http/Controllers/TableController.php b/app/Http/Controllers/TableController.php index 8f84bcf..bdfa575 100644 --- a/app/Http/Controllers/TableController.php +++ b/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()]); } diff --git a/app/Http/Controllers/TableEditController.php b/app/Http/Controllers/TableEditController.php new file mode 100644 index 0000000..db55166 --- /dev/null +++ b/app/Http/Controllers/TableEditController.php @@ -0,0 +1,174 @@ +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); + } +} diff --git a/app/Models/BaseModel.php b/app/Models/BaseModel.php index 8561357..a7fb450 100644 --- a/app/Models/BaseModel.php +++ b/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); diff --git a/app/Models/Proposal.php b/app/Models/Proposal.php index f746405..a56f313 100644 --- a/app/Models/Proposal.php +++ b/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(), + ]); + } } diff --git a/app/Models/Revision.php b/app/Models/Revision.php index b140623..550c31d 100644 --- a/app/Models/Revision.php +++ b/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)); diff --git a/app/Models/Row.php b/app/Models/Row.php index 1b7220c..ca00961 100644 --- a/app/Models/Row.php +++ b/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 { diff --git a/app/Models/Table.php b/app/Models/Table.php index 454d121..381cd3f 100644 --- a/app/Models/Table.php +++ b/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') diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 44d8fb8..d01dda5 100644 --- a/app/Providers/AppServiceProvider.php +++ b/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) { diff --git a/app/Tables/BaseNumerator.php b/app/Tables/BaseNumerator.php new file mode 100644 index 0000000..e94cb41 --- /dev/null +++ b/app/Tables/BaseNumerator.php @@ -0,0 +1,76 @@ +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(); + } + } +} diff --git a/app/Tables/Changeset.php b/app/Tables/Changeset.php new file mode 100644 index 0000000..7e4fbdf --- /dev/null +++ b/app/Tables/Changeset.php @@ -0,0 +1,467 @@ + [_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()); + } +} diff --git a/app/Tables/Column.php b/app/Tables/Column.php index 7be9660..4c24329 100644 --- a/app/Tables/Column.php +++ b/app/Tables/Column.php @@ -1,32 +1,105 @@ 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: diff --git a/app/Tables/ColumnNumerator.php b/app/Tables/ColumnNumerator.php index e053192..bde16fd 100644 --- a/app/Tables/ColumnNumerator.php +++ b/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); } } diff --git a/app/Tables/RowNumerator.php b/app/Tables/RowNumerator.php index dde4484..c493f1b 100644 --- a/app/Tables/RowNumerator.php +++ b/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)); } } diff --git a/app/helpers.php b/app/helpers.php index 002aed8..87537e7 100644 --- a/app/helpers.php +++ b/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()] + ); +} diff --git a/config/app.php b/config/app.php index 692b7d2..7f8021b 100644 --- a/config/app.php +++ b/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, diff --git a/config/debugbar.php b/config/debugbar.php new file mode 100644 index 0000000..8350e1c --- /dev/null +++ b/config/debugbar.php @@ -0,0 +1,201 @@ + 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 , 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, +]; diff --git a/package-lock.json b/package-lock.json index e051297..0ec9888 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index e81ab87..172d403 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/porklib/Exceptions/SimpleValidationException.php b/porklib/Exceptions/SimpleValidationException.php new file mode 100644 index 0000000..9e51f9e --- /dev/null +++ b/porklib/Exceptions/SimpleValidationException.php @@ -0,0 +1,46 @@ +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; + } +} diff --git a/porklib/Utils/Utils.php b/porklib/Utils/Utils.php index 6e1e97d..7b2789f 100644 --- a/porklib/Utils/Utils.php +++ b/porklib/Utils/Utils.php @@ -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)); + } } diff --git a/porklib/helpers.php b/porklib/helpers.php index 8134d80..77368bb 100644 --- a/porklib/helpers.php +++ b/porklib/helpers.php @@ -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 json_encode, - * 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 json_encode, + * 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); } /** diff --git a/resources/assets/js/app.js b/resources/assets/js/app.js index 155c2be..ebc08c1 100644 --- a/resources/assets/js/app.js +++ b/resources/assets/js/app.js @@ -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' -}); diff --git a/resources/assets/js/base-setup.js b/resources/assets/js/base-setup.js new file mode 100644 index 0000000..8f32b5a --- /dev/null +++ b/resources/assets/js/base-setup.js @@ -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 +// }); diff --git a/resources/assets/js/bootstrap.js b/resources/assets/js/bootstrap.js deleted file mode 100644 index c71b30a..0000000 --- a/resources/assets/js/bootstrap.js +++ /dev/null @@ -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 -// }); diff --git a/resources/assets/js/components/Icon.vue b/resources/assets/js/components/Icon.vue index d69f391..f4950b2 100644 --- a/resources/assets/js/components/Icon.vue +++ b/resources/assets/js/components/Icon.vue @@ -1,12 +1,11 @@ diff --git a/resources/assets/js/url-slug.js b/resources/assets/js/lib/url-slug.js similarity index 100% rename from resources/assets/js/url-slug.js rename to resources/assets/js/lib/url-slug.js diff --git a/resources/assets/js/modules/block-collapse.js b/resources/assets/js/modules/block-collapse.js new file mode 100644 index 0000000..27ff883 --- /dev/null +++ b/resources/assets/js/modules/block-collapse.js @@ -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') + } + } + }) diff --git a/resources/assets/js/modules/flash-messages.js b/resources/assets/js/modules/flash-messages.js new file mode 100644 index 0000000..6ab01e0 --- /dev/null +++ b/resources/assets/js/modules/flash-messages.js @@ -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) +}) diff --git a/resources/assets/js/modules/form-autoalias.js b/resources/assets/js/modules/form-autoalias.js new file mode 100644 index 0000000..fcb4bf9 --- /dev/null +++ b/resources/assets/js/modules/form-autoalias.js @@ -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) + } +}) diff --git a/resources/assets/js/udash.js b/resources/assets/js/udash.js new file mode 100644 index 0000000..747b133 --- /dev/null +++ b/resources/assets/js/udash.js @@ -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 } diff --git a/resources/assets/js/vue-init.js b/resources/assets/js/vue-init.js new file mode 100644 index 0000000..5543a57 --- /dev/null +++ b/resources/assets/js/vue-init.js @@ -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); + } +} diff --git a/resources/assets/sass/_helpers.scss b/resources/assets/sass/_helpers.scss index 42e4cff..520a710 100644 --- a/resources/assets/sass/_helpers.scss +++ b/resources/assets/sass/_helpers.scss @@ -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; +} diff --git a/resources/assets/sass/app.scss b/resources/assets/sass/app.scss index f140f5f..7974758 100644 --- a/resources/assets/sass/app.scss +++ b/resources/assets/sass/app.scss @@ -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"; diff --git a/resources/assets/sass/bootstrap-customizations/_border.scss b/resources/assets/sass/bootstrap-customizations/_border.scss index 67bb404..25ccb19 100644 --- a/resources/assets/sass/bootstrap-customizations/_border.scss +++ b/resources/assets/sass/bootstrap-customizations/_border.scss @@ -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; +} diff --git a/resources/assets/sass/bootstrap-customizations/_nav.scss b/resources/assets/sass/bootstrap-customizations/_nav.scss new file mode 100644 index 0000000..e69de29 diff --git a/resources/assets/sass/bootstrap-customizations/_responsive.scss b/resources/assets/sass/bootstrap-customizations/_responsive.scss index 7f74e12..584f7f4 100644 --- a/resources/assets/sass/bootstrap-customizations/_responsive.scss +++ b/resources/assets/sass/bootstrap-customizations/_responsive.scss @@ -1,5 +1,5 @@ @media (max-width:767px) { - .mobile-only { + .no-mobile { display: none; } diff --git a/resources/assets/sass/bootstrap-customizations/_table.scss b/resources/assets/sass/bootstrap-customizations/_table.scss new file mode 100644 index 0000000..532da54 --- /dev/null +++ b/resources/assets/sass/bootstrap-customizations/_table.scss @@ -0,0 +1,9 @@ +.table-fixed { + table-layout: fixed; +} + +.td-va-middle { + td, th { + vertical-align: middle !important; + } +} diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index 9a53fe7..058e577 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -12,6 +12,16 @@ + + @stack('scripts') + + @icon(fa-sign-in pr-1) + {{ __('Login') }} + + +@if(config('app.allow_regs')) +