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:
parent
4b84528e52
commit
11c5dcaf48
|
|
@ -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', 'Пользователь успешно обновлён.');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)">×</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
|
||||
Loading…
Reference in New Issue