Feat: Tags Input компонент для групп (WordPress style)

 Новый компонент x-tags-input
 Поле ввода + бейджи ниже
 Typeahead поиск групп
 Крестик для удаления
 Синие бейджи как в WordPress
 Отдельный компонент (не влияет на searchable-select)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
mirivlad 2026-03-30 12:23:05 +08:00
parent 4b84528e52
commit 11c5dcaf48
3 changed files with 164 additions and 13 deletions

View File

@ -118,7 +118,7 @@ class UserController extends Controller
public function update(Request $request, User $user)
{
Gate::authorize('update', $user);
$validated = $request->validate([
'name' => 'required|string|max:255',
'email' => 'required|string|email|max:255|unique:users,email,' . $user->id,
@ -126,10 +126,10 @@ class UserController extends Controller
'phone' => 'nullable|string|max:20',
'organization_id' => 'nullable|exists:organizations,id',
'role' => 'required|exists:roles,name',
'groups' => 'array|exists:groups,id',
'groups' => 'nullable|string',
'is_active' => 'boolean',
]);
$user->update([
'name' => $validated['name'],
'email' => $validated['email'],
@ -137,23 +137,24 @@ class UserController extends Controller
'organization_id' => $validated['organization_id'] ?? null,
'is_active' => $validated['is_active'] ?? true,
]);
// Обновление пароля
if (!empty($validated['password'])) {
$user->password = Hash::make($validated['password']);
$user->save();
}
// Обновление роли
$user->syncRoles([$validated['role']]);
// Обновление групп
if (isset($validated['groups'])) {
$user->groups()->sync($validated['groups']);
// Обновление групп (строка "1,2,3" → массив)
if (!empty($validated['groups'])) {
$groupIds = array_map('intval', array_filter(explode(',', $validated['groups'])));
$user->groups()->sync($groupIds);
} else {
$user->groups()->detach();
}
return redirect()->route('admin.users.show', $user)
->with('success', 'Пользователь успешно обновлён.');
}

View File

@ -63,12 +63,11 @@
@if($allGroups->count() > 0)
<div class="mb-3">
<label class="form-label">Группы</label>
<x-searchable-select
name="groups[]"
<x-tags-input
name="groups"
url="{{ route('api.groups.search') }}"
placeholder="Начните вводить название группы..."
:value="$userGroups"
:multiple="true"
/>
<small class="text-muted">Выберите группы</small>
</div>

View File

@ -0,0 +1,151 @@
@props(['name', 'url', 'placeholder' => 'Начните вводить...', 'value' => []])
<div class="tags-input-container">
<input type="text" class="form-control" id="{{ $name }}-input" placeholder="{{ $placeholder }}" autocomplete="off">
<input type="hidden" name="{{ $name }}" id="{{ $name }}" value="{{ is_array($value) ? implode(',', $value) : '' }}">
<div id="{{ $name }}-tags" class="tags-container mt-2"></div>
</div>
@push('scripts')
<link href="https://cdn.jsdelivr.net/npm/tom-select@2.3.1/dist/css/tom-select.css" rel="stylesheet">
<style>
.tags-input-container {
position: relative;
}
.tags-container {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
min-height: 40px;
padding: 0.5rem;
border: 1px solid #dee2e6;
border-radius: 0.25rem;
background: #fff;
}
.tags-container:has(input:focus) {
border-color: #0d6efd;
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
}
.tag-badge {
background: #0d6efd;
color: white;
padding: 0.35rem 0.65rem;
border-radius: 0.25rem;
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
}
.tag-badge .remove-tag {
cursor: pointer;
font-size: 1.1rem;
line-height: 1;
opacity: 0.7;
}
.tag-badge .remove-tag:hover {
opacity: 1;
color: #ffc107;
}
</style>
<script src="https://cdn.jsdelivr.net/npm/tom-select@2.3.1/dist/js/tom-select.complete.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
const input = document.getElementById('{{ $name }}-input');
const tagsContainer = document.getElementById('{{ $name }}-tags');
const hiddenInput = document.getElementById('{{ $name }}');
let selectedTags = new Set({{ json_encode(array_map('strval', is_array($value) ? $value : [])) }});
// Инициализация TomSelect
const select = new TomSelect(input, {
valueField: 'id',
labelField: 'text',
searchField: 'text',
placeholder: '{{ $placeholder }}',
preload: false,
maxOptions: null,
persist: false,
hideSelected: true,
create: false,
load: function(query, callback) {
if (query.length < 2) return callback();
fetch('{{ $url }}?q=' + encodeURIComponent(query))
.then(response => response.json())
.then(json => {
// Фильтруем уже выбранные
const filtered = json.filter(item => !selectedTags.has(String(item.id)));
callback(filtered);
}).catch(() => {
callback();
});
},
render: {
option: function(data, escape) {
return '<div>' + escape(data.text) + '</div>';
},
item: function(data, escape) {
return '<div>' + escape(data.text) + '</div>';
}
},
onChange: function(value) {
if (value) {
addTag(value, select.options[value]);
select.clear();
}
}
});
// Добавление тега
function addTag(id, data) {
if (selectedTags.has(String(id))) return;
selectedTags.add(String(id));
updateHiddenInput();
const tag = document.createElement('div');
tag.className = 'tag-badge';
tag.dataset.id = id;
tag.innerHTML = `
<span>${escapeHtml(data.text)}</span>
<span class="remove-tag" onclick="removeTag(${id}, this)">&times;</span>
`;
tagsContainer.appendChild(tag);
}
// Удаление тега
window.removeTag = function(id, element) {
selectedTags.delete(String(id));
updateHiddenInput();
element.parentElement.remove();
select.clear();
};
// Обновление скрытого input
function updateHiddenInput() {
hiddenInput.value = Array.from(selectedTags).join(',');
}
// Экранирование HTML
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Загрузка существующих тегов
@if(count($value) > 0)
fetch('{{ $url }}?q=')
.then(response => response.json())
.then(items => {
selectedTags.forEach(id => {
const item = items.find(i => String(i.id) === id);
if (item) {
select.addOption(item);
addTag(id, item);
}
});
});
@endif
});
</script>
@endpush