Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 85 additions & 2 deletions app/Http/Controllers/ItemRestController.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace App\Http\Controllers;

use App\Item;
use App\User;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Collection;
Expand All @@ -21,6 +22,7 @@ public function __construct()
public function index(): Collection
{
$columns = [
'id',
'title',
'colour',
'url',
Expand All @@ -29,11 +31,27 @@ public function index(): Collection
'appdescription',
];

return Item::select($columns)
return Item::with('parents')
->select($columns)
->where('deleted_at', null)
->where('type', '0')
->orderBy('order', 'asc')
->get();
->get()
->map(function (Item $item) {
return [
'title' => $item->title,
'colour' => $item->colour,
'url' => $item->url,
'description' => $item->description,
'appid' => $item->appid,
'appdescription' => $item->appdescription,
'tags' => $item->parents
->where('id', '!=', 0)
->pluck('title')
->values()
->all(),
];
});
}

/**
Expand All @@ -50,6 +68,16 @@ public function create()
*/
public function store(Request $request): object
{
// Imports pass tags as an array of tag titles so they can round-trip
// across instances. Resolve those titles into local tag ids (creating
// any that don't yet exist) before handing off to the shared store
// logic. When no tags are supplied we keep the previous behaviour.
if ($request->has('tags')) {
$request->merge([
'tags' => $this->resolveTags($request->input('tags')),
]);
}

$item = ItemController::storelogic($request);

if ($item) {
Expand All @@ -59,6 +87,61 @@ public function store(Request $request): object
return (object) ['status' => 'FAILED'];
}

/**
* Resolve an incoming list of tags into tag ids.
*
* Numeric 0 (or "0") maps to the root/default dashboard. Every other entry
* is treated as a tag title: an existing tag with that title is reused, and
* a missing one is created. The lookup keeps the operation idempotent so
* importing many items that share a tag title only ever creates one tag.
*
* @param mixed $tags
* @return array<int, int>
*/
private function resolveTags($tags): array
{
if (! is_array($tags)) {
return [0];
}

$ids = [];

foreach ($tags as $tag) {
if ($tag === 0 || $tag === '0') {
$ids[] = 0;
continue;
}

$title = is_string($tag) ? trim($tag) : $tag;

if ($title === '' || $title === null) {
continue;
}

$existing = Item::where('type', '1')
->where('title', $title)
->first();

if ($existing) {
$ids[] = (int) $existing->id;
continue;
}

$created = Item::create([
'title' => $title,
'type' => '1',
'url' => str_slug($title, '-', 'en_US'),
'user_id' => User::currentUser()->getId(),
]);

$ids[] = (int) $created->id;
}

$ids = array_values(array_unique($ids));

return empty($ids) ? [0] : $ids;
}

/**
* Display the specified resource.
*/
Expand Down
2 changes: 1 addition & 1 deletion public/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -4446,7 +4446,7 @@ var getCSRFToken = function getCSRFToken() {
var mergeItemWithAppDetails = function mergeItemWithAppDetails(item, appDetails) {
return {
pinned: 1,
tags: [0],
tags: Array.isArray(item.tags) && item.tags.length ? item.tags : [0],
appid: item.appid,
title: item.title,
colour: item.colour,
Expand Down
2 changes: 1 addition & 1 deletion resources/assets/js/itemImport.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ const getCSRFToken = () => {
*/
const mergeItemWithAppDetails = (item, appDetails) => ({
pinned: 1,
tags: [0],
tags: Array.isArray(item.tags) && item.tags.length ? item.tags : [0],

appid: item.appid,
title: item.title,
Expand Down
23 changes: 22 additions & 1 deletion tests/Feature/ItemExportTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,28 @@ public function test_returns_exactly_the_defined_fields(): void

$response = $this->get('api/item');

$response->assertExactJson([(object)$exampleItem]);
$response->assertExactJson([$exampleItem + ["tags" => []]]);
}

public function test_exports_assigned_tag_titles_excluding_the_root_tag(): void
{
$item = Item::factory()
->create([
'title' => 'Tagged Item',
]);
$tag = Item::factory()
->create([
'type' => 1,
'title' => 'Media',
]);

// Assign both the root/default dashboard (id 0) and the Media tag.
$item->parents()->sync([0, $tag->id]);

$response = $this->get('api/item');

$response->assertJsonCount(1);
$response->assertJsonPath('0.tags', ['Media']);
}

public function test_returns_all_items(): void
Expand Down
107 changes: 107 additions & 0 deletions tests/Feature/ItemImportTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
<?php

namespace Tests\Feature;

use App\Item;
use App\ItemTag;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class ItemImportTest extends TestCase
{
use RefreshDatabase;

/**
* @return array<string, mixed>
*/
private function importPayload(array $overrides = []): array
{
return array_merge([
'pinned' => 1,
'appid' => 'null',
'website' => null,
'title' => 'Item A',
'colour' => '#00f',
'url' => 'http://10.0.1.1',
'tags' => [0],
], $overrides);
}

public function test_import_creates_and_assigns_a_tag_from_its_title(): void
{
$this->seed();

$response = $this->postJson('api/item', $this->importPayload([
'title' => 'Item A',
'tags' => ['Media'],
]));

$response->assertStatus(200);
$response->assertJson(['status' => 'OK']);

$tag = Item::where('type', 1)->where('title', 'Media')->first();
$this->assertNotNull($tag);
$this->assertSame(1, (int) $tag->type);

$item = Item::where('type', 0)->where('title', 'Item A')->first();
$this->assertNotNull($item);

$this->assertTrue(
ItemTag::where('item_id', $item->id)->where('tag_id', $tag->id)->exists()
);
}

public function test_import_reuses_an_existing_tag_for_the_same_title(): void
{
$this->seed();

$this->postJson('api/item', $this->importPayload([
'title' => 'Item A',
'tags' => ['Media'],
]))->assertStatus(200);

$this->postJson('api/item', $this->importPayload([
'title' => 'Item B',
'tags' => ['Media'],
]))->assertStatus(200);

$this->assertSame(
1,
Item::where('type', 1)->where('title', 'Media')->count()
);

$tag = Item::where('type', 1)->where('title', 'Media')->first();
$itemA = Item::where('type', 0)->where('title', 'Item A')->first();
$itemB = Item::where('type', 0)->where('title', 'Item B')->first();

$this->assertTrue(
ItemTag::where('item_id', $itemA->id)->where('tag_id', $tag->id)->exists()
);
$this->assertTrue(
ItemTag::where('item_id', $itemB->id)->where('tag_id', $tag->id)->exists()
);
}

public function test_import_with_root_tag_only_creates_no_tags(): void
{
$this->seed();

$response = $this->postJson('api/item', $this->importPayload([
'title' => 'Item A',
'tags' => [0],
]));

$response->assertStatus(200);

// No stray tag items should have been created beyond the seeded
// root/default dashboard tag (id 0).
$this->assertSame(0, Item::where('type', 1)->where('id', '>', 0)->count());

// The item should be assigned to the root/default dashboard (tag id 0).
$item = Item::where('type', 0)->where('title', 'Item A')->first();
$this->assertNotNull($item);
$this->assertTrue(
ItemTag::where('item_id', $item->id)->where('tag_id', 0)->exists()
);
}
}
Loading