I am working on a blogging application (link to GitHub repo) in Laravel 8.
I have put together a way to add and edit tags to articles.
I ran into a bug whose cause and solution I have been unable to find: updating the article's list of tags fails after one or more (but not all) tags are deleted. By contrast, if I only select new tags, the update operation works fine.
In the ArticleController controller, I have the below methods for editing and updating an article:
public function edit($id)
{
$article = Article::find($id);
$attached_tags = $article->tags()->get()->pluck('id')->toArray();
return view(
'dashboard/edit-article',
[
'categories' => $this->categories(),
'tags' => $this->tags(),
'attached_tags' => $attached_tags,
'article' => $article
]
);
}
public function update(Request $request, $id)
{
$validator = Validator::make($request->all(), $this->rules, $this->messages);
if ($validator->fails()) {
return redirect()->back()->withErrors($validator->errors())->withInput();
}
$fields = $validator->validated();
$article = Article::find($id);
// If a new image is uploaded, set it as the article image
// Otherwise, set the old image...
if (isset($request->image)) {
$imageName = md5(time()) . Auth::user()->id . '.' . $request->image->extension();
$request->image->move(public_path('images/articles'), $imageName);
} else {
$imageName = $article->image;
}
$article->title = $request->get('title');
$article->short_description = $request->get('short_description');
$article->category_id = $request->get('category_id');
$article->featured = $request->has('featured');
$article->image = $request->get('image') == 'default.jpg' ? 'default.jpg' : $imageName;
$article->content = $request->get('content');
// Save changes to the article
$article->save();
//Attach tags to article
if ($request->has('tags')) {
$article->tags()->sync($request->tags);
} else {
$article->tags()->sync([]);
}
return redirect()->route('dashboard.articles')->with('success', 'The article titled "' . $article->title . '" was updated');
}
In views\dashboard\edit-article.blade.php
I have this code got the pat of the interface that deals with the tags:
<div class="row mb-2">
<label for="tags" class="col-md-12">{{ __('Tags') }}</label>
<div class="position-relative">
<span id="tagSelectorToggler" class="tag-toggler" onclick="toggleTagSelector(event)">
<i class="fas fa-chevron-up"></i>
</span>
<ul id="tagsList" class="form-control tags-list mb-1" onclick="toggleTagSelector(event)">
<li class="text-muted">
Use [Ctrl] + [Click] to select one or more tags from the list
</li>
</ul>
</div>
<div id="tagActions" class="tag-actions">
<input oninput="filterTags(event)" type="search" class="form-control mb-1"
placeholder="Filter available tags" />
@php $selectedTags = old('tags', $attached_tags) @endphp
<select name="tags[]" id="tags" class="form-control tag-select" multiple>
@foreach ($tags as $tag)
<option value="{{ $tag->id }}"
{{ in_array($tag->id, $selectedTags) ? 'selected' : '' }}>
{{ $tag->name }}
</option>
@endforeach
</select>
</div>
</div>
In resources\js\tags.js
, I have:
const tagsList = document.querySelector(".tags-list")
const tagActions = document.getElementById("tagActions")
const tagSelector = document.getElementById("tags")
const tagToggler = document.getElementById("tagSelectorToggler")
if (tagSelector) {
var preSelectedTags = Array.from(tagSelector.options)
.filter((option) => option.selected)
.map((option) => option.text)
var selectedTags = new Set()
}
window.filterTags = (event) => {
var query = event.target.value
var availableTags = Array.from(tagSelector.options)
availableTags.forEach(function (option) {
if (!option.text.toLowerCase().includes(query.toLowerCase())) {
option.classList.add("d-none")
} else {
option.classList.remove("d-none")
}
})
}
window.toggleTagSelector = (event) => {
let tagActionsVisibility = tagActions.checkVisibility()
if (event.target.tagName !== "BUTTON" && event.target.tagName !== "SPAN") {
if (tagActionsVisibility) {
tagActions.style.display = "none"
tagToggler.classList.add("active")
} else {
tagActions.style.display = "block"
tagToggler.classList.remove("active")
}
}
}
window.renderTags = () => {
tagsList.innerHTML =
[...selectedTags]
.sort()
.map(
(tag) =>
`<li class="tag"
><span class="value">${tag}</span>
<button>×</button>
</li>`,
)
.join("") ||
`<li class="text-muted">Use [Ctrl] + [Click] to select one or more tags from the list</li>`
for (const option of tagSelector.options) {
option.selected = selectedTags.has(option.textContent)
}
}
if (preSelectedTags) {
window.addPreselectedTags = () => {
preSelectedTags.forEach(selectedTags.add.bind(selectedTags))
renderTags()
}
}
if (tagsList) {
tagsList.addEventListener("click", function (event) {
if (event.target.tagName !== "BUTTON") return
let tagToRemove = event.target.closest("LI").children[0].textContent
let optionToDeselect = Array.from(tagSelector.options).find((option) => {
return option.innerText == tagToRemove
})
optionToDeselect.removeAttribute('selected')
selectedTags.delete(tagToRemove)
console.log(selectedTags);
renderTags()
})
}
if (tagSelector) {
tagSelector.addEventListener("change", function () {
selectedTags = new Set(
Array.from(tagSelector.options)
.filter((option) => option.selected)
.map((option) => option.textContent),
)
renderTags()
console.log(selectedTags);
})
}
if (tagSelector) {
window.addPreselectedTags();
}
If I click a tag's close button and then update the article, it loses all the tags (not only the deleted one).
There is a working fiddle HERE, with JavaScript and HTML.
Where is my mistake?
Ok, I traced the issue. The issue is in the expression option.textContent
, which is used two times in the tags.js file. When you retrieve option.textContent
it returns entire text content which includes the extra spaces before and after the actual tag text. So your script is not properly pre-selecting the tags, specifically at this line:
option.selected = selectedTags.has(option.textContent)
Solution:
In place of the expression option.textContent
, either use option.textContent.trim()
or option.text
. You need to replace this in all the places.