javascriptphplaravel

What causes this Laravel and JavaScript article tags bug?


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.

enter image description here

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>&times;</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?


Solution

  • 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.