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
|
|
@ -126,7 +126,7 @@ class UserController extends Controller
|
||||||
'phone' => 'nullable|string|max:20',
|
'phone' => 'nullable|string|max:20',
|
||||||
'organization_id' => 'nullable|exists:organizations,id',
|
'organization_id' => 'nullable|exists:organizations,id',
|
||||||
'role' => 'required|exists:roles,name',
|
'role' => 'required|exists:roles,name',
|
||||||
'groups' => 'array|exists:groups,id',
|
'groups' => 'nullable|string',
|
||||||
'is_active' => 'boolean',
|
'is_active' => 'boolean',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -147,9 +147,10 @@ class UserController extends Controller
|
||||||
// Обновление роли
|
// Обновление роли
|
||||||
$user->syncRoles([$validated['role']]);
|
$user->syncRoles([$validated['role']]);
|
||||||
|
|
||||||
// Обновление групп
|
// Обновление групп (строка "1,2,3" → массив)
|
||||||
if (isset($validated['groups'])) {
|
if (!empty($validated['groups'])) {
|
||||||
$user->groups()->sync($validated['groups']);
|
$groupIds = array_map('intval', array_filter(explode(',', $validated['groups'])));
|
||||||
|
$user->groups()->sync($groupIds);
|
||||||
} else {
|
} else {
|
||||||
$user->groups()->detach();
|
$user->groups()->detach();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -63,12 +63,11 @@
|
||||||
@if($allGroups->count() > 0)
|
@if($allGroups->count() > 0)
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Группы</label>
|
<label class="form-label">Группы</label>
|
||||||
<x-searchable-select
|
<x-tags-input
|
||||||
name="groups[]"
|
name="groups"
|
||||||
url="{{ route('api.groups.search') }}"
|
url="{{ route('api.groups.search') }}"
|
||||||
placeholder="Начните вводить название группы..."
|
placeholder="Начните вводить название группы..."
|
||||||
:value="$userGroups"
|
:value="$userGroups"
|
||||||
:multiple="true"
|
|
||||||
/>
|
/>
|
||||||
<small class="text-muted">Выберите группы</small>
|
<small class="text-muted">Выберите группы</small>
|
||||||
</div>
|
</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