1
0
mirror of https://github.com/Kovah/LinkAce.git synced 2025-04-21 07:22:20 +02:00

Add bulk editing for links, lists and tags (#26)

This commit is contained in:
Kovah 2024-02-20 15:01:02 +01:00
parent 3075255b1a
commit e785460e31
No known key found for this signature in database
GPG Key ID: AAAA031BA9830D7B
39 changed files with 922 additions and 100 deletions

View File

@ -0,0 +1,147 @@
<?php
namespace App\Http\Controllers\Models;
use App\Http\Controllers\Controller;
use App\Http\Requests\Models\BulkDeleteRequest;
use App\Http\Requests\Models\BulkEditFormRequest;
use App\Http\Requests\Models\BulkEditLinksRequest;
use App\Http\Requests\Models\BulkEditListsRequest;
use App\Http\Requests\Models\BulkEditTagsRequest;
use App\Models\Link;
use App\Models\LinkList;
use App\Models\Tag;
use App\Repositories\LinkRepository;
use App\Repositories\ListRepository;
use App\Repositories\TagRepository;
use Illuminate\Support\Facades\Log;
class BulkEditController extends Controller
{
public function form(BulkEditFormRequest $request)
{
$type = $request->input('type');
$view = sprintf('models.%s.bulk-edit', $type);
$models = explode(',', $request->input('models'));
return view($view, [
'models' => $models,
'modelCount' => count($models),
]);
}
public function updateLinks(BulkEditLinksRequest $request)
{
$models = explode(',', $request->input('models'));
$links = Link::whereIn('id', $models)->with([
'tags:id',
'lists:id',
])->get();
$results = $links->map(function (Link $link) use ($request) {
if (!auth()->user()->can('update', $link)) {
Log::warning('Could not update ' . $link->id . ' during bulk update: Permission denied!');
return null;
}
$newTags = explode(',', $request->input('tags'));
$newLists = explode(',', $request->input('lists'));
$linkData = $link->toArray();
$linkData['tags'] = $request->input('tags_mode') === 'replace'
? $newTags
: array_merge($link->tags->pluck('id')->toArray(), $newTags);
$linkData['lists'] = $request->input('lists_mode') === 'replace'
? $newLists
: array_merge($link->lists->pluck('id')->toArray(), $newLists);
$linkData['visibility'] = $request->input('visibility') ?: $linkData['visibility'];
return LinkRepository::update($link, $linkData);
});
$successCount = $results->filter(fn($e) => $e !== null)->count();
flash(trans('link.bulk_edit_success', ['success' => $successCount, 'selected' => $links->count()]));
return redirect()->route('links.index');
}
public function updateLists(BulkEditListsRequest $request)
{
$models = explode(',', $request->input('models'));
$lists = LinkList::whereIn('id', $models)->get();
$results = $lists->map(function (LinkList $list) use ($request) {
if (!auth()->user()->can('update', $list)) {
Log::warning('Could not update list ' . $list->id . ' during bulk update: Permission denied!');
return null;
}
$listData = $list->toArray();
$listData['visibility'] = $request->input('visibility') ?: $listData['visibility'];
return ListRepository::update($list, $listData);
});
$successCount = $results->filter(fn($e) => $e !== null)->count();
flash(trans('list.bulk_edit_success', ['success' => $successCount, 'selected' => $lists->count()]));
return redirect()->route('lists.index');
}
public function updateTags(BulkEditTagsRequest $request)
{
$models = explode(',', $request->input('models'));
$tags = Tag::whereIn('id', $models)->get();
$results = $tags->map(function (Tag $tag) use ($request) {
if (!auth()->user()->can('update', $tag)) {
Log::warning('Could not update tag ' . $tag->id . ' during bulk update: Permission denied!');
return null;
}
$tagData = $tag->toArray();
$tagData['visibility'] = $request->input('visibility') ?: $tagData['visibility'];
return TagRepository::update($tag, $tagData);
});
$successCount = $results->filter(fn($e) => $e !== null)->count();
flash(trans('tag.bulk_edit_success', ['success' => $successCount, 'selected' => $tags->count()]));
return redirect()->route('tags.index');
}
public function delete(BulkDeleteRequest $request)
{
$type = $request->input('type');
$formModels = explode(',', $request->input('models'));
$models = match ($type) {
'links' => Link::whereIn('id', $formModels)->get(),
'lists' => LinkList::whereIn('id', $formModels)->get(),
'tags' => Tag::whereIn('id', $formModels)->get(),
};
$results = $models->map(function ($model) use ($type) {
if (!auth()->user()->can('delete', $model)) {
Log::warning('Could not delete ' . $type . ' ' . $model->id . ' during bulk deletion: Permission denied!');
return null;
}
return match ($type) {
'links' => LinkRepository::delete($model),
'lists' => ListRepository::delete($model),
'tags' => TagRepository::delete($model)
};
});
$successCount = $results->filter(fn($e) => $e !== null)->count();
$message = match ($type) {
'links' => 'link.bulk_delete_success',
'lists' => 'list.bulk_delete_success',
'tags' => 'tag.bulk_delete_success'
};
flash(trans($message, ['success' => $successCount, 'selected' => $models->count()]));
return redirect()->route($type . '.index');
}
}

View File

@ -0,0 +1,16 @@
<?php
namespace App\Http\Requests\Models;
use Illuminate\Foundation\Http\FormRequest;
class BulkDeleteRequest extends FormRequest
{
public function rules(): array
{
return [
'type' => ['required', 'in:links,lists,tags'],
'models' => ['required', 'string'],
];
}
}

View File

@ -0,0 +1,16 @@
<?php
namespace App\Http\Requests\Models;
use Illuminate\Foundation\Http\FormRequest;
class BulkEditFormRequest extends FormRequest
{
public function rules(): array
{
return [
'type' => ['required', 'in:links,lists,tags'],
'models' => ['required', 'string'],
];
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Http\Requests\Models;
use App\Rules\ModelVisibility;
use Illuminate\Foundation\Http\FormRequest;
class BulkEditLinksRequest extends FormRequest
{
public function rules(): array
{
return [
'models' => ['required', 'string'],
'tags' => ['nullable', 'string'],
'tags_mode' => ['required', 'in:append,replace'],
'lists' => ['nullable', 'string'],
'lists_mode' => ['required', 'in:append,replace'],
'visibility' => ['nullable', new ModelVisibility],
];
}
}

View File

@ -0,0 +1,17 @@
<?php
namespace App\Http\Requests\Models;
use App\Rules\ModelVisibility;
use Illuminate\Foundation\Http\FormRequest;
class BulkEditListsRequest extends FormRequest
{
public function rules(): array
{
return [
'models' => ['required', 'string'],
'visibility' => ['nullable', new ModelVisibility],
];
}
}

View File

@ -0,0 +1,17 @@
<?php
namespace App\Http\Requests\Models;
use App\Rules\ModelVisibility;
use Illuminate\Foundation\Http\FormRequest;
class BulkEditTagsRequest extends FormRequest
{
public function rules(): array
{
return [
'models' => ['required', 'string'],
'visibility' => ['nullable', new ModelVisibility],
];
}
}

View File

@ -3,6 +3,7 @@
namespace App\Settings;
use App\Enums\ModelAttribute;
use App\Models\Link;
use Spatie\LaravelSettings\Settings;
class UserSettings extends Settings
@ -83,7 +84,7 @@ class UserSettings extends Settings
'archive_private_backups_enabled' => true,
'listitem_count' => 24,
'darkmode_setting' => 2,
'link_display_mode' => 1,
'link_display_mode' => Link::DISPLAY_LIST_DETAILED,
'links_new_tab' => false,
'markdown_for_text' => true,
'share_services' => true,

View File

@ -12,7 +12,8 @@ class VisibilityToggle extends Component
private ?int $existingValue = null,
private string $visibilitySetting = 'links_default_visibility',
public string $inputClasses = '',
public string $labelClasses = ''
public string $labelClasses = '',
public bool $unchangedOption = false
) {
}

View File

@ -4,6 +4,7 @@ return [
'links' => 'Links',
'all_links' => 'All Links',
'recent_links' => 'Recent Links',
'update_links' => 'Update Links',
'no_links' => 'No Links',
'add' => 'Add Link',
@ -12,7 +13,7 @@ return [
'details' => 'Link Details',
'edit' => 'Edit Link',
'update' => 'Update Link',
'delete' => 'Delete Link',
'delete' => 'Delete Link|Delete Links',
'public' => 'Public Link',
'internal' => 'Internal Link',
@ -49,6 +50,10 @@ return [
'status_is_broken' => 'Link is marked as broken',
'status_mark_working' => 'Mark as working',
'bulk_title' => 'You want to edit :count Link.|You want to edit :count Links.',
'bulk_edit_success' => 'Successfully updated :success Links out of :selected selected ones.',
'bulk_delete_success' => 'Successfully moved :success Links out of :selected selected ones to the trash.',
'added_successfully' => 'Link added successfully.',
'added_connection_error' => 'The Link was added but a connection error occurred when trying to access the URL. Details can be found in the logs.',
'added_request_error' => 'The Link was added but an error occurred when trying to request the URL, for example an invalid certificate. Details can be found in the logs.',

View File

@ -50,6 +50,8 @@ return [
'continue_adding' => 'Continue Adding',
'visibility' => 'Visibility',
'change_visibility' => 'Change Visibility',
'dont_change_visibility' => 'Do not change Visibility',
'history' => 'History',
'history_added' => 'Added <code>:newvalue</code> to :fieldname.',

View File

@ -4,12 +4,13 @@ return [
'lists' => 'Lists',
'all_lists' => 'All Lists',
'recent_lists' => 'Recent Lists',
'update_lists' => 'Update Lists',
'add' => 'Add List',
'show' => 'Show List',
'edit' => 'Edit List',
'update' => 'Update List',
'delete' => 'Delete List',
'delete' => 'Delete List|Delete Lists',
'filter_lists' => 'Filter Lists...',
@ -29,6 +30,12 @@ return [
'no_lists' => 'No Lists',
'bulk_title' => 'You want to edit :count List.|You want to edit :count Lists.',
'bulk_edit_success' => 'Successfully updated :success Lists out of :selected selected ones.',
'bulk_delete_success' => 'Successfully moved :success Lists out of :selected selected ones to the trash.',
'bulk_mode_append' => 'Append new Lists to existing ones',
'bulk_mode_replace' => 'Replace existing Lists with new ones',
'number_links' => ':number Link in this List|:number Links in this List',
'added_successfully' => 'List added successfully.',

View File

@ -4,12 +4,13 @@ return [
'tags' => 'Tags',
'all_tags' => 'All Tags',
'recent_tags' => 'Recent Tags',
'update_tags' => 'Update Tags',
'add' => 'Add Tag',
'show' => 'Show Tag',
'edit' => 'Edit Tag',
'update' => 'Update Tag',
'delete' => 'Delete Tag',
'delete' => 'Delete Tag|Delete Tags',
'filter_tags' => 'Filter Tags...',
@ -28,6 +29,12 @@ return [
'no_tags' => 'No Tags',
'bulk_title' => 'You want to edit :count Tag.|You want to edit :count Tags.',
'bulk_edit_success' => 'Successfully updated :success Tags out of :selected selected ones.',
'bulk_delete_success' => 'Successfully moved :success Tags out of :selected selected ones to the trash.',
'bulk_mode_append' => 'Append new Tags to existing ones',
'bulk_mode_replace' => 'Replace existing Tags with new ones',
'added_successfully' => 'Tag added successfully.',
'updated_successfully' => 'Tag updated successfully.',
'deleted_successfully' => 'Tag deleted successfully.',

View File

@ -1,28 +1,30 @@
import { register } from './lib/views';
import Base from './components/Base';
import UrlField from './components/UrlField';
import LoadingButton from './components/LoadingButton';
import BookmarkTimer from './components/BookmarkTimer';
import TagsSelect from './components/TagsSelect';
import SimpleSelect from './components/SimpleSelect';
import ShareToggleAll from './components/ShareToggleAll';
import BulkEdit from './components/BulkEdit';
import GenerateCronToken from './components/GenerateCronToken';
import UpdateCheck from './components/UpdateCheck';
import Import from './components/Import';
import LoadingButton from './components/LoadingButton';
import ShareToggleAll from './components/ShareToggleAll';
import SimpleSelect from './components/SimpleSelect';
import TagsSelect from './components/TagsSelect';
import UpdateCheck from './components/UpdateCheck';
import UrlField from './components/UrlField';
// Register view components
function registerViews () {
register('#app', Base);
register('input[id="url"]', UrlField);
register('button[type="submit"]', LoadingButton);
register('.bm-timer', BookmarkTimer);
register('.tag-select', TagsSelect);
register('.simple-select', SimpleSelect);
register('.share-toggle', ShareToggleAll);
register('.bulk-edit', BulkEdit);
register('.cron-token', GenerateCronToken);
register('.update-check', UpdateCheck);
register('.import-form', Import);
register('.share-toggle', ShareToggleAll);
register('.simple-select', SimpleSelect);
register('.tag-select', TagsSelect);
register('.update-check', UpdateCheck);
register('button[type="submit"]', LoadingButton);
register('input[id="url"]', UrlField);
}
if (document.readyState !== 'loading') {

View File

@ -0,0 +1,63 @@
export default class BulkEdit {
constructor ($el) {
this.$el = $el;
this.$form = $el.querySelector('.bulk-edit-form');
this.$submit = $el.querySelector('.bulk-edit-submit');
this.$selectAll = $el.querySelector('.bulk-edit-select-all');
this.$models = $el.querySelectorAll('.bulk-edit-model');
this.$form.querySelector('[name="type"]').value = $el.dataset.type;
this.selectedModels = [];
this.init();
}
init () {
console.log('bulk edit init for ' + this.$models.length + ' models'); //@DEBUG
this.$models.forEach($model => {
$model.addEventListener('change', this.toggleBulkEdit.bind(this));
});
this.$selectAll.addEventListener('click', this.selectAll.bind(this));
this.$submit.addEventListener('click', this.submitEdit.bind(this));
}
toggleBulkEdit (event) {
const newModel = event.target.dataset.id;
if (event.target.checked) {
this.selectedModels.push(newModel);
} else {
this.selectedModels = this.selectedModels.filter(existingModel => newModel !== existingModel);
}
this.toggleHeader();
}
toggleHeader () {
if (this.selectedModels.length > 0) {
this.$form.classList.remove('visually-hidden');
} else {
this.$form.classList.add('visually-hidden');
}
}
selectAll () {
if (this.selectedModels.length === this.$models.length) {
this.selectedModels = [];
this.$models.forEach($model => {
$model.checked = false;
});
this.toggleHeader();
} else {
this.selectedModels = [];
this.$models.forEach($model => {
this.selectedModels.push($model.dataset.id);
$model.checked = true;
});
}
}
submitEdit () {
this.$form.querySelector('[name="models"]').value = this.selectedModels.join(',');
this.$form.submit();
}
}

View File

@ -178,6 +178,28 @@ code {
margin: 2px;
}
.form-check.bulk-edit-model {
width: 1rem;
height: 1rem;
}
.link-card .form-check.bulk-edit-model {
position: absolute;
top: .5rem;
right: .5rem;
opacity: .5;
&:hover,
&:focus,
&:checked {
opacity: 1;
}
}
.link-card:hover .form-check.bulk-edit-model {
opacity: 1;
}
.link-thumbnail {
box-shadow: inset 0 0 1px $secondary;
display: flex;

View File

@ -1,6 +1,9 @@
<div {{ $attributes }}>
<label class="form-label {{ $labelClasses }}" for="visibility">@lang('linkace.visibility')</label>
<select id="visibility" name="visibility" class="form-select {{ $inputClasses }}{{ $errors->has('visibility') ? ' is-invalid' : '' }}">
@if($unchangedOption)
<option value="">@lang('linkace.dont_change_visibility')</option>
@endif
<option value="{{ $public }}" {{ $publicSelected ? 'selected' : '' }}>
@lang('attributes.visibility.' . $public)
</option>

View File

@ -0,0 +1,87 @@
@extends('layouts.app')
@section('content')
<form action="{{ route('bulk-edit.update-links') }}" method="POST" class="card">
@csrf
<input type="hidden" name="models" value="{{ old('models', implode(',', $models)) }}">
<header class="card-header">@choice('link.bulk_title', $modelCount, ['count' => $modelCount])</header>
<div class="card-body">
<div class="row row-cols-1 row-cols-md-2 row-gap-2">
<div>
<label class="form-label" for="tags">@lang('tag.update_tags')</label>
<input name="tags" id="tags" type="text" placeholder="@lang('placeholder.tags_select')"
class="tag-select"
data-value="{{ Link::oldTaxonomyOutputWithoutLink('tags', []) }}"
data-allow-creation="1" data-tag-type="tags">
@if ($errors->has('tags'))
<p class="invalid-feedback" role="alert">
{{ $errors->first('tags') }}
</p>
@endif
</div>
<div>
<label class="form-label" for="tags_mode">Mode</label>
<select id="tags_mode" name="tags_mode" class="form-select {{ $errors->has('tags_mode') ? ' is-invalid' : '' }}">
<option value="append" @selected(old('tags_mode') === 'append')>
@lang('tag.bulk_mode_append')
</option>
<option value="replace" @selected(old('tags_mode') === 'replace')>
@lang('tag.bulk_mode_replace')
</option>
</select>
</div>
</div>
<div class="mt-4 row row-cols-1 row-cols-md-2 row-gap-2">
<div>
<label class="form-label" for="lists">@lang('list.update_lists')</label>
<input name="lists" id="lists" type="text" placeholder="@lang('placeholder.list_select')"
class="tag-select"
data-value="{{ Link::oldTaxonomyOutputWithoutLink('lists', []) }}"
data-allow-creation="1" data-tag-type="lists">
@if ($errors->has('lists'))
<p class="invalid-feedback" role="alert">
{{ $errors->first('lists') }}
</p>
@endif
</div>
<div>
<label class="form-label" for="lists_mode">Mode</label>
<select id="lists_mode" name="lists_mode" class="form-select {{ $errors->has('lists_mode') ? ' is-invalid' : '' }}">
<option value="append" @selected(old('lists_mode') === 'append')>
@lang('list.bulk_mode_append')
</option>
<option value="replace" @selected(old('lists_mode') === 'replace')>
@lang('list.bulk_mode_replace')
</option>
</select>
</div>
</div>
<div class="mt-4 row">
<x-forms.visibility-toggle class="col-6" :unchanged-option="true"/>
</div>
<div class="mt-3 d-sm-flex align-items-center justify-content-end">
<button type="submit" class="btn btn-primary">
<x-icon.save class="me-2"/> @lang('link.update_links')
</button>
</div>
</div>
</form>
<form action="{{ route('bulk-edit.delete') }}" method="POST" class="card mt-4">
@csrf
<input type="hidden" name="type" value="links">
<input type="hidden" name="models" value="{{ implode(',', $models) }}">
<header class="card-header">@choice('link.delete', $modelCount)</header>
<div class="card-body">
<div class="text-end">
<button type="submit" class="btn btn-danger">
<x-icon.save class="me-2"/> @choice('link.delete', $modelCount)
</button>
</div>
</div>
</form>
@endsection

View File

@ -98,7 +98,7 @@
<div class="d-sm-inline-block mb-3 mb-sm-0 me-auto">
<button type="button" class="btn btn-sm btn-outline-danger"
onclick="window.deleteLink.submit()">
<x-icon.trash class="me-2"/> @lang('link.delete')
<x-icon.trash class="me-2"/> @choice('link.delete', 1)
</button>
</div>

View File

@ -17,7 +17,7 @@
</div>
</header>
<section class="my-4">
<section class="mb-4">
@if($links->isNotEmpty())
<div class="link-wrapper">

View File

@ -1,5 +1,17 @@
<div class="link-list row gy-4">
@foreach($links as $link)
@include('models.links.partials.single-card')
@endforeach
<div class="bulk-edit" data-type="links">
<form class="bulk-edit-form visually-hidden text-end" action="{{ route('bulk-edit.form') }}" method="POST">
@csrf()
<input type="hidden" name="type">
<input type="hidden" name="models">
<div class="btn-group mt-1">
<button type="button" class="bulk-edit-submit btn btn-outline-primary btn-xs">Edit</button>
<button type="button" class="bulk-edit-select-all btn btn-outline-primary btn-xs">Select all</button>
</div>
</form>
<div class="link-list row gy-4 mt-1">
@foreach($links as $link)
@include('models.links.partials.single-card')
@endforeach
</div>
</div>

View File

@ -1,5 +1,16 @@
<div class="link-list list-group">
@foreach($links as $link)
@include('models.links.partials.single-detailed')
@endforeach
<div class="bulk-edit" data-type="links">
<form class="bulk-edit-form visually-hidden text-end" action="{{ route('bulk-edit.form') }}" method="POST">
@csrf()
<input type="hidden" name="type">
<input type="hidden" name="models">
<div class="btn-group mt-1">
<button type="button" class="bulk-edit-submit btn btn-outline-primary btn-xs">Edit</button>
<button type="button" class="bulk-edit-select-all btn btn-outline-primary btn-xs">Select all</button>
</div>
</form>
<div class="link-list list-group mt-3">
@foreach($links as $link)
@include('models.links.partials.single-detailed')
@endforeach
</div>
</div>

View File

@ -1,5 +1,16 @@
<ul class="link-list list-group">
@foreach($links as $link)
@include('models.links.partials.single-simple')
@endforeach
</ul>
<div class="bulk-edit" data-type="links">
<form class="bulk-edit-form visually-hidden text-end" action="{{ route('bulk-edit.form') }}" method="POST">
@csrf()
<input type="hidden" name="type">
<input type="hidden" name="models">
<div class="btn-group mt-1">
<button type="button" class="bulk-edit-submit btn btn-outline-primary btn-xs">Edit</button>
<button type="button" class="bulk-edit-select-all btn btn-outline-primary btn-xs">Select all</button>
</div>
</form>
<ul class="link-list list-group mt-3">
@foreach($links as $link)
@include('models.links.partials.single-simple')
@endforeach
</ul>
</div>

View File

@ -3,18 +3,19 @@
@endphp
<div class="link-card col-12 col-md-6 col-lg-4">
<div class="h-100 card">
<div class="link-thumbnail-list-holder-detailed">
<a href="{{ $link->url }}" {!! linkTarget() !!} class="link-thumbnail-list-detailed"
<a href="{{ $link->url }}" {!! linkTarget() !!} class="link-thumbnail-list-detailed">
@if($link->thumbnail)
style="background-image: url('{{ $link->thumbnail }}');"
@endif>
<img src="{{ $link->thumbnail }}" alt="{{ $link->title }}" class="w-100 h-100 object-fit-cover" loading="lazy">
@endif
@if(!$link->thumbnail)
<span class="link-thumbnail-placeholder link-thumbnail-placeholder-detailed">
<x-icon.linkace-icon/>
</span>
@endif
</a>
<input type="checkbox" aria-label="Add link to bulk edit" class="bulk-edit-model form-check"
data-id="{{ $link->id }}">
</div>
<div class="card-body h-100 border-bottom-0">
@ -25,7 +26,7 @@
</div>
@if($link->tags->count() > 0)
<div class="px-3">
<div class="px-3 mb-3">
@foreach($link->tags as $tag)
<a href="{{ route('tags.show', [$tag]) }}" class="btn btn-light btn-xs">
{{ $tag->name }}
@ -40,23 +41,21 @@
</div>
<div class="btn-group ms-auto me-2">
<button type="button" class="btn btn-xs btn-md-sm btn-outline-secondary"
<button type="button" class="btn btn-xs btn-md-sm btn-link"
title="@lang('sharing.share_link')"
data-bs-toggle="collapse" data-bs-target="#sharing-{{ $link->id }}"
aria-expanded="false" aria-controls="sharing-{{ $link->id }}">
<x-icon.share class="fw"/>
<span class="visually-hidden">@lang('sharing.share_link')</span>
</button>
<a href="{{ route('links.show', [$link]) }}" class="btn btn-xs btn-outline-secondary"
title="@lang('link.show')">
<a href="{{ route('links.show', [$link]) }}" class="btn btn-xs btn-link" title="@lang('link.show')">
@lang('linkace.show')
</a>
<a href="{{ route('links.edit', [$link]) }}" class="btn btn-xs btn-outline-secondary"
title="@lang('link.edit')">
<a href="{{ route('links.edit', [$link]) }}" class="btn btn-xs btn-link" title="@lang('link.edit')">
@lang('linkace.edit')
</a>
<button type="submit" form="link-delete-{{ $link->id }}" title="@lang('link.delete')"
class="btn btn-xs btn-outline-secondary">
<button type="submit" form="link-delete-{{ $link->id }}" title="@choice('link.delete', 1)"
class="btn btn-xs btn-link">
@lang('linkace.delete')
</button>
</div>

View File

@ -35,27 +35,26 @@
@lang('linkace.added') {!! $link->addedAt() !!}
</div>
<div class="btn-group ms-2">
<button type="button" class="btn btn-xs btn-outline-secondary" title="@lang('sharing.share_link')"
<div class="btn-group ms-2 me-1">
<button type="button" class="btn btn-xs btn-link" title="@lang('sharing.share_link')"
data-bs-toggle="collapse" data-bs-target="#sharing-{{ $link->id }}"
aria-expanded="false" aria-controls="sharing-{{ $link->id }}">
<x-icon.share class="fw"/>
<span class="visually-hidden">@lang('sharing.share_link')</span>
</button>
<a href="{{ route('links.show', [$link]) }}" class="btn btn-xs btn-outline-secondary"
title="@lang('link.show')">
<a href="{{ route('links.show', [$link]) }}" class="btn btn-xs btn-link" title="@lang('link.show')">
@lang('linkace.show')
</a>
<a href="{{ route('links.edit', [$link]) }}" class="btn btn-xs btn-outline-secondary"
title="@lang('link.edit')">
<a href="{{ route('links.edit', [$link]) }}" class="btn btn-xs btn-link" title="@lang('link.edit')">
@lang('linkace.edit')
</a>
<button type="submit" form="link-delete-{{ $link->id }}" title="@lang('link.delete')"
class="btn btn-xs btn-outline-secondary">
<button type="submit" form="link-delete-{{ $link->id }}" title="@choice('link.delete', 1)"
class="btn btn-xs btn-link">
@lang('linkace.delete')
</button>
</div>
<input type="checkbox" aria-label="Add link to bulk edit" class="bulk-edit-model form-check"
data-id="{{ $link->id }}">
</div>
</div>
@if($shareLinks !== '')

View File

@ -4,6 +4,7 @@
<li class="link-simple list-group-item">
<div class="d-sm-flex align-items-center">
<div class="me-4 one-line-sm">
{!! $link->getIcon('me-1') !!}
<a href="{{ $link->url }}" title="{{ $link->url }}" {!! linkTarget() !!}>
{{ $link->title }}
</a>
@ -14,12 +15,14 @@
<x-icon.info class="fw"/>
<span class="visually-hidden">@lang('link.details')</span>
</a>
<button type="button" class="btn btn-xs btn-link" title="@lang('sharing.share_link')"
<button type="button" class="btn btn-xs btn-link me-1" title="@lang('sharing.share_link')"
data-bs-toggle="collapse" data-bs-target="#sharing-{{ $link->id }}"
aria-expanded="false" aria-controls="sharing-{{ $link->id }}">
<x-icon.share class="fw"/>
<span class="visually-hidden">@lang('sharing.share_link')</span>
</button>
<input type="checkbox" aria-label="Add link to bulk edit" class="bulk-edit-model form-check"
data-id="{{ $link->id }}">
</div>
</div>
@if($shareLinks !== '')

View File

@ -59,7 +59,7 @@
<x-icon.edit class="me-2"/>
<span class="d-none d-sm-inline">@lang('linkace.edit')</span>
</a>
<button type="submit" form="link-delete-{{ $link->id }}" aria-label="@lang('link.delete')"
<button type="submit" form="link-delete-{{ $link->id }}" aria-label="@choice('link.delete', 1)"
class="btn btn-sm btn-outline-danger cursor-pointer">
<x-icon.trash class="me-2"/>
<span class="d-none d-sm-inline">@lang('linkace.delete')</span>

View File

@ -0,0 +1,36 @@
@extends('layouts.app')
@section('content')
<form action="{{ route('bulk-edit.update-lists') }}" method="POST" class="card">
@csrf
<input type="hidden" name="models" value="{{ old('models', implode(',', $models)) }}">
<header class="card-header">@choice('list.bulk_title', $modelCount, ['count' => $modelCount])</header>
<div class="card-body">
<div class="row">
<x-forms.visibility-toggle class="col-6" :unchanged-option="true"/>
</div>
<div class="mt-3 d-sm-flex align-items-center justify-content-end">
<button type="submit" class="btn btn-primary">
<x-icon.save class="me-2"/> @lang('list.update_lists')
</button>
</div>
</div>
</form>
<form action="{{ route('bulk-edit.delete') }}" method="POST" class="card mt-4">
@csrf
<input type="hidden" name="type" value="links">
<input type="hidden" name="models" value="{{ implode(',', $models) }}">
<header class="card-header">@choice('list.delete', $modelCount)</header>
<div class="card-body">
<div class="text-end">
<button type="submit" class="btn btn-danger">
<x-icon.save class="me-2"/> @choice('list.delete', $modelCount)
</button>
</div>
</div>
</form>
@endsection

View File

@ -56,7 +56,7 @@
<div class="d-sm-inline-block mb-3 mb-sm-0 me-auto">
<button type="button" class="btn btn-sm btn-outline-danger"
onclick="window.deleteList.submit()">
<x-icon.trash class="me-2"/> @lang('list.delete')
<x-icon.trash class="me-2"/> @choice('list.delete', 1)
</button>
</div>

View File

@ -35,10 +35,21 @@
@if($lists->isNotEmpty())
<div class="row mt-3">
@foreach($lists as $list)
@include('models.lists.partials.single')
@endforeach
<div class="bulk-edit" data-type="lists">
<form class="bulk-edit-form visually-hidden text-end" action="{{ route('bulk-edit.form') }}" method="POST">
@csrf()
<input type="hidden" name="type">
<input type="hidden" name="models">
<div class="btn-group mt-1">
<button type="button" class="bulk-edit-submit btn btn-outline-primary btn-xs">Edit</button>
<button type="button" class="bulk-edit-select-all btn btn-outline-primary btn-xs">Select all</button>
</div>
</form>
<div class="row mt-3">
@foreach($lists as $list)
@include('models.lists.partials.single')
@endforeach
</div>
</div>
@else

View File

@ -20,17 +20,16 @@
@lang('link.no_links')
@endif
</div>
<div class="btn-group ms-auto me-2">
<a href="{{ route('lists.edit', ['list' => $list]) }}" class="btn btn-sm btn-link">
<x-icon.edit/>
<span class="visually-hidden">@lang('list.edit')</span>
<div class="btn-group ms-auto me-1">
<a href="{{ route('lists.edit', ['list' => $list]) }}" class="btn btn-xs btn-link">
@lang('linkace.edit')
</a>
<button type="submit" form="list-delete-{{ $list->id }}" title="@lang('list.delete')"
class="btn btn-sm btn-link">
<x-icon.trash/>
<span class="visually-hidden">@lang('list.delete')</span>
<button type="submit" form="list-delete-{{ $list->id }}" class="btn btn-xs btn-link">
@lang('linkace.delete')
</button>
</div>
<input type="checkbox" aria-label="Add link to bulk edit" class="bulk-edit-model form-check me-2"
data-id="{{ $list->id }}">
<form id="list-delete-{{ $list->id }}" method="POST" style="display: none;"
action="{{ route('lists.destroy', ['list' => $list]) }}">

View File

@ -15,7 +15,7 @@
@lang('linkace.edit')
</a>
<a onclick="event.preventDefault();document.getElementById('list-delete-{{ $list->id }}').submit();"
class="btn btn-sm btn-outline-danger" aria-label="@lang('list.delete')">
class="btn btn-sm btn-outline-danger" aria-label="@choice('list.delete', 1)">
<x-icon.trash class="me-2"/>
@lang('linkace.delete')
</a>

View File

@ -0,0 +1,36 @@
@extends('layouts.app')
@section('content')
<form action="{{ route('bulk-edit.update-tags') }}" method="POST" class="card">
@csrf
<input type="hidden" name="models" value="{{ old('models', implode(',', $models)) }}">
<header class="card-header">@choice('tag.bulk_title', $modelCount, ['count' => $modelCount])</header>
<div class="card-body">
<div class="row">
<x-forms.visibility-toggle class="col-6" :unchanged-option="true"/>
</div>
<div class="mt-3 d-sm-flex align-items-center justify-content-end">
<button type="submit" class="btn btn-primary">
<x-icon.save class="me-2"/> @lang('tag.update_tags')
</button>
</div>
</div>
</form>
<form action="{{ route('bulk-edit.delete') }}" method="POST" class="card mt-4">
@csrf
<input type="hidden" name="type" value="links">
<input type="hidden" name="models" value="{{ implode(',', $models) }}">
<header class="card-header">@choice('tag.delete', $modelCount)</header>
<div class="card-body">
<div class="text-end">
<button type="submit" class="btn btn-danger">
<x-icon.save class="me-2"/> @choice('tag.delete', $modelCount)
</button>
</div>
</div>
</form>
@endsection

View File

@ -42,7 +42,7 @@
<div class="d-sm-inline-block mb-3 mb-sm-0 me-auto">
<button type="button" class="btn btn-sm btn-outline-danger"
onclick="window.deleteTag.submit()">
<x-icon.trash class="me-2"/> @lang('tag.delete')
<x-icon.trash class="me-2"/> @choice('tag.delete', 1)
</button>
</div>

View File

@ -8,17 +8,19 @@
<td>
{{ $tag->links_count }}
</td>
<td class="py-1 text-end">
<div class="btn-group btn-group-sm">
<a href="{{ route('tags.edit', [$tag]) }}" class="btn btn-link">
<x-icon.edit class="fw"/>
<span class="visually-hidden">@lang('tag.edit')</span>
</a>
<button type="submit" form="tag-delete-{{ $tag->id }}" title="@lang('tag.delete')"
class="btn btn-link">
<x-icon.trash class="fw"/>
<span class="visually-hidden">@lang('tag.delete')</span>
</button>
<td class="py-1">
<div class="mt-1 d-flex align-items-center justify-content-end">
<div class="btn-group me-1">
<a href="{{ route('tags.edit', [$tag]) }}" class="btn btn-xs btn-link">
@lang('linkace.edit')
</a>
<button type="submit" form="tag-delete-{{ $tag->id }}" title="@choice('tag.delete', 1)"
class="btn btn-xs btn-link">
@lang('linkace.delete')
</button>
</div>
<input type="checkbox" aria-label="Add link to bulk edit" class="bulk-edit-model form-check"
data-id="{{ $tag->id }}">
</div>
<form id="tag-delete-{{ $tag->id }}" method="POST" style="display: none;"

View File

@ -1,4 +1,4 @@
<div class="table-responsive">
<div class="bulk-edit table-responsive" data-type="tags">
<table class="table mb-0">
<thead>
<tr>
@ -8,7 +8,17 @@
<th>
{!! tableSorter(trans('link.links'), $route, 'links_count', $orderBy, $orderDir) !!}
</th>
<th></th>
<th>
<form class="bulk-edit-form visually-hidden text-end" action="{{ route('bulk-edit.form') }}" method="POST">
@csrf()
<input type="hidden" name="type">
<input type="hidden" name="models">
<div class="btn-group mt-1">
<button type="button" class="bulk-edit-submit btn btn-xs btn-outline-primary">Edit</button>
<button type="button" class="bulk-edit-select-all btn btn-xs btn-outline-primary">Select all</button>
</div>
</form>
</th>
</tr>
</thead>
<tbody>

View File

@ -15,7 +15,7 @@
@lang('linkace.edit')
</a>
<a onclick="event.preventDefault();document.getElementById('tag-delete-{{ $tag->id }}').submit();"
class="btn btn-sm btn-outline-danger" aria-label="@lang('tag.delete')">
class="btn btn-sm btn-outline-danger" aria-label="@choice('tag.delete', 1)">
<x-icon.trash class="me-2"/>
@lang('linkace.delete')
</a>

View File

@ -21,6 +21,7 @@ use App\Http\Controllers\Guest\LinkController as GuestLinkController;
use App\Http\Controllers\Guest\ListController as GuestListController;
use App\Http\Controllers\Guest\TagController as GuestTagController;
use App\Http\Controllers\Guest\UserController as GuestUserController;
use App\Http\Controllers\Models\BulkEditController;
use App\Http\Controllers\Models\LinkController;
use App\Http\Controllers\Models\ListController;
use App\Http\Controllers\Models\NoteController;
@ -88,6 +89,17 @@ Route::group(['middleware' => ['auth']], function () {
Route::resource('notes', NoteController::class)
->except(['index', 'show', 'create']);
Route::post('bulk-edit', [BulkEditController::class, 'form'])
->name('bulk-edit.form');
Route::post('bulk-edit/update-links', [BulkEditController::class, 'updateLinks'])
->name('bulk-edit.update-links');
Route::post('bulk-edit/update-lists', [BulkEditController::class, 'updateLists'])
->name('bulk-edit.update-lists');
Route::post('bulk-edit/update-tags', [BulkEditController::class, 'updateTags'])
->name('bulk-edit.update-tags');
Route::post('bulk-edit/delete', [BulkEditController::class, 'delete'])
->name('bulk-edit.delete');
Route::get('users/{user:name}', [UserController::class, 'show'])->name('users.show');
Route::post('links/toggle-check/{link}', [LinkController::class, 'updateCheckToggle'])

View File

@ -69,22 +69,6 @@ class SettingsEntryTest extends TestCase
);
}
public function testDisplayModeSettingsChange(): void
{
$settings = app(UserSettings::class);
$settings->link_display_mode = 2;
$settings->save();
$historyEntry = Audit::where('auditable_type', SettingsAudit::class)->with('auditable')->latest()->first();
$output = (new SettingsEntry($historyEntry))->render();
$this->assertStringContainsString(
'Changed Link Display Mode for User 1 from <code>cards with less details</code> to <code>list with less details</code>',
$output
);
}
public function testLocaleSettingsChange(): void
{
$settings = app(UserSettings::class);

View File

@ -0,0 +1,263 @@
<?php
namespace Tests\Controller\Models;
use App\Enums\ModelAttribute;
use App\Models\Link;
use App\Models\LinkList;
use App\Models\Tag;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Queue;
use Tests\Controller\Traits\PreparesTestData;
use Tests\TestCase;
class BulkEditControllerTest extends TestCase
{
use RefreshDatabase;
use PreparesTestData;
private User $user;
protected function setUp(): void
{
parent::setUp();
$this->user = User::factory()->create();
$this->actingAs($this->user);
Queue::fake();
}
public function testBulkEditForm(): void
{
$this->post('bulk-edit', [
'type' => 'links',
'models' => '2,9,42',
])->assertOk()->assertSee('You want to edit 3 Links.');
}
public function testLinksEdit(): void
{
Log::shouldReceive('warning')->once();
$links = $this->prepareLinkTestData();
$otherUser = User::factory()->create();
$otherLink = Link::factory()->for($otherUser)->create(['visibility' => ModelAttribute::VISIBILITY_PRIVATE]);
$this->post('bulk-edit/update-links', [
'models' => '1,2,3,4',
'tags' => '3',
'tags_mode' => 'append',
'lists' => '3',
'lists_mode' => 'append',
'visibility' => null,
])
->assertRedirect('links')
->assertSessionHas('flash_notification.0.message', 'Successfully updated 3 Links out of 4 selected ones.');
array_walk($links, fn($link) => $link->refresh());
$this->assertEqualsCanonicalizing([1, 3], $links[0]->lists()->pluck('id')->sort()->toArray());
$this->assertEqualsCanonicalizing([1, 2, 3], $links[1]->lists()->pluck('id')->sort()->toArray());
$this->assertEqualsCanonicalizing([3], $links[2]->lists()->pluck('id')->sort()->toArray());
$this->assertEqualsCanonicalizing([1, 3], $links[0]->tags()->pluck('id')->toArray());
$this->assertEqualsCanonicalizing([1, 2, 3], $links[1]->tags()->pluck('id')->toArray());
$this->assertEqualsCanonicalizing([3], $links[2]->tags()->pluck('id')->toArray());
$this->assertEquals(ModelAttribute::VISIBILITY_PUBLIC, $links[0]->visibility);
$this->assertEquals(ModelAttribute::VISIBILITY_INTERNAL, $links[1]->visibility);
$this->assertEquals(ModelAttribute::VISIBILITY_PRIVATE, $links[2]->visibility);
$this->assertEquals(ModelAttribute::VISIBILITY_PRIVATE, $otherLink->visibility);
}
public function testAlternativeLinksEdit(): void
{
Log::shouldReceive('warning')->once();
$links = $this->prepareLinkTestData();
$otherUser = User::factory()->create();
$otherLink = Link::factory()->for($otherUser)->create(['visibility' => ModelAttribute::VISIBILITY_PRIVATE]);
$this->post('bulk-edit/update-links', [
'models' => '1,2,3,4',
'tags' => '2,3',
'tags_mode' => 'replace',
'lists' => '3',
'lists_mode' => 'replace',
'visibility' => ModelAttribute::VISIBILITY_INTERNAL,
])
->assertRedirect('links')
->assertSessionHas('flash_notification.0.message', 'Successfully updated 3 Links out of 4 selected ones.');
array_walk($links, fn($link) => $link->refresh());
$this->assertEqualsCanonicalizing([3], $links[0]->lists()->pluck('id')->sort()->toArray());
$this->assertEqualsCanonicalizing([3], $links[1]->lists()->pluck('id')->sort()->toArray());
$this->assertEqualsCanonicalizing([3], $links[2]->lists()->pluck('id')->sort()->toArray());
$this->assertEqualsCanonicalizing([2, 3], $links[0]->tags()->pluck('id')->toArray());
$this->assertEqualsCanonicalizing([2, 3], $links[1]->tags()->pluck('id')->toArray());
$this->assertEqualsCanonicalizing([2, 3], $links[2]->tags()->pluck('id')->toArray());
$this->assertEquals(ModelAttribute::VISIBILITY_INTERNAL, $links[0]->visibility);
$this->assertEquals(ModelAttribute::VISIBILITY_INTERNAL, $links[1]->visibility);
$this->assertEquals(ModelAttribute::VISIBILITY_INTERNAL, $links[2]->visibility);
$this->assertEquals(ModelAttribute::VISIBILITY_PRIVATE, $otherLink->visibility);
}
public function testListsEdit(): void
{
Log::shouldReceive('warning')->once();
$lists = $this->createTestLists($this->user);
$otherUser = User::factory()->create();
$otherList = LinkList::factory()->for($otherUser)->create(['visibility' => ModelAttribute::VISIBILITY_PRIVATE]);
$this->post('bulk-edit/update-lists', [
'models' => '1,2,3,4',
'visibility' => null,
])
->assertRedirect('lists')
->assertSessionHas('flash_notification.0.message', 'Successfully updated 3 Lists out of 4 selected ones.');
array_walk($lists, fn($list) => $list->refresh());
$this->assertEquals(ModelAttribute::VISIBILITY_PUBLIC, $lists[0]->visibility);
$this->assertEquals(ModelAttribute::VISIBILITY_INTERNAL, $lists[1]->visibility);
$this->assertEquals(ModelAttribute::VISIBILITY_PRIVATE, $lists[2]->visibility);
$this->assertEquals(ModelAttribute::VISIBILITY_PRIVATE, $otherList->visibility);
}
public function testAlternativeListsEdit(): void
{
Log::shouldReceive('warning')->once();
$lists = $this->createTestLists($this->user);
$otherUser = User::factory()->create();
$otherList = LinkList::factory()->for($otherUser)->create(['visibility' => ModelAttribute::VISIBILITY_PRIVATE]);
$this->post('bulk-edit/update-lists', [
'models' => '1,2,3,4',
'visibility' => 2,
])
->assertRedirect('lists')
->assertSessionHas('flash_notification.0.message', 'Successfully updated 3 Lists out of 4 selected ones.');
array_walk($lists, fn($list) => $list->refresh());
$this->assertEquals(ModelAttribute::VISIBILITY_INTERNAL, $lists[0]->visibility);
$this->assertEquals(ModelAttribute::VISIBILITY_INTERNAL, $lists[1]->visibility);
$this->assertEquals(ModelAttribute::VISIBILITY_INTERNAL, $lists[2]->visibility);
$this->assertEquals(ModelAttribute::VISIBILITY_PRIVATE, $otherList->visibility);
}
public function testTagsEdit(): void
{
Log::shouldReceive('warning')->once();
$tags = $this->createTestTags($this->user);
$otherUser = User::factory()->create();
$otherTag = Tag::factory()->for($otherUser)->create(['visibility' => ModelAttribute::VISIBILITY_PRIVATE]);
$this->post('bulk-edit/update-tags', [
'models' => '1,2,3,4',
'visibility' => null,
])
->assertRedirect('tags')
->assertSessionHas('flash_notification.0.message', 'Successfully updated 3 Tags out of 4 selected ones.');
array_walk($tags, fn($tag) => $tag->refresh());
$this->assertEquals(ModelAttribute::VISIBILITY_PUBLIC, $tags[0]->visibility);
$this->assertEquals(ModelAttribute::VISIBILITY_INTERNAL, $tags[1]->visibility);
$this->assertEquals(ModelAttribute::VISIBILITY_PRIVATE, $tags[2]->visibility);
$this->assertEquals(ModelAttribute::VISIBILITY_PRIVATE, $otherTag->visibility);
}
public function testAlternativeTagsEdit(): void
{
Log::shouldReceive('warning')->once();
$tags = $this->createTestTags($this->user);
$otherUser = User::factory()->create();
$otherTag = Tag::factory()->for($otherUser)->create(['visibility' => ModelAttribute::VISIBILITY_PRIVATE]);
$this->post('bulk-edit/update-tags', [
'models' => '1,2,3,4',
'visibility' => 2,
])
->assertRedirect('tags')
->assertSessionHas('flash_notification.0.message', 'Successfully updated 3 Tags out of 4 selected ones.');
array_walk($tags, fn($tag) => $tag->refresh());
$this->assertEquals(ModelAttribute::VISIBILITY_INTERNAL, $tags[0]->visibility);
$this->assertEquals(ModelAttribute::VISIBILITY_INTERNAL, $tags[1]->visibility);
$this->assertEquals(ModelAttribute::VISIBILITY_INTERNAL, $tags[2]->visibility);
$this->assertEquals(ModelAttribute::VISIBILITY_PRIVATE, $otherTag->visibility);
}
public function testDeletion()
{
Log::shouldReceive('warning')->times(3);
$otherUser = User::factory()->create();
$links = $this->createTestLinks($this->user);
$otherLink = Link::factory()->for($otherUser)->create();
$lists = $this->createTestLists($this->user);
$otherList = LinkList::factory()->for($otherUser)->create();
$tags = $this->createTestTags($this->user);
$otherTag = Tag::factory()->for($otherUser)->create();
$this->post('bulk-edit/delete', [
'models' => '1,2,4',
'type' => 'links',
])
->assertRedirect('links')
->assertSessionHas('flash_notification.0.message', 'Successfully moved 2 Links out of 3 selected ones to the trash.');
array_walk($links, fn($link) => $link->refresh());
$this->assertNotNull($links[0]->deleted_at);
$this->assertNotNull($links[1]->deleted_at);
$this->assertNull($otherLink->deleted_at);
$this->post('bulk-edit/delete', [
'models' => '1,2,4',
'type' => 'lists',
])
->assertRedirect('lists')
->assertSessionHas('flash_notification.1.message', 'Successfully moved 2 Lists out of 3 selected ones to the trash.');
array_walk($lists, fn($list) => $list->refresh());
$this->assertNotNull($lists[0]->deleted_at);
$this->assertNotNull($lists[1]->deleted_at);
$this->assertNull($otherList->deleted_at);
$this->post('bulk-edit/delete', [
'models' => '1,2,4',
'type' => 'tags',
])
->assertRedirect('tags')
->assertSessionHas('flash_notification.2.message', 'Successfully moved 2 Tags out of 3 selected ones to the trash.');
array_walk($tags, fn($tag) => $tag->refresh());
$this->assertNotNull($tags[0]->deleted_at);
$this->assertNotNull($tags[1]->deleted_at);
$this->assertNull($otherTag->deleted_at);
}
protected function prepareLinkTestData(): array
{
$links = $this->createTestLinks($this->user);
$this->createTestTags($this->user);
$this->createTestLists($this->user);
$links[0]->lists()->sync([1]);
$links[0]->tags()->sync([1]);
$links[1]->lists()->sync([1, 2]);
$links[1]->tags()->sync([1, 2]);
return $links;
}
}