mirror of
https://github.com/sissbruecker/linkding
synced 2024-11-10 06:04:15 +00:00
Implement bulk edit (#101)
This commit is contained in:
parent
2e4f271490
commit
0c1c21c8d1
26 changed files with 915 additions and 88 deletions
|
@ -11,9 +11,10 @@ The name comes from:
|
||||||
**Feature Overview:**
|
**Feature Overview:**
|
||||||
- Tags for organizing bookmarks
|
- Tags for organizing bookmarks
|
||||||
- Search by text or tags
|
- Search by text or tags
|
||||||
|
- Bulk editing
|
||||||
|
- Bookmark archive
|
||||||
- Automatically provides titles and descriptions from linked websites
|
- Automatically provides titles and descriptions from linked websites
|
||||||
- Import and export bookmarks in Netscape HTML format
|
- Import and export bookmarks in Netscape HTML format
|
||||||
- Bookmark archive
|
|
||||||
- Extensions for [Firefox](https://addons.mozilla.org/de/firefox/addon/linkding-extension/) and [Chrome](https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe)
|
- Extensions for [Firefox](https://addons.mozilla.org/de/firefox/addon/linkding-extension/) and [Chrome](https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe)
|
||||||
- Bookmarklet that should work in most browsers
|
- Bookmarklet that should work in most browsers
|
||||||
- Dark mode
|
- Dark mode
|
||||||
|
@ -22,7 +23,7 @@ The name comes from:
|
||||||
- Works without Javascript
|
- Works without Javascript
|
||||||
- ...but has several UI enhancements when Javascript is enabled
|
- ...but has several UI enhancements when Javascript is enabled
|
||||||
- REST API for developing 3rd party apps
|
- REST API for developing 3rd party apps
|
||||||
- Admin panel for user self-service and bulk operations
|
- Admin panel for user self-service and raw data access
|
||||||
|
|
||||||
|
|
||||||
**Demo:** https://demo.linkding.link/ (configured with open registration)
|
**Demo:** https://demo.linkding.link/ (configured with open registration)
|
||||||
|
|
|
@ -4,8 +4,10 @@
|
||||||
export let id;
|
export let id;
|
||||||
export let name;
|
export let name;
|
||||||
export let value;
|
export let value;
|
||||||
export let tags;
|
export let apiClient;
|
||||||
|
export let variant = 'default';
|
||||||
|
|
||||||
|
let tags = [];
|
||||||
let isFocus = false;
|
let isFocus = false;
|
||||||
let isOpen = false;
|
let isOpen = false;
|
||||||
let input = null;
|
let input = null;
|
||||||
|
@ -13,6 +15,18 @@
|
||||||
let suggestions = [];
|
let suggestions = [];
|
||||||
let selectedIndex = 0;
|
let selectedIndex = 0;
|
||||||
|
|
||||||
|
init();
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
// For now we cache all tags on load as the template did before
|
||||||
|
try {
|
||||||
|
tags = await apiClient.getTags({limit: 1000, offset: 0});
|
||||||
|
tags.sort((left, right) => left.name.toLowerCase().localeCompare(right.name.toLowerCase()))
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('TagAutocomplete: Error loading tag list');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleFocus() {
|
function handleFocus() {
|
||||||
isFocus = true;
|
isFocus = true;
|
||||||
}
|
}
|
||||||
|
@ -27,7 +41,9 @@
|
||||||
|
|
||||||
const word = getCurrentWord(input);
|
const word = getCurrentWord(input);
|
||||||
|
|
||||||
suggestions = word ? tags.filter(tag => tag.indexOf(word) === 0) : [];
|
suggestions = word
|
||||||
|
? tags.filter(tag => tag.name.toLowerCase().indexOf(word.toLowerCase()) === 0)
|
||||||
|
: [];
|
||||||
|
|
||||||
if (word && suggestions.length > 0) {
|
if (word && suggestions.length > 0) {
|
||||||
open();
|
open();
|
||||||
|
@ -70,7 +86,7 @@
|
||||||
function complete(suggestion) {
|
function complete(suggestion) {
|
||||||
const bounds = getCurrentWordBounds(input);
|
const bounds = getCurrentWordBounds(input);
|
||||||
const value = input.value;
|
const value = input.value;
|
||||||
input.value = value.substring(0, bounds.start) + suggestion + value.substring(bounds.end);
|
input.value = value.substring(0, bounds.start) + suggestion.name + value.substring(bounds.end);
|
||||||
|
|
||||||
close();
|
close();
|
||||||
}
|
}
|
||||||
|
@ -87,11 +103,11 @@
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="form-autocomplete">
|
<div class="form-autocomplete" class:small={variant === 'small'}>
|
||||||
<!-- autocomplete input container -->
|
<!-- autocomplete input container -->
|
||||||
<div class="form-autocomplete-input form-input" class:is-focused={isFocus}>
|
<div class="form-autocomplete-input form-input" class:is-focused={isFocus}>
|
||||||
<!-- autocomplete real input box -->
|
<!-- autocomplete real input box -->
|
||||||
<input id="{id}" name="{name}" value="{value ||''}"
|
<input id="{id}" name="{name}" value="{value ||''}" placeholder=" "
|
||||||
class="form-input" type="text" autocomplete="off"
|
class="form-input" type="text" autocomplete="off"
|
||||||
on:input={handleInput} on:keydown={handleKeyDown}
|
on:input={handleInput} on:keydown={handleKeyDown}
|
||||||
on:focus={handleFocus} on:blur={handleBlur}>
|
on:focus={handleFocus} on:blur={handleBlur}>
|
||||||
|
@ -105,7 +121,7 @@
|
||||||
<a href="#" on:mousedown|preventDefault={() => complete(tag)}>
|
<a href="#" on:mousedown|preventDefault={() => complete(tag)}>
|
||||||
<div class="tile tile-centered">
|
<div class="tile tile-centered">
|
||||||
<div class="tile-content">
|
<div class="tile-content">
|
||||||
{tag}
|
{tag.name}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
@ -124,4 +140,17 @@
|
||||||
.menu.open {
|
.menu.open {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-autocomplete.small .form-autocomplete-input {
|
||||||
|
height: 1.4rem;
|
||||||
|
min-height: 1.4rem;
|
||||||
|
}
|
||||||
|
.form-autocomplete.small .form-autocomplete-input input {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
.form-autocomplete.small .menu .menu-item {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -5,7 +5,7 @@ export class ApiClient {
|
||||||
|
|
||||||
getBookmarks(query, options = {limit: 100, offset: 0}) {
|
getBookmarks(query, options = {limit: 100, offset: 0}) {
|
||||||
const encodedQuery = encodeURIComponent(query)
|
const encodedQuery = encodeURIComponent(query)
|
||||||
const url = `${this.baseUrl}bookmarks?q=${encodedQuery}&limit=${options.limit}&offset=${options.offset}`
|
const url = `${this.baseUrl}bookmarks/?q=${encodedQuery}&limit=${options.limit}&offset=${options.offset}`
|
||||||
|
|
||||||
return fetch(url)
|
return fetch(url)
|
||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
|
@ -14,7 +14,15 @@ export class ApiClient {
|
||||||
|
|
||||||
getArchivedBookmarks(query, options = {limit: 100, offset: 0}) {
|
getArchivedBookmarks(query, options = {limit: 100, offset: 0}) {
|
||||||
const encodedQuery = encodeURIComponent(query)
|
const encodedQuery = encodeURIComponent(query)
|
||||||
const url = `${this.baseUrl}bookmarks/archived?q=${encodedQuery}&limit=${options.limit}&offset=${options.offset}`
|
const url = `${this.baseUrl}bookmarks/archived/?q=${encodedQuery}&limit=${options.limit}&offset=${options.offset}`
|
||||||
|
|
||||||
|
return fetch(url)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => data.results)
|
||||||
|
}
|
||||||
|
|
||||||
|
getTags(options = {limit: 100, offset: 0}) {
|
||||||
|
const url = `${this.baseUrl}tags/?limit=${options.limit}&offset=${options.offset}`
|
||||||
|
|
||||||
return fetch(url)
|
return fetch(url)
|
||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
from typing import Union
|
||||||
|
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
|
@ -46,6 +48,13 @@ def archive_bookmark(bookmark: Bookmark):
|
||||||
return bookmark
|
return bookmark
|
||||||
|
|
||||||
|
|
||||||
|
def archive_bookmarks(bookmark_ids: [Union[int, str]], current_user: User):
|
||||||
|
sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)
|
||||||
|
bookmarks = Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids)
|
||||||
|
|
||||||
|
bookmarks.update(is_archived=True, date_modified=timezone.now())
|
||||||
|
|
||||||
|
|
||||||
def unarchive_bookmark(bookmark: Bookmark):
|
def unarchive_bookmark(bookmark: Bookmark):
|
||||||
bookmark.is_archived = False
|
bookmark.is_archived = False
|
||||||
bookmark.date_modified = timezone.now()
|
bookmark.date_modified = timezone.now()
|
||||||
|
@ -53,6 +62,46 @@ def unarchive_bookmark(bookmark: Bookmark):
|
||||||
return bookmark
|
return bookmark
|
||||||
|
|
||||||
|
|
||||||
|
def unarchive_bookmarks(bookmark_ids: [Union[int, str]], current_user: User):
|
||||||
|
sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)
|
||||||
|
bookmarks = Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids)
|
||||||
|
|
||||||
|
bookmarks.update(is_archived=False, date_modified=timezone.now())
|
||||||
|
|
||||||
|
|
||||||
|
def delete_bookmarks(bookmark_ids: [Union[int, str]], current_user: User):
|
||||||
|
sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)
|
||||||
|
bookmarks = Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids)
|
||||||
|
|
||||||
|
bookmarks.delete()
|
||||||
|
|
||||||
|
|
||||||
|
def tag_bookmarks(bookmark_ids: [Union[int, str]], tag_string: str, current_user: User):
|
||||||
|
sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)
|
||||||
|
bookmarks = Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids)
|
||||||
|
tag_names = parse_tag_string(tag_string, ' ')
|
||||||
|
tags = get_or_create_tags(tag_names, current_user)
|
||||||
|
|
||||||
|
for bookmark in bookmarks:
|
||||||
|
bookmark.tags.add(*tags)
|
||||||
|
bookmark.date_modified = timezone.now()
|
||||||
|
|
||||||
|
Bookmark.objects.bulk_update(bookmarks, ['date_modified'])
|
||||||
|
|
||||||
|
|
||||||
|
def untag_bookmarks(bookmark_ids: [Union[int, str]], tag_string: str, current_user: User):
|
||||||
|
sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)
|
||||||
|
bookmarks = Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids)
|
||||||
|
tag_names = parse_tag_string(tag_string, ' ')
|
||||||
|
tags = get_or_create_tags(tag_names, current_user)
|
||||||
|
|
||||||
|
for bookmark in bookmarks:
|
||||||
|
bookmark.tags.remove(*tags)
|
||||||
|
bookmark.date_modified = timezone.now()
|
||||||
|
|
||||||
|
Bookmark.objects.bulk_update(bookmarks, ['date_modified'])
|
||||||
|
|
||||||
|
|
||||||
def _merge_bookmark_data(from_bookmark: Bookmark, to_bookmark: Bookmark):
|
def _merge_bookmark_data(from_bookmark: Bookmark, to_bookmark: Bookmark):
|
||||||
to_bookmark.title = from_bookmark.title
|
to_bookmark.title = from_bookmark.title
|
||||||
to_bookmark.description = from_bookmark.description
|
to_bookmark.description = from_bookmark.description
|
||||||
|
@ -68,3 +117,8 @@ def _update_bookmark_tags(bookmark: Bookmark, tag_string: str, user: User):
|
||||||
tag_names = parse_tag_string(tag_string, ' ')
|
tag_names = parse_tag_string(tag_string, ' ')
|
||||||
tags = get_or_create_tags(tag_names, user)
|
tags = get_or_create_tags(tag_names, user)
|
||||||
bookmark.tags.set(tags)
|
bookmark.tags.set(tags)
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_id_list(bookmark_ids: [Union[int, str]]) -> [int]:
|
||||||
|
# Convert string ids to int if necessary
|
||||||
|
return [int(bm_id) if isinstance(bm_id, str) else bm_id for bm_id in bookmark_ids]
|
||||||
|
|
86
bookmarks/static/bulk_edit.js
Normal file
86
bookmarks/static/bulk_edit.js
Normal file
|
@ -0,0 +1,86 @@
|
||||||
|
(function () {
|
||||||
|
const bulkEditToggle = document.getElementById('bulk-edit-mode')
|
||||||
|
const bulkEditBar = document.querySelector('.bulk-edit-bar')
|
||||||
|
const singleToggles = document.querySelectorAll('.bulk-edit-toggle input')
|
||||||
|
const allToggle = document.querySelector('.bulk-edit-all-toggle input')
|
||||||
|
|
||||||
|
function isAllSelected() {
|
||||||
|
let result = true
|
||||||
|
|
||||||
|
singleToggles.forEach(function (toggle) {
|
||||||
|
result = result && toggle.checked
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectAll() {
|
||||||
|
singleToggles.forEach(function (toggle) {
|
||||||
|
toggle.checked = true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function deselectAll() {
|
||||||
|
singleToggles.forEach(function (toggle) {
|
||||||
|
toggle.checked = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle all
|
||||||
|
allToggle.addEventListener('change', function (e) {
|
||||||
|
if (e.target.checked) {
|
||||||
|
selectAll()
|
||||||
|
} else {
|
||||||
|
deselectAll()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Toggle single
|
||||||
|
singleToggles.forEach(function (toggle) {
|
||||||
|
toggle.addEventListener('change', function () {
|
||||||
|
allToggle.checked = isAllSelected()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Allow overflow when bulk edit mode is active to be able to display tag auto complete menu
|
||||||
|
let bulkEditToggleTimeout
|
||||||
|
if (bulkEditToggle.checked) {
|
||||||
|
bulkEditBar.style.overflow = 'visible';
|
||||||
|
}
|
||||||
|
bulkEditToggle.addEventListener('change', function (e) {
|
||||||
|
if (bulkEditToggleTimeout) {
|
||||||
|
clearTimeout(bulkEditToggleTimeout);
|
||||||
|
bulkEditToggleTimeout = null;
|
||||||
|
}
|
||||||
|
if (e.target.checked) {
|
||||||
|
bulkEditToggleTimeout = setTimeout(function () {
|
||||||
|
bulkEditBar.style.overflow = 'visible';
|
||||||
|
}, 500);
|
||||||
|
} else {
|
||||||
|
bulkEditBar.style.overflow = 'hidden';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Init tag auto-complete
|
||||||
|
function initTagAutoComplete() {
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
const tagInput = document.getElementById('bulk-edit-tags-input');
|
||||||
|
const apiBaseUrl = document.documentElement.dataset.apiBaseUrl || '';
|
||||||
|
const apiClient = new linkding.ApiClient(apiBaseUrl)
|
||||||
|
|
||||||
|
new linkding.TagAutoComplete({
|
||||||
|
target: wrapper,
|
||||||
|
props: {
|
||||||
|
id: 'bulk-edit-tags-input',
|
||||||
|
name: tagInput.name,
|
||||||
|
value: tagInput.value,
|
||||||
|
apiClient: apiClient,
|
||||||
|
variant: 'small'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tagInput.parentElement.replaceChild(wrapper, tagInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
initTagAutoComplete();
|
||||||
|
})()
|
45
bookmarks/static/shared.js
Normal file
45
bookmarks/static/shared.js
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
(function () {
|
||||||
|
|
||||||
|
function initConfirmationButtons() {
|
||||||
|
const buttonEls = document.querySelectorAll('.btn-confirmation');
|
||||||
|
|
||||||
|
function showConfirmation(buttonEl) {
|
||||||
|
const cancelEl = document.createElement(buttonEl.nodeName);
|
||||||
|
cancelEl.innerText = 'Cancel';
|
||||||
|
cancelEl.className = 'btn btn-link btn-sm btn-confirmation-action mr-1';
|
||||||
|
cancelEl.addEventListener('click', function () {
|
||||||
|
container.remove();
|
||||||
|
buttonEl.style = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
const confirmEl = document.createElement(buttonEl.nodeName);
|
||||||
|
confirmEl.innerText = 'Confirm';
|
||||||
|
confirmEl.className = 'btn btn-link btn-delete btn-sm btn-confirmation-action';
|
||||||
|
|
||||||
|
if (buttonEl.nodeName === 'BUTTON') {
|
||||||
|
confirmEl.type = buttonEl.type;
|
||||||
|
confirmEl.name = buttonEl.name;
|
||||||
|
}
|
||||||
|
if (buttonEl.nodeName === 'A') {
|
||||||
|
confirmEl.href = buttonEl.href;
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = document.createElement('span');
|
||||||
|
container.className = 'confirmation'
|
||||||
|
container.appendChild(cancelEl);
|
||||||
|
container.appendChild(confirmEl);
|
||||||
|
buttonEl.parentElement.insertBefore(container, buttonEl);
|
||||||
|
buttonEl.style = 'display: none';
|
||||||
|
}
|
||||||
|
|
||||||
|
buttonEls.forEach(function (linkEl) {
|
||||||
|
linkEl.addEventListener('click', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
showConfirmation(linkEl);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
initConfirmationButtons()
|
||||||
|
})()
|
|
@ -1,5 +1,10 @@
|
||||||
body {
|
body {
|
||||||
margin: 20px 10px;
|
margin: 20px 10px;
|
||||||
|
|
||||||
|
@media (min-width: $size-sm) {
|
||||||
|
// High horizontal padding accounts for checkboxes that show up in bulk edit mode
|
||||||
|
margin: 20px 24px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
header {
|
header {
|
||||||
|
|
|
@ -30,6 +30,12 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bookmarks-page .content-area-header {
|
||||||
|
span.btn {
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ul.bookmark-list {
|
ul.bookmark-list {
|
||||||
|
|
||||||
list-style: none;
|
list-style: none;
|
||||||
|
@ -57,11 +63,8 @@ ul.bookmark-list {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions .btn-link.bm-remove-confirm {
|
.bulk-edit-toggle {
|
||||||
color: $error-color;
|
display: none;
|
||||||
&:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -103,3 +106,100 @@ ul.bookmark-list {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Bulk edit */
|
||||||
|
$bulk-edit-toggle-width: 16px;
|
||||||
|
$bulk-edit-toggle-offset: 8px;
|
||||||
|
$bulk-edit-bar-offset: $bulk-edit-toggle-width + (2 * $bulk-edit-toggle-offset);
|
||||||
|
$bulk-edit-transition-duration: 400ms;
|
||||||
|
|
||||||
|
.bulk-edit-form {
|
||||||
|
|
||||||
|
.bulk-edit-bar {
|
||||||
|
margin-top: -17px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
margin-left: -$bulk-edit-bar-offset;
|
||||||
|
max-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: max-height $bulk-edit-transition-duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-edit-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
padding: 4px 0;
|
||||||
|
border-top: solid 1px $border-color;
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
> label.form-checkbox {
|
||||||
|
min-height: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
> button {
|
||||||
|
padding: 0;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
> span {
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
> input, .form-autocomplete {
|
||||||
|
width: auto;
|
||||||
|
margin-left: 4px;
|
||||||
|
max-width: 200px;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
span.confirmation {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
span.confirmation button {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-edit-all-toggle {
|
||||||
|
width: $bulk-edit-toggle-width;
|
||||||
|
margin: 0 0 0 $bulk-edit-toggle-offset;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul.bookmark-list li {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul.bookmark-list li .bulk-edit-toggle {
|
||||||
|
display: block;
|
||||||
|
position: absolute;
|
||||||
|
width: $bulk-edit-toggle-width;
|
||||||
|
left: -$bulk-edit-toggle-width - $bulk-edit-toggle-offset;
|
||||||
|
top: 0;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
transition: all $bulk-edit-transition-duration;
|
||||||
|
|
||||||
|
i {
|
||||||
|
top: 0.2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#bulk-edit-mode {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#bulk-edit-mode:checked ~ .bookmarks-page .bulk-edit-toggle {
|
||||||
|
visibility: visible;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#bulk-edit-mode:checked ~ .bookmarks-page .bulk-edit-bar {
|
||||||
|
max-height: 37px;
|
||||||
|
border-bottom: solid 1px $border-color;
|
||||||
|
}
|
|
@ -21,6 +21,11 @@ a:focus, .btn:focus {
|
||||||
background: darken($error-color, 40%);
|
background: darken($error-color, 40%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-checkbox input:checked + .form-icon, .form-radio input:checked + .form-icon, .form-switch input:checked + .form-icon {
|
||||||
|
background: $dt-primary-button-color;
|
||||||
|
border-color: $dt-primary-button-color;
|
||||||
|
}
|
||||||
|
|
||||||
/* Pagination */
|
/* Pagination */
|
||||||
.pagination .page-item.active a {
|
.pagination .page-item.active a {
|
||||||
background: $dt-primary-button-color;
|
background: $dt-primary-button-color;
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
// Content area component
|
||||||
section.content-area {
|
section.content-area {
|
||||||
|
|
||||||
.content-area-header {
|
.content-area-header {
|
||||||
|
@ -11,3 +12,11 @@ section.content-area {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Confirm button component
|
||||||
|
.btn-confirmation-action {
|
||||||
|
color: $error-color !important;
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
|
@ -7,3 +7,11 @@
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.text-sm {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-gray-dark {
|
||||||
|
color: $gray-color-dark;
|
||||||
|
}
|
|
@ -4,6 +4,9 @@
|
||||||
{% load bookmarks %}
|
{% load bookmarks %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
|
{% include 'bookmarks/bulk_edit/state.html' %}
|
||||||
|
|
||||||
<div class="bookmarks-page columns">
|
<div class="bookmarks-page columns">
|
||||||
|
|
||||||
{# Bookmark list #}
|
{# Bookmark list #}
|
||||||
|
@ -12,13 +15,20 @@
|
||||||
<h2>Archived bookmarks</h2>
|
<h2>Archived bookmarks</h2>
|
||||||
<div class="spacer"></div>
|
<div class="spacer"></div>
|
||||||
{% bookmark_search query tags mode='archive' %}
|
{% bookmark_search query tags mode='archive' %}
|
||||||
|
{% include 'bookmarks/bulk_edit/toggle.html' %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if empty %}
|
<form class="bulk-edit-form" action="{% url 'bookmarks:bulk_edit' %}?return_url={{ return_url }}"
|
||||||
{% include 'bookmarks/empty_bookmarks.html' %}
|
method="post">
|
||||||
{% else %}
|
{% csrf_token %}
|
||||||
{% bookmark_list bookmarks return_url %}
|
{% include 'bookmarks/bulk_edit/bar.html' with mode='archive' %}
|
||||||
{% endif %}
|
|
||||||
|
{% if empty %}
|
||||||
|
{% include 'bookmarks/empty_bookmarks.html' %}
|
||||||
|
{% else %}
|
||||||
|
{% bookmark_list bookmarks return_url %}
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{# Tag list #}
|
{# Tag list #}
|
||||||
|
@ -31,4 +41,6 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="{% static "bundle.js" %}"></script>
|
<script src="{% static "bundle.js" %}"></script>
|
||||||
|
<script src="{% static "shared.js" %}"></script>
|
||||||
|
<script src="{% static "bulk_edit.js" %}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -4,16 +4,20 @@
|
||||||
<ul class="bookmark-list">
|
<ul class="bookmark-list">
|
||||||
{% for bookmark in bookmarks %}
|
{% for bookmark in bookmarks %}
|
||||||
<li>
|
<li>
|
||||||
|
<label class="form-checkbox bulk-edit-toggle">
|
||||||
|
<input type="checkbox" name="bookmark_id" value="{{ bookmark.id }}">
|
||||||
|
<i class="form-icon"></i>
|
||||||
|
</label>
|
||||||
<div class="title truncate">
|
<div class="title truncate">
|
||||||
<a href="{{ bookmark.url }}" target="_blank" rel="noopener">{{ bookmark.resolved_title }}</a>
|
<a href="{{ bookmark.url }}" target="_blank" rel="noopener">{{ bookmark.resolved_title }}</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="description truncate">
|
<div class="description truncate">
|
||||||
{% if bookmark.tag_names %}
|
{% if bookmark.tag_names %}
|
||||||
<span>
|
<span>
|
||||||
{% for tag_name in bookmark.tag_names %}
|
{% for tag_name in bookmark.tag_names %}
|
||||||
<a href="?{% append_query_param q=tag_name|hash_tag %}">{{ tag_name|hash_tag }}</a>
|
<a href="?{% append_query_param q=tag_name|hash_tag %}">{{ tag_name|hash_tag }}</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if bookmark.tag_names and bookmark.resolved_description %} | {% endif %}
|
{% if bookmark.tag_names and bookmark.resolved_description %} | {% endif %}
|
||||||
|
|
||||||
|
@ -32,7 +36,7 @@
|
||||||
class="btn btn-link btn-sm">Archive</a>
|
class="btn btn-link btn-sm">Archive</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="{% url 'bookmarks:remove' bookmark.id %}?return_url={{ return_url }}"
|
<a href="{% url 'bookmarks:remove' bookmark.id %}?return_url={{ return_url }}"
|
||||||
class="btn btn-link btn-sm bm-remove">Remove</a>
|
class="btn btn-link btn-sm btn-confirmation">Remove</a>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
@ -40,39 +44,4 @@
|
||||||
|
|
||||||
<div class="bookmark-pagination">
|
<div class="bookmark-pagination">
|
||||||
{% pagination bookmarks %}
|
{% pagination bookmarks %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Enhance delete links to show inline confirmation #}
|
|
||||||
<script type="application/javascript">
|
|
||||||
window.addEventListener("load", function () {
|
|
||||||
const linkEls = document.querySelectorAll('.bookmark-list a.bm-remove');
|
|
||||||
|
|
||||||
function showConfirmation(linkEl) {
|
|
||||||
const cancelEl = document.createElement('span');
|
|
||||||
cancelEl.innerText = 'Cancel';
|
|
||||||
cancelEl.className = 'btn btn-link btn-sm bm-remove-confirm mr-1';
|
|
||||||
cancelEl.addEventListener('click', function() {
|
|
||||||
container.remove();
|
|
||||||
linkEl.style = '';
|
|
||||||
});
|
|
||||||
|
|
||||||
const confirmEl = document.createElement('a');
|
|
||||||
confirmEl.innerText = 'Confirm';
|
|
||||||
confirmEl.className = 'btn btn-link btn-delete btn-sm bm-remove-confirm';
|
|
||||||
confirmEl.href = linkEl.href;
|
|
||||||
|
|
||||||
const container = document.createElement('span');
|
|
||||||
container.appendChild(cancelEl);
|
|
||||||
container.appendChild(confirmEl);
|
|
||||||
linkEl.parentElement.appendChild(container);
|
|
||||||
linkEl.style = 'display: none';
|
|
||||||
}
|
|
||||||
|
|
||||||
linkEls.forEach(function (linkEl) {
|
|
||||||
linkEl.addEventListener('click', function (e) {
|
|
||||||
e.preventDefault();
|
|
||||||
showConfirmation(linkEl);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
31
bookmarks/templates/bookmarks/bulk_edit/bar.html
Normal file
31
bookmarks/templates/bookmarks/bulk_edit/bar.html
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
<div class="bulk-edit-bar">
|
||||||
|
<div class="bulk-edit-actions bg-gray">
|
||||||
|
<label class="form-checkbox bulk-edit-all-toggle">
|
||||||
|
<input type="checkbox" style="display: none">
|
||||||
|
<i class="form-icon"></i>
|
||||||
|
</label>
|
||||||
|
{% if mode == 'archive' %}
|
||||||
|
<button type="submit" name="bulk_unarchive" class="btn btn-link btn-sm btn-confirmation"
|
||||||
|
title="Unarchive selected bookmarks">Unarchive
|
||||||
|
</button>
|
||||||
|
{% else %}
|
||||||
|
<button type="submit" name="bulk_archive" class="btn btn-link btn-sm btn-confirmation"
|
||||||
|
title="Archive selected bookmarks">Archive
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
<span class="text-sm text-gray-dark">•</span>
|
||||||
|
<button type="submit" name="bulk_delete" class="btn btn-link btn-sm btn-confirmation"
|
||||||
|
title="Delete selected bookmarks">Delete
|
||||||
|
</button>
|
||||||
|
<span class="text-sm text-gray-dark">•</span>
|
||||||
|
<span class="text-sm text-gray-dark"><label for="bulk-edit-tags-input">Tags:</label></span>
|
||||||
|
<input id="bulk-edit-tags-input" name="bulk_tag_string" class="form-input input-sm"
|
||||||
|
placeholder=" ">
|
||||||
|
<button type="submit" name="bulk_tag" class="btn btn-link btn-sm"
|
||||||
|
title="Add tags to selected bookmarks">Add
|
||||||
|
</button>
|
||||||
|
<button type="submit" name="bulk_untag" class="btn btn-link btn-sm"
|
||||||
|
title="Remove tags from selected bookmarks">Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
1
bookmarks/templates/bookmarks/bulk_edit/state.html
Normal file
1
bookmarks/templates/bookmarks/bulk_edit/state.html
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<input id="bulk-edit-mode" type="checkbox">
|
8
bookmarks/templates/bookmarks/bulk_edit/toggle.html
Normal file
8
bookmarks/templates/bookmarks/bulk_edit/toggle.html
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
<label for="bulk-edit-mode" class="hide-sm">
|
||||||
|
<span class="btn" title="Bulk edit">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" width="20px"
|
||||||
|
height="20px">
|
||||||
|
<path d="M7 3a1 1 0 000 2h6a1 1 0 100-2H7zM4 7a1 1 0 011-1h10a1 1 0 110 2H5a1 1 0 01-1-1zM2 11a2 2 0 012-2h12a2 2 0 012 2v4a2 2 0 01-2 2H4a2 2 0 01-2-2v-4z"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</label>
|
|
@ -8,7 +8,7 @@
|
||||||
<h2>Edit bookmark</h2>
|
<h2>Edit bookmark</h2>
|
||||||
</div>
|
</div>
|
||||||
<form action="{% url 'bookmarks:edit' bookmark_id %}" method="post" class="col-6 col-md-12" novalidate>
|
<form action="{% url 'bookmarks:edit' bookmark_id %}" method="post" class="col-6 col-md-12" novalidate>
|
||||||
{% bookmark_form form all_tags return_url bookmark_id %}
|
{% bookmark_form form return_url bookmark_id %}
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -64,8 +64,7 @@
|
||||||
<script type="application/javascript">
|
<script type="application/javascript">
|
||||||
const wrapper = document.createElement('div');
|
const wrapper = document.createElement('div');
|
||||||
const tagInput = document.getElementById('{{ form.tag_string.id_for_label }}');
|
const tagInput = document.getElementById('{{ form.tag_string.id_for_label }}');
|
||||||
const allTagsString = '{{ all_tags }}';
|
const apiClient = new linkding.ApiClient('{% url 'bookmarks:api-root' %}')
|
||||||
const allTags = allTagsString.split(' ');
|
|
||||||
|
|
||||||
new linkding.TagAutoComplete({
|
new linkding.TagAutoComplete({
|
||||||
target: wrapper,
|
target: wrapper,
|
||||||
|
@ -73,7 +72,7 @@
|
||||||
id: '{{ form.tag_string.id_for_label }}',
|
id: '{{ form.tag_string.id_for_label }}',
|
||||||
name: '{{ form.tag_string.name }}',
|
name: '{{ form.tag_string.name }}',
|
||||||
value: tagInput.value,
|
value: tagInput.value,
|
||||||
tags: allTags
|
apiClient: apiClient
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -4,6 +4,9 @@
|
||||||
{% load bookmarks %}
|
{% load bookmarks %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
|
{% include 'bookmarks/bulk_edit/state.html' %}
|
||||||
|
|
||||||
<div class="bookmarks-page columns">
|
<div class="bookmarks-page columns">
|
||||||
|
|
||||||
{# Bookmark list #}
|
{# Bookmark list #}
|
||||||
|
@ -12,13 +15,20 @@
|
||||||
<h2>Bookmarks</h2>
|
<h2>Bookmarks</h2>
|
||||||
<div class="spacer"></div>
|
<div class="spacer"></div>
|
||||||
{% bookmark_search query tags %}
|
{% bookmark_search query tags %}
|
||||||
|
{% include 'bookmarks/bulk_edit/toggle.html' %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if empty %}
|
<form class="bulk-edit-form" action="{% url 'bookmarks:bulk_edit' %}?return_url={{ return_url }}"
|
||||||
{% include 'bookmarks/empty_bookmarks.html' %}
|
method="post">
|
||||||
{% else %}
|
{% csrf_token %}
|
||||||
{% bookmark_list bookmarks return_url %}
|
{% include 'bookmarks/bulk_edit/bar.html' with mode='default' %}
|
||||||
{% endif %}
|
|
||||||
|
{% if empty %}
|
||||||
|
{% include 'bookmarks/empty_bookmarks.html' %}
|
||||||
|
{% else %}
|
||||||
|
{% bookmark_list bookmarks return_url %}
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{# Tag list #}
|
{# Tag list #}
|
||||||
|
@ -31,4 +41,6 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="{% static "bundle.js" %}"></script>
|
<script src="{% static "bundle.js" %}"></script>
|
||||||
|
<script src="{% static "shared.js" %}"></script>
|
||||||
|
<script src="{% static "bulk_edit.js" %}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -2,7 +2,8 @@
|
||||||
{% load sass_tags %}
|
{% load sass_tags %}
|
||||||
|
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
{# Use data attributes as storage for access in static scripts #}
|
||||||
|
<html lang="en" data-api-base-url="{% url 'bookmarks:api-root' %}">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<link rel="icon" href="{% static 'favicon.png' %}"/>
|
<link rel="icon" href="{% static 'favicon.png' %}"/>
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
<h2>New bookmark</h2>
|
<h2>New bookmark</h2>
|
||||||
</div>
|
</div>
|
||||||
<form action="{% url 'bookmarks:new' %}" method="post" class="col-6 col-md-12" novalidate>
|
<form action="{% url 'bookmarks:new' %}" method="post" class="col-6 col-md-12" novalidate>
|
||||||
{% bookmark_form form all_tags return_url auto_close=auto_close %}
|
{% bookmark_form form return_url auto_close=auto_close %}
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -9,15 +9,10 @@ register = template.Library()
|
||||||
|
|
||||||
|
|
||||||
@register.inclusion_tag('bookmarks/form.html', name='bookmark_form')
|
@register.inclusion_tag('bookmarks/form.html', name='bookmark_form')
|
||||||
def bookmark_form(form: BookmarkForm, all_tags: List[Tag], cancel_url: str, bookmark_id: int = 0, auto_close: bool = False):
|
def bookmark_form(form: BookmarkForm, cancel_url: str, bookmark_id: int = 0, auto_close: bool = False):
|
||||||
|
|
||||||
all_tag_names = [tag.name for tag in all_tags]
|
|
||||||
all_tags_string = build_tag_string(all_tag_names, ' ')
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'form': form,
|
'form': form,
|
||||||
'auto_close': auto_close,
|
'auto_close': auto_close,
|
||||||
'all_tags': all_tags_string,
|
|
||||||
'bookmark_id': bookmark_id,
|
'bookmark_id': bookmark_id,
|
||||||
'cancel_url': cancel_url
|
'cancel_url': cancel_url
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,18 +2,20 @@ from django.contrib.auth import get_user_model
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from bookmarks.models import Bookmark
|
from bookmarks.models import Bookmark, Tag
|
||||||
from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark
|
from bookmarks.services.bookmarks import archive_bookmark, archive_bookmarks, unarchive_bookmark, unarchive_bookmarks, \
|
||||||
|
delete_bookmarks, tag_bookmarks, untag_bookmarks
|
||||||
|
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
class BookmarkServiceTestCase(TestCase):
|
class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
|
||||||
|
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
self.user = User.objects.create_user('testuser', 'test@example.com', 'password123')
|
self.user = User.objects.create_user('testuser', 'test@example.com', 'password123')
|
||||||
|
|
||||||
def test_archive(self):
|
def test_archive_bookmark(self):
|
||||||
bookmark = Bookmark(
|
bookmark = Bookmark(
|
||||||
url='https://example.com',
|
url='https://example.com',
|
||||||
date_added=timezone.now(),
|
date_added=timezone.now(),
|
||||||
|
@ -30,7 +32,7 @@ class BookmarkServiceTestCase(TestCase):
|
||||||
|
|
||||||
self.assertTrue(updated_bookmark.is_archived)
|
self.assertTrue(updated_bookmark.is_archived)
|
||||||
|
|
||||||
def test_unarchive(self):
|
def test_unarchive_bookmark(self):
|
||||||
bookmark = Bookmark(
|
bookmark = Bookmark(
|
||||||
url='https://example.com',
|
url='https://example.com',
|
||||||
date_added=timezone.now(),
|
date_added=timezone.now(),
|
||||||
|
@ -45,3 +47,297 @@ class BookmarkServiceTestCase(TestCase):
|
||||||
updated_bookmark = Bookmark.objects.get(id=bookmark.id)
|
updated_bookmark = Bookmark.objects.get(id=bookmark.id)
|
||||||
|
|
||||||
self.assertFalse(updated_bookmark.is_archived)
|
self.assertFalse(updated_bookmark.is_archived)
|
||||||
|
|
||||||
|
def test_archive_bookmarks(self):
|
||||||
|
bookmark1 = self.setup_bookmark()
|
||||||
|
bookmark2 = self.setup_bookmark()
|
||||||
|
bookmark3 = self.setup_bookmark()
|
||||||
|
|
||||||
|
archive_bookmarks([bookmark1.id, bookmark2.id, bookmark3.id], self.get_or_create_test_user())
|
||||||
|
|
||||||
|
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).is_archived)
|
||||||
|
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).is_archived)
|
||||||
|
self.assertTrue(Bookmark.objects.get(id=bookmark3.id).is_archived)
|
||||||
|
|
||||||
|
def test_archive_bookmarks_should_only_archive_specified_bookmarks(self):
|
||||||
|
bookmark1 = self.setup_bookmark()
|
||||||
|
bookmark2 = self.setup_bookmark()
|
||||||
|
bookmark3 = self.setup_bookmark()
|
||||||
|
|
||||||
|
archive_bookmarks([bookmark1.id, bookmark3.id], self.get_or_create_test_user())
|
||||||
|
|
||||||
|
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).is_archived)
|
||||||
|
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).is_archived)
|
||||||
|
self.assertTrue(Bookmark.objects.get(id=bookmark3.id).is_archived)
|
||||||
|
|
||||||
|
def test_archive_bookmarks_should_only_archive_user_owned_bookmarks(self):
|
||||||
|
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
|
||||||
|
bookmark1 = self.setup_bookmark()
|
||||||
|
bookmark2 = self.setup_bookmark()
|
||||||
|
inaccessible_bookmark = self.setup_bookmark(user=other_user)
|
||||||
|
|
||||||
|
archive_bookmarks([bookmark1.id, bookmark2.id, inaccessible_bookmark.id], self.get_or_create_test_user())
|
||||||
|
|
||||||
|
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).is_archived)
|
||||||
|
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).is_archived)
|
||||||
|
self.assertFalse(Bookmark.objects.get(id=inaccessible_bookmark.id).is_archived)
|
||||||
|
|
||||||
|
def test_archive_bookmarks_should_accept_mix_of_int_and_string_ids(self):
|
||||||
|
bookmark1 = self.setup_bookmark()
|
||||||
|
bookmark2 = self.setup_bookmark()
|
||||||
|
bookmark3 = self.setup_bookmark()
|
||||||
|
|
||||||
|
archive_bookmarks([str(bookmark1.id), bookmark2.id, str(bookmark3.id)], self.get_or_create_test_user())
|
||||||
|
|
||||||
|
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).is_archived)
|
||||||
|
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).is_archived)
|
||||||
|
self.assertTrue(Bookmark.objects.get(id=bookmark3.id).is_archived)
|
||||||
|
|
||||||
|
def test_unarchive_bookmarks(self):
|
||||||
|
bookmark1 = self.setup_bookmark(is_archived=True)
|
||||||
|
bookmark2 = self.setup_bookmark(is_archived=True)
|
||||||
|
bookmark3 = self.setup_bookmark(is_archived=True)
|
||||||
|
|
||||||
|
unarchive_bookmarks([bookmark1.id, bookmark2.id, bookmark3.id], self.get_or_create_test_user())
|
||||||
|
|
||||||
|
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).is_archived)
|
||||||
|
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).is_archived)
|
||||||
|
self.assertFalse(Bookmark.objects.get(id=bookmark3.id).is_archived)
|
||||||
|
|
||||||
|
def test_unarchive_bookmarks_should_only_unarchive_specified_bookmarks(self):
|
||||||
|
bookmark1 = self.setup_bookmark(is_archived=True)
|
||||||
|
bookmark2 = self.setup_bookmark(is_archived=True)
|
||||||
|
bookmark3 = self.setup_bookmark(is_archived=True)
|
||||||
|
|
||||||
|
unarchive_bookmarks([bookmark1.id, bookmark3.id], self.get_or_create_test_user())
|
||||||
|
|
||||||
|
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).is_archived)
|
||||||
|
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).is_archived)
|
||||||
|
self.assertFalse(Bookmark.objects.get(id=bookmark3.id).is_archived)
|
||||||
|
|
||||||
|
def test_unarchive_bookmarks_should_only_unarchive_user_owned_bookmarks(self):
|
||||||
|
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
|
||||||
|
bookmark1 = self.setup_bookmark(is_archived=True)
|
||||||
|
bookmark2 = self.setup_bookmark(is_archived=True)
|
||||||
|
inaccessible_bookmark = self.setup_bookmark(is_archived=True, user=other_user)
|
||||||
|
|
||||||
|
unarchive_bookmarks([bookmark1.id, bookmark2.id, inaccessible_bookmark.id], self.get_or_create_test_user())
|
||||||
|
|
||||||
|
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).is_archived)
|
||||||
|
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).is_archived)
|
||||||
|
self.assertTrue(Bookmark.objects.get(id=inaccessible_bookmark.id).is_archived)
|
||||||
|
|
||||||
|
def test_unarchive_bookmarks_should_accept_mix_of_int_and_string_ids(self):
|
||||||
|
bookmark1 = self.setup_bookmark(is_archived=True)
|
||||||
|
bookmark2 = self.setup_bookmark(is_archived=True)
|
||||||
|
bookmark3 = self.setup_bookmark(is_archived=True)
|
||||||
|
|
||||||
|
unarchive_bookmarks([str(bookmark1.id), bookmark2.id, str(bookmark3.id)], self.get_or_create_test_user())
|
||||||
|
|
||||||
|
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).is_archived)
|
||||||
|
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).is_archived)
|
||||||
|
self.assertFalse(Bookmark.objects.get(id=bookmark3.id).is_archived)
|
||||||
|
|
||||||
|
def test_delete_bookmarks(self):
|
||||||
|
bookmark1 = self.setup_bookmark()
|
||||||
|
bookmark2 = self.setup_bookmark()
|
||||||
|
bookmark3 = self.setup_bookmark()
|
||||||
|
|
||||||
|
delete_bookmarks([bookmark1.id, bookmark2.id, bookmark3.id], self.get_or_create_test_user())
|
||||||
|
|
||||||
|
self.assertIsNone(Bookmark.objects.filter(id=bookmark1.id).first())
|
||||||
|
self.assertIsNone(Bookmark.objects.filter(id=bookmark2.id).first())
|
||||||
|
self.assertIsNone(Bookmark.objects.filter(id=bookmark3.id).first())
|
||||||
|
|
||||||
|
def test_delete_bookmarks_should_only_delete_specified_bookmarks(self):
|
||||||
|
bookmark1 = self.setup_bookmark()
|
||||||
|
bookmark2 = self.setup_bookmark()
|
||||||
|
bookmark3 = self.setup_bookmark()
|
||||||
|
|
||||||
|
delete_bookmarks([bookmark1.id, bookmark3.id], self.get_or_create_test_user())
|
||||||
|
|
||||||
|
self.assertIsNone(Bookmark.objects.filter(id=bookmark1.id).first())
|
||||||
|
self.assertIsNotNone(Bookmark.objects.filter(id=bookmark2.id).first())
|
||||||
|
self.assertIsNone(Bookmark.objects.filter(id=bookmark3.id).first())
|
||||||
|
|
||||||
|
def test_delete_bookmarks_should_only_delete_user_owned_bookmarks(self):
|
||||||
|
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
|
||||||
|
bookmark1 = self.setup_bookmark()
|
||||||
|
bookmark2 = self.setup_bookmark()
|
||||||
|
inaccessible_bookmark = self.setup_bookmark(user=other_user)
|
||||||
|
|
||||||
|
delete_bookmarks([bookmark1.id, bookmark2.id, inaccessible_bookmark.id], self.get_or_create_test_user())
|
||||||
|
|
||||||
|
self.assertIsNone(Bookmark.objects.filter(id=bookmark1.id).first())
|
||||||
|
self.assertIsNone(Bookmark.objects.filter(id=bookmark2.id).first())
|
||||||
|
self.assertIsNotNone(Bookmark.objects.filter(id=inaccessible_bookmark.id).first())
|
||||||
|
|
||||||
|
def test_delete_bookmarks_should_accept_mix_of_int_and_string_ids(self):
|
||||||
|
bookmark1 = self.setup_bookmark()
|
||||||
|
bookmark2 = self.setup_bookmark()
|
||||||
|
bookmark3 = self.setup_bookmark()
|
||||||
|
|
||||||
|
delete_bookmarks([bookmark1.id, bookmark2.id, bookmark3.id], self.get_or_create_test_user())
|
||||||
|
|
||||||
|
self.assertIsNone(Bookmark.objects.filter(id=bookmark1.id).first())
|
||||||
|
self.assertIsNone(Bookmark.objects.filter(id=bookmark2.id).first())
|
||||||
|
self.assertIsNone(Bookmark.objects.filter(id=bookmark3.id).first())
|
||||||
|
|
||||||
|
def test_tag_bookmarks(self):
|
||||||
|
bookmark1 = self.setup_bookmark()
|
||||||
|
bookmark2 = self.setup_bookmark()
|
||||||
|
bookmark3 = self.setup_bookmark()
|
||||||
|
tag1 = self.setup_tag()
|
||||||
|
tag2 = self.setup_tag()
|
||||||
|
|
||||||
|
tag_bookmarks([bookmark1.id, bookmark2.id, bookmark3.id], f'{tag1.name} {tag2.name}',
|
||||||
|
self.get_or_create_test_user())
|
||||||
|
|
||||||
|
bookmark1.refresh_from_db()
|
||||||
|
bookmark2.refresh_from_db()
|
||||||
|
bookmark3.refresh_from_db()
|
||||||
|
|
||||||
|
self.assertCountEqual(bookmark1.tags.all(), [tag1, tag2])
|
||||||
|
self.assertCountEqual(bookmark2.tags.all(), [tag1, tag2])
|
||||||
|
self.assertCountEqual(bookmark3.tags.all(), [tag1, tag2])
|
||||||
|
|
||||||
|
def test_tag_bookmarks_should_create_tags(self):
|
||||||
|
bookmark1 = self.setup_bookmark()
|
||||||
|
bookmark2 = self.setup_bookmark()
|
||||||
|
bookmark3 = self.setup_bookmark()
|
||||||
|
|
||||||
|
tag_bookmarks([bookmark1.id, bookmark2.id, bookmark3.id], 'tag1 tag2', self.get_or_create_test_user())
|
||||||
|
|
||||||
|
bookmark1.refresh_from_db()
|
||||||
|
bookmark2.refresh_from_db()
|
||||||
|
bookmark3.refresh_from_db()
|
||||||
|
|
||||||
|
self.assertEqual(2, Tag.objects.count())
|
||||||
|
|
||||||
|
tag1 = Tag.objects.filter(name='tag1').first()
|
||||||
|
tag2 = Tag.objects.filter(name='tag2').first()
|
||||||
|
|
||||||
|
self.assertIsNotNone(tag1)
|
||||||
|
self.assertIsNotNone(tag2)
|
||||||
|
|
||||||
|
self.assertCountEqual(bookmark1.tags.all(), [tag1, tag2])
|
||||||
|
self.assertCountEqual(bookmark2.tags.all(), [tag1, tag2])
|
||||||
|
self.assertCountEqual(bookmark3.tags.all(), [tag1, tag2])
|
||||||
|
|
||||||
|
def test_tag_bookmarks_should_only_tag_specified_bookmarks(self):
|
||||||
|
bookmark1 = self.setup_bookmark()
|
||||||
|
bookmark2 = self.setup_bookmark()
|
||||||
|
bookmark3 = self.setup_bookmark()
|
||||||
|
tag1 = self.setup_tag()
|
||||||
|
tag2 = self.setup_tag()
|
||||||
|
|
||||||
|
tag_bookmarks([bookmark1.id, bookmark3.id], f'{tag1.name} {tag2.name}', self.get_or_create_test_user())
|
||||||
|
|
||||||
|
bookmark1.refresh_from_db()
|
||||||
|
bookmark2.refresh_from_db()
|
||||||
|
bookmark3.refresh_from_db()
|
||||||
|
|
||||||
|
self.assertCountEqual(bookmark1.tags.all(), [tag1, tag2])
|
||||||
|
self.assertCountEqual(bookmark2.tags.all(), [])
|
||||||
|
self.assertCountEqual(bookmark3.tags.all(), [tag1, tag2])
|
||||||
|
|
||||||
|
def test_tag_bookmarks_should_only_tag_user_owned_bookmarks(self):
|
||||||
|
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
|
||||||
|
bookmark1 = self.setup_bookmark()
|
||||||
|
bookmark2 = self.setup_bookmark()
|
||||||
|
inaccessible_bookmark = self.setup_bookmark(user=other_user)
|
||||||
|
tag1 = self.setup_tag()
|
||||||
|
tag2 = self.setup_tag()
|
||||||
|
|
||||||
|
tag_bookmarks([bookmark1.id, bookmark2.id, inaccessible_bookmark.id], f'{tag1.name} {tag2.name}',
|
||||||
|
self.get_or_create_test_user())
|
||||||
|
|
||||||
|
bookmark1.refresh_from_db()
|
||||||
|
bookmark2.refresh_from_db()
|
||||||
|
inaccessible_bookmark.refresh_from_db()
|
||||||
|
|
||||||
|
self.assertCountEqual(bookmark1.tags.all(), [tag1, tag2])
|
||||||
|
self.assertCountEqual(bookmark2.tags.all(), [tag1, tag2])
|
||||||
|
self.assertCountEqual(inaccessible_bookmark.tags.all(), [])
|
||||||
|
|
||||||
|
def test_tag_bookmarks_should_accept_mix_of_int_and_string_ids(self):
|
||||||
|
bookmark1 = self.setup_bookmark()
|
||||||
|
bookmark2 = self.setup_bookmark()
|
||||||
|
bookmark3 = self.setup_bookmark()
|
||||||
|
tag1 = self.setup_tag()
|
||||||
|
tag2 = self.setup_tag()
|
||||||
|
|
||||||
|
tag_bookmarks([str(bookmark1.id), bookmark2.id, str(bookmark3.id)], f'{tag1.name} {tag2.name}',
|
||||||
|
self.get_or_create_test_user())
|
||||||
|
|
||||||
|
self.assertCountEqual(bookmark1.tags.all(), [tag1, tag2])
|
||||||
|
self.assertCountEqual(bookmark2.tags.all(), [tag1, tag2])
|
||||||
|
self.assertCountEqual(bookmark3.tags.all(), [tag1, tag2])
|
||||||
|
|
||||||
|
def test_untag_bookmarks(self):
|
||||||
|
tag1 = self.setup_tag()
|
||||||
|
tag2 = self.setup_tag()
|
||||||
|
bookmark1 = self.setup_bookmark(tags=[tag1, tag2])
|
||||||
|
bookmark2 = self.setup_bookmark(tags=[tag1, tag2])
|
||||||
|
bookmark3 = self.setup_bookmark(tags=[tag1, tag2])
|
||||||
|
|
||||||
|
untag_bookmarks([bookmark1.id, bookmark2.id, bookmark3.id], f'{tag1.name} {tag2.name}',
|
||||||
|
self.get_or_create_test_user())
|
||||||
|
|
||||||
|
bookmark1.refresh_from_db()
|
||||||
|
bookmark2.refresh_from_db()
|
||||||
|
bookmark3.refresh_from_db()
|
||||||
|
|
||||||
|
self.assertCountEqual(bookmark1.tags.all(), [])
|
||||||
|
self.assertCountEqual(bookmark2.tags.all(), [])
|
||||||
|
self.assertCountEqual(bookmark3.tags.all(), [])
|
||||||
|
|
||||||
|
def test_untag_bookmarks_should_only_tag_specified_bookmarks(self):
|
||||||
|
tag1 = self.setup_tag()
|
||||||
|
tag2 = self.setup_tag()
|
||||||
|
bookmark1 = self.setup_bookmark(tags=[tag1, tag2])
|
||||||
|
bookmark2 = self.setup_bookmark(tags=[tag1, tag2])
|
||||||
|
bookmark3 = self.setup_bookmark(tags=[tag1, tag2])
|
||||||
|
|
||||||
|
untag_bookmarks([bookmark1.id, bookmark3.id], f'{tag1.name} {tag2.name}', self.get_or_create_test_user())
|
||||||
|
|
||||||
|
bookmark1.refresh_from_db()
|
||||||
|
bookmark2.refresh_from_db()
|
||||||
|
bookmark3.refresh_from_db()
|
||||||
|
|
||||||
|
self.assertCountEqual(bookmark1.tags.all(), [])
|
||||||
|
self.assertCountEqual(bookmark2.tags.all(), [tag1, tag2])
|
||||||
|
self.assertCountEqual(bookmark3.tags.all(), [])
|
||||||
|
|
||||||
|
def test_untag_bookmarks_should_only_tag_user_owned_bookmarks(self):
|
||||||
|
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
|
||||||
|
tag1 = self.setup_tag()
|
||||||
|
tag2 = self.setup_tag()
|
||||||
|
bookmark1 = self.setup_bookmark(tags=[tag1, tag2])
|
||||||
|
bookmark2 = self.setup_bookmark(tags=[tag1, tag2])
|
||||||
|
inaccessible_bookmark = self.setup_bookmark(user=other_user, tags=[tag1, tag2])
|
||||||
|
|
||||||
|
untag_bookmarks([bookmark1.id, bookmark2.id, inaccessible_bookmark.id], f'{tag1.name} {tag2.name}',
|
||||||
|
self.get_or_create_test_user())
|
||||||
|
|
||||||
|
bookmark1.refresh_from_db()
|
||||||
|
bookmark2.refresh_from_db()
|
||||||
|
inaccessible_bookmark.refresh_from_db()
|
||||||
|
|
||||||
|
self.assertCountEqual(bookmark1.tags.all(), [])
|
||||||
|
self.assertCountEqual(bookmark2.tags.all(), [])
|
||||||
|
self.assertCountEqual(inaccessible_bookmark.tags.all(), [tag1, tag2])
|
||||||
|
|
||||||
|
def test_untag_bookmarks_should_accept_mix_of_int_and_string_ids(self):
|
||||||
|
tag1 = self.setup_tag()
|
||||||
|
tag2 = self.setup_tag()
|
||||||
|
bookmark1 = self.setup_bookmark(tags=[tag1, tag2])
|
||||||
|
bookmark2 = self.setup_bookmark(tags=[tag1, tag2])
|
||||||
|
bookmark3 = self.setup_bookmark(tags=[tag1, tag2])
|
||||||
|
|
||||||
|
untag_bookmarks([str(bookmark1.id), bookmark2.id, str(bookmark3.id)], f'{tag1.name} {tag2.name}',
|
||||||
|
self.get_or_create_test_user())
|
||||||
|
|
||||||
|
self.assertCountEqual(bookmark1.tags.all(), [])
|
||||||
|
self.assertCountEqual(bookmark2.tags.all(), [])
|
||||||
|
self.assertCountEqual(bookmark3.tags.all(), [])
|
||||||
|
|
132
bookmarks/tests/test_bulk_edit_integration.py
Normal file
132
bookmarks/tests/test_bulk_edit_integration.py
Normal file
|
@ -0,0 +1,132 @@
|
||||||
|
from django.forms import model_to_dict
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from bookmarks.models import Bookmark
|
||||||
|
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||||
|
|
||||||
|
|
||||||
|
class BulkEditIntegrationTests(TestCase, BookmarkFactoryMixin):
|
||||||
|
|
||||||
|
def setUp(self) -> None:
|
||||||
|
user = self.get_or_create_test_user()
|
||||||
|
self.client.force_login(user)
|
||||||
|
|
||||||
|
def assertBookmarksAreUnmodified(self, bookmarks: [Bookmark]):
|
||||||
|
self.assertEqual(len(bookmarks), Bookmark.objects.count())
|
||||||
|
|
||||||
|
for bookmark in bookmarks:
|
||||||
|
self.assertEqual(model_to_dict(bookmark), model_to_dict(Bookmark.objects.get(id=bookmark.id)))
|
||||||
|
|
||||||
|
def test_bulk_archive(self):
|
||||||
|
bookmark1 = self.setup_bookmark()
|
||||||
|
bookmark2 = self.setup_bookmark()
|
||||||
|
bookmark3 = self.setup_bookmark()
|
||||||
|
|
||||||
|
self.client.post(reverse('bookmarks:bulk_edit'), {
|
||||||
|
'bulk_archive': [''],
|
||||||
|
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||||
|
})
|
||||||
|
|
||||||
|
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).is_archived)
|
||||||
|
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).is_archived)
|
||||||
|
self.assertTrue(Bookmark.objects.get(id=bookmark3.id).is_archived)
|
||||||
|
|
||||||
|
def test_bulk_unarchive(self):
|
||||||
|
bookmark1 = self.setup_bookmark(is_archived=True)
|
||||||
|
bookmark2 = self.setup_bookmark(is_archived=True)
|
||||||
|
bookmark3 = self.setup_bookmark(is_archived=True)
|
||||||
|
|
||||||
|
self.client.post(reverse('bookmarks:bulk_edit'), {
|
||||||
|
'bulk_unarchive': [''],
|
||||||
|
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||||
|
})
|
||||||
|
|
||||||
|
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).is_archived)
|
||||||
|
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).is_archived)
|
||||||
|
self.assertFalse(Bookmark.objects.get(id=bookmark3.id).is_archived)
|
||||||
|
|
||||||
|
def test_bulk_delete(self):
|
||||||
|
bookmark1 = self.setup_bookmark()
|
||||||
|
bookmark2 = self.setup_bookmark()
|
||||||
|
bookmark3 = self.setup_bookmark()
|
||||||
|
|
||||||
|
self.client.post(reverse('bookmarks:bulk_edit'), {
|
||||||
|
'bulk_delete': [''],
|
||||||
|
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||||
|
})
|
||||||
|
|
||||||
|
self.assertIsNone(Bookmark.objects.filter(id=bookmark1.id).first())
|
||||||
|
self.assertFalse(Bookmark.objects.filter(id=bookmark2.id).first())
|
||||||
|
self.assertFalse(Bookmark.objects.filter(id=bookmark3.id).first())
|
||||||
|
|
||||||
|
def test_bulk_tag(self):
|
||||||
|
bookmark1 = self.setup_bookmark()
|
||||||
|
bookmark2 = self.setup_bookmark()
|
||||||
|
bookmark3 = self.setup_bookmark()
|
||||||
|
tag1 = self.setup_tag()
|
||||||
|
tag2 = self.setup_tag()
|
||||||
|
|
||||||
|
self.client.post(reverse('bookmarks:bulk_edit'), {
|
||||||
|
'bulk_tag': [''],
|
||||||
|
'bulk_tag_string': [f'{tag1.name} {tag2.name}'],
|
||||||
|
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||||
|
})
|
||||||
|
|
||||||
|
bookmark1.refresh_from_db()
|
||||||
|
bookmark2.refresh_from_db()
|
||||||
|
bookmark3.refresh_from_db()
|
||||||
|
|
||||||
|
self.assertCountEqual(bookmark1.tags.all(), [tag1, tag2])
|
||||||
|
self.assertCountEqual(bookmark2.tags.all(), [tag1, tag2])
|
||||||
|
self.assertCountEqual(bookmark3.tags.all(), [tag1, tag2])
|
||||||
|
|
||||||
|
def test_bulk_untag(self):
|
||||||
|
tag1 = self.setup_tag()
|
||||||
|
tag2 = self.setup_tag()
|
||||||
|
bookmark1 = self.setup_bookmark(tags=[tag1, tag2])
|
||||||
|
bookmark2 = self.setup_bookmark(tags=[tag1, tag2])
|
||||||
|
bookmark3 = self.setup_bookmark(tags=[tag1, tag2])
|
||||||
|
|
||||||
|
self.client.post(reverse('bookmarks:bulk_edit'), {
|
||||||
|
'bulk_untag': [''],
|
||||||
|
'bulk_tag_string': [f'{tag1.name} {tag2.name}'],
|
||||||
|
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||||
|
})
|
||||||
|
|
||||||
|
bookmark1.refresh_from_db()
|
||||||
|
bookmark2.refresh_from_db()
|
||||||
|
bookmark3.refresh_from_db()
|
||||||
|
|
||||||
|
self.assertCountEqual(bookmark1.tags.all(), [])
|
||||||
|
self.assertCountEqual(bookmark2.tags.all(), [])
|
||||||
|
self.assertCountEqual(bookmark3.tags.all(), [])
|
||||||
|
|
||||||
|
def test_bulk_edit_handles_empty_bookmark_id(self):
|
||||||
|
bookmark1 = self.setup_bookmark()
|
||||||
|
bookmark2 = self.setup_bookmark()
|
||||||
|
bookmark3 = self.setup_bookmark()
|
||||||
|
|
||||||
|
response = self.client.post(reverse('bookmarks:bulk_edit'), {
|
||||||
|
'bulk_archive': [''],
|
||||||
|
})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
||||||
|
response = self.client.post(reverse('bookmarks:bulk_edit'), {
|
||||||
|
'bulk_archive': [''],
|
||||||
|
'bookmark_id': [],
|
||||||
|
})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
||||||
|
self.assertBookmarksAreUnmodified([bookmark1, bookmark2, bookmark3])
|
||||||
|
|
||||||
|
def test_empty_action_does_not_modify_bookmarks(self):
|
||||||
|
bookmark1 = self.setup_bookmark()
|
||||||
|
bookmark2 = self.setup_bookmark()
|
||||||
|
bookmark3 = self.setup_bookmark()
|
||||||
|
|
||||||
|
self.client.post(reverse('bookmarks:bulk_edit'), {
|
||||||
|
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||||
|
})
|
||||||
|
|
||||||
|
self.assertBookmarksAreUnmodified([bookmark1, bookmark2, bookmark3])
|
|
@ -18,6 +18,7 @@ urlpatterns = [
|
||||||
path('bookmarks/<int:bookmark_id>/remove', views.bookmarks.remove, name='remove'),
|
path('bookmarks/<int:bookmark_id>/remove', views.bookmarks.remove, name='remove'),
|
||||||
path('bookmarks/<int:bookmark_id>/archive', views.bookmarks.archive, name='archive'),
|
path('bookmarks/<int:bookmark_id>/archive', views.bookmarks.archive, name='archive'),
|
||||||
path('bookmarks/<int:bookmark_id>/unarchive', views.bookmarks.unarchive, name='unarchive'),
|
path('bookmarks/<int:bookmark_id>/unarchive', views.bookmarks.unarchive, name='unarchive'),
|
||||||
|
path('bookmarks/bulkedit', views.bookmarks.bulk_edit, name='bulk_edit'),
|
||||||
# Settings
|
# Settings
|
||||||
path('settings', views.settings.general, name='settings.index'),
|
path('settings', views.settings.general, name='settings.index'),
|
||||||
path('settings/general', views.settings.general, name='settings.general'),
|
path('settings/general', views.settings.general, name='settings.general'),
|
||||||
|
|
|
@ -8,7 +8,8 @@ from django.urls import reverse
|
||||||
|
|
||||||
from bookmarks import queries
|
from bookmarks import queries
|
||||||
from bookmarks.models import Bookmark, BookmarkForm, build_tag_string
|
from bookmarks.models import Bookmark, BookmarkForm, build_tag_string
|
||||||
from bookmarks.services.bookmarks import create_bookmark, update_bookmark, archive_bookmark, unarchive_bookmark
|
from bookmarks.services.bookmarks import create_bookmark, update_bookmark, archive_bookmark, archive_bookmarks, \
|
||||||
|
unarchive_bookmark, unarchive_bookmarks, delete_bookmarks, tag_bookmarks, untag_bookmarks
|
||||||
|
|
||||||
_default_page_size = 30
|
_default_page_size = 30
|
||||||
|
|
||||||
|
@ -87,11 +88,9 @@ def new(request):
|
||||||
if initial_auto_close:
|
if initial_auto_close:
|
||||||
form.initial['auto_close'] = 'true'
|
form.initial['auto_close'] = 'true'
|
||||||
|
|
||||||
all_tags = queries.get_user_tags(request.user)
|
|
||||||
context = {
|
context = {
|
||||||
'form': form,
|
'form': form,
|
||||||
'auto_close': initial_auto_close,
|
'auto_close': initial_auto_close,
|
||||||
'all_tags': all_tags,
|
|
||||||
'return_url': reverse('bookmarks:index')
|
'return_url': reverse('bookmarks:index')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -116,12 +115,10 @@ def edit(request, bookmark_id: int):
|
||||||
|
|
||||||
form.initial['tag_string'] = build_tag_string(bookmark.tag_names, ' ')
|
form.initial['tag_string'] = build_tag_string(bookmark.tag_names, ' ')
|
||||||
form.initial['return_url'] = return_url
|
form.initial['return_url'] = return_url
|
||||||
all_tags = queries.get_user_tags(request.user)
|
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
'form': form,
|
'form': form,
|
||||||
'bookmark_id': bookmark_id,
|
'bookmark_id': bookmark_id,
|
||||||
'all_tags': all_tags,
|
|
||||||
'return_url': return_url
|
'return_url': return_url
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -155,6 +152,29 @@ def unarchive(request, bookmark_id: int):
|
||||||
return HttpResponseRedirect(return_url)
|
return HttpResponseRedirect(return_url)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def bulk_edit(request):
|
||||||
|
bookmark_ids = request.POST.getlist('bookmark_id')
|
||||||
|
|
||||||
|
# Determine action
|
||||||
|
if 'bulk_archive' in request.POST:
|
||||||
|
archive_bookmarks(bookmark_ids, request.user)
|
||||||
|
if 'bulk_unarchive' in request.POST:
|
||||||
|
unarchive_bookmarks(bookmark_ids, request.user)
|
||||||
|
if 'bulk_delete' in request.POST:
|
||||||
|
delete_bookmarks(bookmark_ids, request.user)
|
||||||
|
if 'bulk_tag' in request.POST:
|
||||||
|
tag_string = request.POST['bulk_tag_string']
|
||||||
|
tag_bookmarks(bookmark_ids, tag_string, request.user)
|
||||||
|
if 'bulk_untag' in request.POST:
|
||||||
|
tag_string = request.POST['bulk_tag_string']
|
||||||
|
untag_bookmarks(bookmark_ids, tag_string, request.user)
|
||||||
|
|
||||||
|
return_url = request.GET.get('return_url')
|
||||||
|
return_url = return_url if return_url else reverse('bookmarks:index')
|
||||||
|
return HttpResponseRedirect(return_url)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def close(request):
|
def close(request):
|
||||||
return render(request, 'bookmarks/close.html')
|
return render(request, 'bookmarks/close.html')
|
||||||
|
|
Loading…
Reference in a new issue