feat: Enhance CommunitiesManager with filter usage tracking and UI improvements, including a refresh button and search prefill from query parameters for better user experience.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m1s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m1s
This commit is contained in:
@@ -11,6 +11,8 @@ import {
|
|||||||
IconFilter,
|
IconFilter,
|
||||||
IconChartBar,
|
IconChartBar,
|
||||||
IconList,
|
IconList,
|
||||||
|
IconLink,
|
||||||
|
IconRefresh,
|
||||||
IconSortAscending,
|
IconSortAscending,
|
||||||
IconSortDescending
|
IconSortDescending
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
@@ -44,6 +46,10 @@ function CommunitiesManager() {
|
|||||||
const [newItem, setNewItem] = useState({ value: '', name: '', description: '', tags: '', gatewayDefault: '', color: '', category: '', priority: 0, enabled: true });
|
const [newItem, setNewItem] = useState({ value: '', name: '', description: '', tags: '', gatewayDefault: '', color: '', category: '', priority: 0, enabled: true });
|
||||||
const [editingValue, setEditingValue] = useState(null);
|
const [editingValue, setEditingValue] = useState(null);
|
||||||
const [editingDraft, setEditingDraft] = useState({ value: '', name: '', description: '', tags: '', gatewayDefault: '', color: '', category: '', priority: 0, enabled: true });
|
const [editingDraft, setEditingDraft] = useState({ value: '', name: '', description: '', tags: '', gatewayDefault: '', color: '', category: '', priority: 0, enabled: true });
|
||||||
|
const [filterUsage, setFilterUsage] = useState({});
|
||||||
|
const [filterUsageLoading, setFilterUsageLoading] = useState(false);
|
||||||
|
const [filterUsageError, setFilterUsageError] = useState('');
|
||||||
|
const [showOnlyUsedInFilters, setShowOnlyUsedInFilters] = useState(false);
|
||||||
|
|
||||||
// Модальные окна
|
// Модальные окна
|
||||||
const [addModalOpen, setAddModalOpen] = useState(false);
|
const [addModalOpen, setAddModalOpen] = useState(false);
|
||||||
@@ -51,7 +57,24 @@ function CommunitiesManager() {
|
|||||||
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
||||||
const [itemToDelete, setItemToDelete] = useState(null);
|
const [itemToDelete, setItemToDelete] = useState(null);
|
||||||
|
|
||||||
useEffect(() => { fetchItems(); }, []);
|
useEffect(() => {
|
||||||
|
fetchItems();
|
||||||
|
fetchFilterUsage();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!filterUsage) return;
|
||||||
|
setItems((prev) => {
|
||||||
|
const map = new Map(prev.map((i) => [i.value, i]));
|
||||||
|
Object.keys(filterUsage).forEach((value) => {
|
||||||
|
if (!value) return;
|
||||||
|
if (!map.has(value)) {
|
||||||
|
map.set(value, { value, name: '', description: '', tags: [], gatewayDefault: '', color: '', _external: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Array.from(map.values());
|
||||||
|
});
|
||||||
|
}, [filterUsage]);
|
||||||
|
|
||||||
const fetchItems = async () => {
|
const fetchItems = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -115,6 +138,42 @@ function CommunitiesManager() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchFilterUsage = async () => {
|
||||||
|
setFilterUsageLoading(true);
|
||||||
|
setFilterUsageError('');
|
||||||
|
try {
|
||||||
|
const serversRes = await api.get(`/server-configs`);
|
||||||
|
const serversList = Array.isArray(serversRes.data) ? serversRes.data : [];
|
||||||
|
if (!serversList.length) {
|
||||||
|
setFilterUsage({});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const responses = await Promise.all(
|
||||||
|
serversList.map((server) =>
|
||||||
|
api.get(`/server-filters/${server.id}`).catch(() => ({ data: [] }))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const usageMap = new Map();
|
||||||
|
responses.forEach((res) => {
|
||||||
|
const arr = Array.isArray(res.data) ? res.data : [];
|
||||||
|
arr.forEach((f) => {
|
||||||
|
const value = String(f?.community || '').trim();
|
||||||
|
if (!value) return;
|
||||||
|
usageMap.set(value, (usageMap.get(value) || 0) + 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
setFilterUsage(Object.fromEntries(usageMap));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error fetching filter usage:', e);
|
||||||
|
setFilterUsageError('Не удалось загрузить использование в фильтрах.');
|
||||||
|
} finally {
|
||||||
|
setFilterUsageLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const saveAll = async (data) => {
|
const saveAll = async (data) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -245,14 +304,16 @@ function CommunitiesManager() {
|
|||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const q = searchTerm.trim().toLowerCase();
|
const q = searchTerm.trim().toLowerCase();
|
||||||
if (!q) return sorted;
|
const searchFiltered = q
|
||||||
return sorted.filter(i =>
|
? sorted.filter(i =>
|
||||||
(i.value || '').toLowerCase().includes(q) ||
|
(i.value || '').toLowerCase().includes(q) ||
|
||||||
(i.name || '').toLowerCase().includes(q) ||
|
(i.name || '').toLowerCase().includes(q) ||
|
||||||
(i.description || '').toLowerCase().includes(q) ||
|
(i.description || '').toLowerCase().includes(q) ||
|
||||||
(i.tags || []).some(t => (t || '').toLowerCase().includes(q))
|
(i.tags || []).some(t => (t || '').toLowerCase().includes(q))
|
||||||
);
|
)
|
||||||
}, [sorted, searchTerm]);
|
: sorted;
|
||||||
|
return searchFiltered.filter((i) => !showOnlyUsedInFilters || (filterUsage[i.value] || 0) > 0);
|
||||||
|
}, [sorted, searchTerm, showOnlyUsedInFilters, filterUsage]);
|
||||||
|
|
||||||
const totalPages = Math.ceil(filtered.length / pageSize) || 1;
|
const totalPages = Math.ceil(filtered.length / pageSize) || 1;
|
||||||
const page = Math.min(currentPage, totalPages);
|
const page = Math.min(currentPage, totalPages);
|
||||||
@@ -375,7 +436,7 @@ function CommunitiesManager() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
<div className="d-flex gap-2 flex-wrap mb-3">
|
<div className="d-flex gap-2 flex-wrap mb-3 align-items-center">
|
||||||
<SavedFilters
|
<SavedFilters
|
||||||
pageKey="communities"
|
pageKey="communities"
|
||||||
currentFilters={{ searchTerm, sortField, sortOrder }}
|
currentFilters={{ searchTerm, sortField, sortOrder }}
|
||||||
@@ -407,6 +468,33 @@ function CommunitiesManager() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="d-flex align-items-center gap-3 flex-wrap">
|
||||||
|
<label className="form-check form-switch mb-0 d-flex align-items-center gap-2">
|
||||||
|
<input
|
||||||
|
className="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
checked={showOnlyUsedInFilters}
|
||||||
|
onChange={(e) => { setShowOnlyUsedInFilters(e.target.checked); setCurrentPage(1); }}
|
||||||
|
/>
|
||||||
|
<span className="form-check-label">Только используемые в фильтрах</span>
|
||||||
|
{filterUsageLoading && (
|
||||||
|
<span className="spinner-border spinner-border-sm text-primary" role="status" aria-hidden="true"></span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-secondary btn-sm"
|
||||||
|
type="button"
|
||||||
|
onClick={fetchFilterUsage}
|
||||||
|
disabled={filterUsageLoading}
|
||||||
|
title="Обновить использование из /filters"
|
||||||
|
>
|
||||||
|
<IconRefresh className="icon me-1" size={16} />
|
||||||
|
Обновить использование
|
||||||
|
</button>
|
||||||
|
{filterUsageError && (
|
||||||
|
<span className="text-danger small">{filterUsageError}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="table-responsive">
|
<div className="table-responsive">
|
||||||
<table className="table card-table table-vcenter table-nowrap mb-0">
|
<table className="table card-table table-vcenter table-nowrap mb-0">
|
||||||
@@ -437,14 +525,15 @@ function CommunitiesManager() {
|
|||||||
<th>Описание</th>
|
<th>Описание</th>
|
||||||
<th>Теги</th>
|
<th>Теги</th>
|
||||||
<th>Gateway</th>
|
<th>Gateway</th>
|
||||||
|
<th style={{ width: '10%' }}>Исп. в фильтрах</th>
|
||||||
<th className="text-end">Действия</th>
|
<th className="text-end">Действия</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<tr><td colSpan={6} className="text-center text-muted py-4">Загрузка...</td></tr>
|
<tr><td colSpan={7} className="text-center text-muted py-4">Загрузка...</td></tr>
|
||||||
) : paginated.length === 0 ? (
|
) : paginated.length === 0 ? (
|
||||||
<tr><td colSpan={6} className="text-center text-muted py-4">
|
<tr><td colSpan={7} className="text-center text-muted py-4">
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="Нет community"
|
title="Нет community"
|
||||||
description="Добавьте запись, чтобы начать."
|
description="Добавьте запись, чтобы начать."
|
||||||
@@ -456,46 +545,63 @@ function CommunitiesManager() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</td></tr>
|
</td></tr>
|
||||||
) : paginated.map((item) => (
|
) : paginated.map((item) => {
|
||||||
<tr key={item.value}>
|
const usageCount = filterUsage[item.value] || 0;
|
||||||
<td>
|
return (
|
||||||
<code className="text-blue">{item.value}</code>
|
<tr key={item.value}>
|
||||||
{item._external && (
|
<td>
|
||||||
<span className="badge bg-yellow-lt text-yellow ms-2" title="Не внесён в справочник">не в справ.</span>
|
<code className="text-blue">{item.value}</code>
|
||||||
)}
|
{item._external && (
|
||||||
</td>
|
<span className="badge bg-yellow-lt text-yellow ms-2" title="Не внесён в справочник">не в справ.</span>
|
||||||
<td>{item.name || <span className="text-muted">—</span>}</td>
|
)}
|
||||||
<td>
|
</td>
|
||||||
<span className="text-muted" title={item.description}>{item.description || '—'}</span>
|
<td>{item.name || <span className="text-muted">—</span>}</td>
|
||||||
</td>
|
<td>
|
||||||
<td>
|
<span className="text-muted" title={item.description}>{item.description || '—'}</span>
|
||||||
{(item.tags || []).map(t => (
|
</td>
|
||||||
<span key={t} className="badge bg-blue-lt text-blue me-1">{t}</span>
|
<td>
|
||||||
))}
|
{(item.tags || []).map(t => (
|
||||||
</td>
|
<span key={t} className="badge bg-blue-lt text-blue me-1">{t}</span>
|
||||||
<td>
|
))}
|
||||||
<span className="text-muted">{item.gatewayDefault || '—'}</span>
|
</td>
|
||||||
</td>
|
<td>
|
||||||
<td className="text-end">
|
<span className="text-muted">{item.gatewayDefault || '—'}</span>
|
||||||
<div className="btn-list">
|
</td>
|
||||||
<button
|
<td>
|
||||||
className="btn btn-outline-primary btn-icon btn-sm"
|
{usageCount > 0 ? (
|
||||||
onClick={()=>startEdit(item)}
|
<span className="badge bg-blue-lt text-blue">{usageCount} в фильтрах</span>
|
||||||
title="Редактировать"
|
) : (
|
||||||
>
|
<span className="text-muted">0</span>
|
||||||
<IconEdit size={16} />
|
)}
|
||||||
</button>
|
</td>
|
||||||
<button
|
<td className="text-end">
|
||||||
className="btn btn-outline-danger btn-icon btn-sm"
|
<div className="btn-list">
|
||||||
onClick={()=>handleDeleteClick(item)}
|
<a
|
||||||
title="Удалить"
|
className="btn btn-outline-primary btn-icon btn-sm"
|
||||||
>
|
href={`/filters?community=${encodeURIComponent(item.value)}`}
|
||||||
<IconTrash size={16} />
|
title="Открыть в /filters с поиском по community"
|
||||||
</button>
|
>
|
||||||
</div>
|
<IconLink size={16} />
|
||||||
</td>
|
</a>
|
||||||
</tr>
|
<button
|
||||||
))}
|
className="btn btn-outline-primary btn-icon btn-sm"
|
||||||
|
onClick={()=>startEdit(item)}
|
||||||
|
title="Редактировать"
|
||||||
|
>
|
||||||
|
<IconEdit size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-danger btn-icon btn-sm"
|
||||||
|
onClick={()=>handleDeleteClick(item)}
|
||||||
|
title="Удалить"
|
||||||
|
>
|
||||||
|
<IconTrash size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -70,6 +70,15 @@ function FilterManager() {
|
|||||||
const [sortBy, setSortBy] = useState('community'); // community | gateway | description
|
const [sortBy, setSortBy] = useState('community'); // community | gateway | description
|
||||||
const [sortDir, setSortDir] = useState('asc'); // asc | desc
|
const [sortDir, setSortDir] = useState('asc'); // asc | desc
|
||||||
|
|
||||||
|
// Префилл поиска из query-параметра ?community=...
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const communityFromQuery = params.get('community');
|
||||||
|
if (communityFromQuery) {
|
||||||
|
setFilterSearch(communityFromQuery);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Состояние управления серверами
|
// Состояние управления серверами
|
||||||
const [serverListModalOpen, setServerListModalOpen] = useState(false);
|
const [serverListModalOpen, setServerListModalOpen] = useState(false);
|
||||||
const [serverSearch, setServerSearch] = useState('');
|
const [serverSearch, setServerSearch] = useState('');
|
||||||
|
|||||||
Reference in New Issue
Block a user