feat: Add export import

This commit is contained in:
Attila Kerekes 2022-11-26 14:35:36 +01:00 committed by Attila Kerekes
parent 2ee5d07e48
commit bb5a078f35
20 changed files with 505 additions and 21 deletions

View file

@ -0,0 +1,31 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\View\View;
class ImportController extends Controller
{
/**
* Instantiate a new controller instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
$this->middleware('allowed');
}
/**
* Handle the incoming request.
*
* @param Request $request
* @return View
*/
public function __invoke(Request $request): View
{
return view('items.import');
}
}

View file

@ -12,9 +12,9 @@ use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Exception\GuzzleException; use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Exception\ServerException; use GuzzleHttp\Exception\ServerException;
use Illuminate\Contracts\View\View; use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Routing\Redirector;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\URL; use Illuminate\Support\Facades\URL;
@ -191,10 +191,10 @@ class ItemController extends Controller
/** /**
* @param Request $request * @param Request $request
* @param $id * @param null $id
* @return void * @return Item
*/ */
public function storelogic(Request $request, $id = null) public static function storelogic(Request $request, $id = null): Item
{ {
$application = Application::single($request->input('appid')); $application = Application::single($request->input('appid'));
$validatedData = $request->validate([ $validatedData = $request->validate([
@ -275,6 +275,7 @@ class ItemController extends Controller
} }
$item->parents()->sync($request->tags); $item->parents()->sync($request->tags);
return $item;
} }
/** /**
@ -285,7 +286,7 @@ class ItemController extends Controller
*/ */
public function store(Request $request): RedirectResponse public function store(Request $request): RedirectResponse
{ {
$this->storelogic($request); self::storelogic($request);
$route = route('dash', []); $route = route('dash', []);
@ -313,7 +314,7 @@ class ItemController extends Controller
*/ */
public function update(Request $request, int $id): RedirectResponse public function update(Request $request, int $id): RedirectResponse
{ {
$this->storelogic($request, $id); self::storelogic($request, $id);
$route = route('dash', []); $route = route('dash', []);
return redirect($route) return redirect($route)

View file

@ -0,0 +1,111 @@
<?php
namespace App\Http\Controllers;
use App\Item;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class ItemRestController extends Controller
{
public function __construct()
{
parent::__construct();
$this->middleware('allowed');
}
/**
* Display a listing of the resource.
*
* @return Response
*/
public function index()
{
$columns = [
'title',
'colour',
'url',
'description',
'appid',
'appdescription',
];
return Item::select($columns)
->where('deleted_at', null)
->where('type', '0')
->orderBy('order', 'asc')
->get();
}
/**
* Show the form for creating a new resource.
*
* @return Response
*/
public function create()
{
}
/**
* Store a newly created resource in storage.
*
* @param Request $request
* @return object
*/
public function store(Request $request): object
{
$item = ItemController::storelogic($request);
if ($item) {
return (object) ['status' => 'OK'];
}
return (object) ['status' => 'FAILED'];
}
/**
* Display the specified resource.
*
* @param Item $item
* @return Response
*/
public function show(Item $item)
{
//
}
/**
* Show the form for editing the specified resource.
*
* @param Item $item
* @return Response
*/
public function edit(Item $item)
{
//
}
/**
* Update the specified resource in storage.
*
* @param Request $request
* @param Item $item
* @return Response
*/
public function update(Request $request, Item $item)
{
//
}
/**
* Remove the specified resource from storage.
*
* @param Item $item
* @return Response
*/
public function destroy(Item $item)
{
//
}
}

View file

@ -4,6 +4,7 @@ namespace App;
use Illuminate\Contracts\Routing\UrlGenerator; use Illuminate\Contracts\Routing\UrlGenerator;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
@ -15,6 +16,8 @@ class Item extends Model
{ {
use SoftDeletes; use SoftDeletes;
use HasFactory;
/** /**
* @return void * @return void
*/ */

View file

@ -0,0 +1,30 @@
<?php
namespace Database\Factories;
use App\Item;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
class ItemFactory extends Factory
{
/**
* The name of the factory's corresponding model.
*
* @var string
*/
protected $model = Item::class;
/**
* Define the model's default state.
*
* @return array
*/
public function definition()
{
return [
'title' => $this->faker->unique()->text(),
'url' => $this->faker->unique()->url(),
];
}
}

2
public/js/app.js vendored

File diff suppressed because one or more lines are too long

View file

@ -1,4 +1,4 @@
{ {
"/css/app.css": "/css/app.css?id=9a25947db63214edd4e6f459200dfa62", "/css/app.css": "/css/app.css?id=9a25947db63214edd4e6f459200dfa62",
"/js/app.js": "/js/app.js?id=894c631b0c521ca3e5df669b4220f77b" "/js/app.js": "/js/app.js?id=50647209eddf7eb990cfe03fcc6652ed"
} }

48
resources/assets/js/itemExport.js vendored Normal file
View file

@ -0,0 +1,48 @@
const EXPORT_FILE_NAME = "HeimdallExport.json";
const EXPORT_API_URL = "api/item";
/**
*
* @param {string} fileName
* @param {string} data
*/
function triggerFileDownload(fileName, data) {
const a = document.createElement("a");
const file = new Blob([data], {
type: "text/plain",
});
a.href = URL.createObjectURL(file);
a.download = EXPORT_FILE_NAME;
a.click();
}
/**
*
* @param {Event} event
*/
const exportItems = (event) => {
event.preventDefault();
fetch(EXPORT_API_URL)
.then((response) => {
if (response.status !== 200) {
window.alert("An error occurred while exporting...");
}
return response.json();
})
.then((data) => {
const exportedJson = JSON.stringify(data, null, 2);
triggerFileDownload(EXPORT_FILE_NAME, exportedJson);
});
};
const exportButton = document.querySelector("#item-export");
if (exportButton) {
exportButton.addEventListener("click", exportItems);
}

128
resources/assets/js/itemImport.js vendored Normal file
View file

@ -0,0 +1,128 @@
const IMPORT_API_URL = "api/item";
const APP_LOAD_URL = "appload";
/**
*
* @param {string|null} appId
* @returns {Promise<{}>|Promise<any>}
*/
const fetchAppDetails = (appId) => {
if (appId === null) {
return Promise.resolve({});
}
return fetch(APP_LOAD_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ app: appId }),
})
.then((response) => response.json())
.catch(() => ({}));
};
/**
*
* @returns {string}
*/
const getCSRFToken = () => {
const tokenSelector = 'input[name="_token"]';
return document.querySelector(tokenSelector).value;
};
/**
*
* @param {object} data
* @param {string} csrfToken
*/
const postToApi = (data, csrfToken) =>
fetch(IMPORT_API_URL, {
method: "POST",
cache: "no-cache",
redirect: "follow",
headers: {
"Content-Type": "application/json",
"X-CSRF-TOKEN": csrfToken,
},
body: JSON.stringify(data),
});
/**
*
* @param {object} item
* @param {object} appDetails
* @returns {undefined}
*/
const mergeItemWithAppDetails = (item, appDetails) => ({
pinned: 1,
tags: [0],
appid: item.appid,
title: item.title,
colour: item.colour,
url: item.url,
appdescription: item.appdescription
? item.appdescription
: appDetails.description,
website: appDetails.website,
icon: appDetails.iconview,
config: item.description ? JSON.parse(item.description) : null,
});
/**
*
* @param {array} items
*/
const importItems = (items) => {
items.forEach((item) => {
fetchAppDetails(item.appid)
.then((appDetails) => {
const itemWithAppDetails = mergeItemWithAppDetails(item, appDetails);
const csrfToken = getCSRFToken();
return postToApi(itemWithAppDetails, csrfToken);
})
.then((response) => {
console.log(response);
});
});
};
/**
*
* @param {Blob} file
* @returns {Promise<unknown>}
*/
const readJSON = (file) =>
new Promise((resolve) => {
const reader = new FileReader();
reader.onload = (e) => {
const contents = e.target.result;
resolve(JSON.parse(contents));
};
reader.readAsText(file);
});
/**
*
* @param {Event} event
*/
const openFileForImport = (event) => {
const file = event.target.files[0];
if (!file) {
return;
}
readJSON(file).then(importItems);
};
const fileInput = document.querySelector("input[name='import']");
if (fileInput) {
fileInput.addEventListener("change", openFileForImport, false);
}

View file

@ -86,6 +86,8 @@ return array (
'delete' => 'Delete', 'delete' => 'Delete',
'optional' => 'Optional', 'optional' => 'Optional',
'restore' => 'Restore', 'restore' => 'Restore',
'export' => 'Export',
'import' => 'Import',
'alert.success.item_created' => 'Item created successfully', 'alert.success.item_created' => 'Item created successfully',
'alert.success.item_updated' => 'Item updated successfully', 'alert.success.item_updated' => 'Item updated successfully',
'alert.success.item_deleted' => 'Item deleted successfully', 'alert.success.item_deleted' => 'Item deleted successfully',

View file

@ -0,0 +1,32 @@
@extends('layouts.app')
@section('content')
<section class="module-container">
<header>
<div class="section-title">{{ __('import.title') }}</div>
<div class="module-actions">
<button type="submit"class="button"><i class="fa fa-save"></i><span>{{ __('import.save') }}</span></button>
<a href="{{ route('settings.index', []) }}" class="button"><i class="fa fa-ban"></i><span>{{ __('app.buttons.cancel') }}</span></a>
</div>
</header>
<div class="create">
{!! csrf_field() !!}
<div class="input">
<input class="form-control" name="import" type="file">
</div>
</div>
<footer>
<div class="section-title">&nbsp;</div>
<div class="module-actions">
<button type="submit"class="button"><i class="fa fa-save"></i><span>{{ __('import.save') }}</span></button>
<a href="{{ route('settings.index', []) }}" class="button"><i class="fa fa-ban"></i><span>{{ __('app.buttons.cancel') }}</span></a>
</div>
</footer>
</section>
@endsection

View file

@ -11,6 +11,8 @@
</div> </div>
<div class="module-actions"> <div class="module-actions">
{{-- <a href="import" id="item-import" class="button"><i class="fa fa-upload"></i><span>{{ __('import') }}</span></a>--}}
<a href="#export" id="item-export" class="button"><i class="fa fa-download"></i><span>{{ __('export') }}</span></a>
<a href="{{ route('applist', []) }}" class="button"><i class="fa fa-cloud-download"></i><span>{{ __('app.buttons.downloadapps') }}</span></a> <a href="{{ route('applist', []) }}" class="button"><i class="fa fa-cloud-download"></i><span>{{ __('app.buttons.downloadapps') }}</span></a>
<a href="{{ route('items.create', []) }}" title="" class="button"><i class="fa fa-plus"></i><span>{{ __('app.buttons.add') }}</span></a> <a href="{{ route('items.create', []) }}" title="" class="button"><i class="fa fa-plus"></i><span>{{ __('app.buttons.add') }}</span></a>
<a href="{{ route('dash', []) }}" class="button"><i class="fa fa-ban"></i><span>{{ __('app.buttons.cancel') }}</span></a> <a href="{{ route('dash', []) }}" class="button"><i class="fa fa-ban"></i><span>{{ __('app.buttons.cancel') }}</span></a>

View file

@ -81,3 +81,6 @@ Route::group([
Auth::routes(); Auth::routes();
Route::get('/home', 'HomeController@index')->name('home'); Route::get('/home', 'HomeController@index')->name('home');
Route::resource('api/item', 'ItemRestController');
Route::get('import', 'ImportController')->name('items.import');

View file

@ -7,12 +7,14 @@ use Tests\TestCase;
class ExampleTest extends TestCase class ExampleTest extends TestCase
{ {
use RefreshDatabase;
/** /**
* A basic test example. * A basic test example.
* *
* @return void * @return void
*/ */
public function testBasicTest() public function test_app_loads()
{ {
$response = $this->get('/'); $response = $this->get('/');

View file

@ -0,0 +1,81 @@
<?php
namespace Tests\Feature;
use App\Item;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Date;
use Tests\TestCase;
class ItemExportTest extends TestCase
{
use RefreshDatabase;
public function test_returns_empty_jsonarray_when_there_are_no_items_in_the_db()
{
$response = $this->get('api/item');
$response->assertJsonCount(0);
}
public function test_returns_exactly_the_defined_fields()
{
$exampleItem = [
"appdescription" => "Description",
"appid" => "123",
"colour" => "#000",
"description" => "Description",
"title" => "Item Title",
"url" => "http://gorczany.com/nihil-rerum-distinctio-voluptate-assumenda-accusantium-exercitationem"
];
Item::factory()
->create($exampleItem);
$response = $this->get('api/item');
$response->assertExactJson([(object)$exampleItem]);
}
public function test_returns_all_items()
{
Item::factory()
->count(3)
->create();
$response = $this->get('api/item');
$response->assertJsonCount(3);
}
public function test_does_not_return_deleted_item()
{
Item::factory()
->create([
'deleted_at' => Date::create('1970')
]);
Item::factory()
->create();
$response = $this->get('api/item');
$response->assertJsonCount(1);
}
public function test_does_not_return_tags()
{
Item::factory()
->create([
'type' => 1
]);
Item::factory()
->create();
$response = $this->get('api/item');
$response->assertJsonCount(1);
}
}

View file

@ -12,7 +12,7 @@ class SettingsSeederTest extends TestCase
* *
* @return void * @return void
*/ */
public function testReturnsAJSONMapWithSameAmountOfItemsAsLanguageDirectoriesPresent() public function test_returns_a_jsonmap_with_same_amount_of_items_as_language_directories_present()
{ {
$languageDirectories = array_filter(glob(resource_path().'/lang/*'), 'is_dir'); $languageDirectories = array_filter(glob(resource_path().'/lang/*'), 'is_dir');

View file

@ -11,7 +11,7 @@ class LangTest extends TestCase
* *
* @return void * @return void
*/ */
public function testAllLanguageKeysAreDefined() public function test_all_language_keys_are_defined()
{ {
$this->markTestSkipped('2022-11-14 Lot of keys missing. Enable this test to see them all.'); $this->markTestSkipped('2022-11-14 Lot of keys missing. Enable this test to see them all.');
$languageDirectories = array_filter(glob(resource_path().'/lang/*'), 'is_dir'); $languageDirectories = array_filter(glob(resource_path().'/lang/*'), 'is_dir');

View file

@ -18,6 +18,7 @@ return array(
'App\\Http\\Controllers\\Controller' => $baseDir . '/app/Http/Controllers/Controller.php', 'App\\Http\\Controllers\\Controller' => $baseDir . '/app/Http/Controllers/Controller.php',
'App\\Http\\Controllers\\HomeController' => $baseDir . '/app/Http/Controllers/HomeController.php', 'App\\Http\\Controllers\\HomeController' => $baseDir . '/app/Http/Controllers/HomeController.php',
'App\\Http\\Controllers\\ItemController' => $baseDir . '/app/Http/Controllers/ItemController.php', 'App\\Http\\Controllers\\ItemController' => $baseDir . '/app/Http/Controllers/ItemController.php',
'App\\Http\\Controllers\\ItemRestController' => $baseDir . '/app/Http/Controllers/ItemRestController.php',
'App\\Http\\Controllers\\SearchController' => $baseDir . '/app/Http/Controllers/SearchController.php', 'App\\Http\\Controllers\\SearchController' => $baseDir . '/app/Http/Controllers/SearchController.php',
'App\\Http\\Controllers\\SettingsController' => $baseDir . '/app/Http/Controllers/SettingsController.php', 'App\\Http\\Controllers\\SettingsController' => $baseDir . '/app/Http/Controllers/SettingsController.php',
'App\\Http\\Controllers\\TagController' => $baseDir . '/app/Http/Controllers/TagController.php', 'App\\Http\\Controllers\\TagController' => $baseDir . '/app/Http/Controllers/TagController.php',

View file

@ -616,6 +616,7 @@ class ComposerStaticInitb2555e5ff7197b9e020da74bbd3b7cfa
'App\\Http\\Controllers\\Controller' => __DIR__ . '/../..' . '/app/Http/Controllers/Controller.php', 'App\\Http\\Controllers\\Controller' => __DIR__ . '/../..' . '/app/Http/Controllers/Controller.php',
'App\\Http\\Controllers\\HomeController' => __DIR__ . '/../..' . '/app/Http/Controllers/HomeController.php', 'App\\Http\\Controllers\\HomeController' => __DIR__ . '/../..' . '/app/Http/Controllers/HomeController.php',
'App\\Http\\Controllers\\ItemController' => __DIR__ . '/../..' . '/app/Http/Controllers/ItemController.php', 'App\\Http\\Controllers\\ItemController' => __DIR__ . '/../..' . '/app/Http/Controllers/ItemController.php',
'App\\Http\\Controllers\\ItemRestController' => __DIR__ . '/../..' . '/app/Http/Controllers/ItemRestController.php',
'App\\Http\\Controllers\\SearchController' => __DIR__ . '/../..' . '/app/Http/Controllers/SearchController.php', 'App\\Http\\Controllers\\SearchController' => __DIR__ . '/../..' . '/app/Http/Controllers/SearchController.php',
'App\\Http\\Controllers\\SettingsController' => __DIR__ . '/../..' . '/app/Http/Controllers/SettingsController.php', 'App\\Http\\Controllers\\SettingsController' => __DIR__ . '/../..' . '/app/Http/Controllers/SettingsController.php',
'App\\Http\\Controllers\\TagController' => __DIR__ . '/../..' . '/app/Http/Controllers/TagController.php', 'App\\Http\\Controllers\\TagController' => __DIR__ . '/../..' . '/app/Http/Controllers/TagController.php',

28
webpack.mix.js vendored
View file

@ -1,4 +1,4 @@
let mix = require('laravel-mix'); const mix = require("laravel-mix");
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
@ -11,12 +11,20 @@ let mix = require('laravel-mix');
| |
*/ */
mix.babel([ mix
//'resources/assets/js/jquery-ui.min.js', .babel(
'resources/assets/js/huebee.js', [
'resources/assets/js/app.js', // 'resources/assets/js/jquery-ui.min.js',
'resources/assets/js/keyBindings.js', "resources/assets/js/huebee.js",
], 'public/js/app.js') "resources/assets/js/app.js",
.sass('resources/assets/sass/app.scss', 'public/css').options({ "resources/assets/js/keyBindings.js",
processCssUrls: false "resources/assets/js/itemExport.js",
}).version(); "resources/assets/js/itemImport.js",
],
"public/js/app.js"
)
.sass("resources/assets/sass/app.scss", "public/css")
.options({
processCssUrls: false,
})
.version();