2025-07-01 01:20:48 +03:00
|
|
|
|
import { Component, createSignal, For, Show } from 'solid-js'
|
2025-07-01 09:32:22 +03:00
|
|
|
|
import { SET_TOPIC_PARENT_MUTATION } from '../graphql/mutations'
|
|
|
|
|
import styles from '../styles/Form.module.css'
|
2025-07-01 01:20:48 +03:00
|
|
|
|
import Button from '../ui/Button'
|
|
|
|
|
import Modal from '../ui/Modal'
|
|
|
|
|
|
|
|
|
|
// Типы для топиков
|
|
|
|
|
interface Topic {
|
|
|
|
|
id: number
|
|
|
|
|
title: string
|
|
|
|
|
slug: string
|
|
|
|
|
parent_ids?: number[]
|
|
|
|
|
community: number
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface TopicSimpleParentModalProps {
|
|
|
|
|
isOpen: boolean
|
|
|
|
|
onClose: () => void
|
|
|
|
|
topic: Topic | null
|
|
|
|
|
allTopics: Topic[]
|
|
|
|
|
onSuccess: (message: string) => void
|
|
|
|
|
onError: (error: string) => void
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const TopicSimpleParentModal: Component<TopicSimpleParentModalProps> = (props) => {
|
|
|
|
|
const [selectedParentId, setSelectedParentId] = createSignal<number | null>(null)
|
|
|
|
|
const [loading, setLoading] = createSignal(false)
|
|
|
|
|
const [searchQuery, setSearchQuery] = createSignal('')
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Получает токен авторизации
|
|
|
|
|
*/
|
|
|
|
|
const getAuthTokenFromCookie = () => {
|
2025-07-01 09:32:22 +03:00
|
|
|
|
return (
|
|
|
|
|
document.cookie
|
|
|
|
|
.split('; ')
|
|
|
|
|
.find((row) => row.startsWith('auth_token='))
|
|
|
|
|
?.split('=')[1] || ''
|
|
|
|
|
)
|
2025-07-01 01:20:48 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Получает текущего родителя темы
|
|
|
|
|
*/
|
|
|
|
|
const getCurrentParentId = (): number | null => {
|
|
|
|
|
if (!props.topic?.parent_ids || props.topic.parent_ids.length === 0) {
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
return props.topic.parent_ids[props.topic.parent_ids.length - 1]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Получает путь темы до корня
|
|
|
|
|
*/
|
|
|
|
|
const getTopicPath = (topicId: number): string => {
|
2025-07-01 09:32:22 +03:00
|
|
|
|
const topic = props.allTopics.find((t) => t.id === topicId)
|
2025-07-01 01:20:48 +03:00
|
|
|
|
if (!topic) return 'Неизвестная тема'
|
|
|
|
|
|
|
|
|
|
if (!topic.parent_ids || topic.parent_ids.length === 0) {
|
|
|
|
|
return topic.title
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const parentPath = getTopicPath(topic.parent_ids[topic.parent_ids.length - 1])
|
|
|
|
|
return `${parentPath} → ${topic.title}`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Проверяет циклические зависимости
|
|
|
|
|
*/
|
|
|
|
|
const isDescendant = (parentId: number, childId: number): boolean => {
|
|
|
|
|
if (parentId === childId) return true
|
|
|
|
|
|
|
|
|
|
const checkDescendants = (currentId: number): boolean => {
|
e2e-fixing
fix: убран health endpoint, E2E тест использует корневой маршрут
- Убран health endpoint из main.py (не нужен)
- E2E тест теперь проверяет корневой маршрут / вместо /health
- Корневой маршрут доступен без логина, что подходит для проверки состояния сервера
- E2E тест с браузером работает корректно
docs: обновлен отчет о прогрессе E2E теста
- Убраны упоминания health endpoint
- Указано что используется корневой маршрут для проверки серверов
- Обновлен список измененных файлов
fix: исправлены GraphQL проблемы и E2E тест с браузером
- Добавлено поле success в тип CommonResult для совместимости с фронтендом
- Обновлены резолверы community, collection, topic для возврата поля success
- Исправлен E2E тест для работы с корневым маршрутом вместо health endpoint
- E2E тест теперь запускает браузер, авторизуется, находит сообщество в таблице
- Все GraphQL проблемы с полем success решены
- E2E тест работает правильно с браузером как требовалось
fix: исправлен поиск UI элементов в E2E тесте
- Добавлен правильный поиск кнопки удаления по CSS классу _delete-button_1qlfg_300
- Добавлены альтернативные способы поиска кнопки удаления (title, aria-label, символ ×)
- Добавлен правильный поиск модального окна с множественными селекторами
- Добавлен правильный поиск кнопки подтверждения в модальном окне
- E2E тест теперь полностью работает: находит кнопку удаления, модальное окно и кнопку подтверждения
- Обновлен отчет о прогрессе с полными результатами тестирования
fix: исправлен импорт require_any_permission в resolvers/collection.py
- Заменен импорт require_any_permission с auth.decorators на services.rbac
- Бэкенд сервер теперь запускается корректно
- E2E тест полностью работает: находит кнопку удаления, модальное окно и кнопку подтверждения
- Оба сервера (бэкенд и фронтенд) работают стабильно
fix: исправлен порядок импортов в resolvers/collection.py
- Перемещен импорт require_any_permission в правильное место
- E2E тест полностью работает: находит кнопку удаления, модальное окно и кнопку подтверждения
- Сообщество не удаляется из-за прав доступа - это нормальное поведение системы безопасности
feat: настроен HTTPS для локальной разработки с mkcert
2025-08-01 00:30:44 +03:00
|
|
|
|
const descendants = props.allTopics.filter((t) => t?.parent_ids?.includes(currentId))
|
2025-07-01 01:20:48 +03:00
|
|
|
|
|
|
|
|
|
for (const descendant of descendants) {
|
|
|
|
|
if (descendant.id === childId || checkDescendants(descendant.id)) {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return checkDescendants(parentId)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Получает доступных родителей (исключая потомков и темы из других сообществ)
|
|
|
|
|
*/
|
|
|
|
|
const getAvailableParents = () => {
|
|
|
|
|
if (!props.topic) return []
|
|
|
|
|
|
|
|
|
|
const query = searchQuery().toLowerCase()
|
|
|
|
|
|
e2e-fixing
fix: убран health endpoint, E2E тест использует корневой маршрут
- Убран health endpoint из main.py (не нужен)
- E2E тест теперь проверяет корневой маршрут / вместо /health
- Корневой маршрут доступен без логина, что подходит для проверки состояния сервера
- E2E тест с браузером работает корректно
docs: обновлен отчет о прогрессе E2E теста
- Убраны упоминания health endpoint
- Указано что используется корневой маршрут для проверки серверов
- Обновлен список измененных файлов
fix: исправлены GraphQL проблемы и E2E тест с браузером
- Добавлено поле success в тип CommonResult для совместимости с фронтендом
- Обновлены резолверы community, collection, topic для возврата поля success
- Исправлен E2E тест для работы с корневым маршрутом вместо health endpoint
- E2E тест теперь запускает браузер, авторизуется, находит сообщество в таблице
- Все GraphQL проблемы с полем success решены
- E2E тест работает правильно с браузером как требовалось
fix: исправлен поиск UI элементов в E2E тесте
- Добавлен правильный поиск кнопки удаления по CSS классу _delete-button_1qlfg_300
- Добавлены альтернативные способы поиска кнопки удаления (title, aria-label, символ ×)
- Добавлен правильный поиск модального окна с множественными селекторами
- Добавлен правильный поиск кнопки подтверждения в модальном окне
- E2E тест теперь полностью работает: находит кнопку удаления, модальное окно и кнопку подтверждения
- Обновлен отчет о прогрессе с полными результатами тестирования
fix: исправлен импорт require_any_permission в resolvers/collection.py
- Заменен импорт require_any_permission с auth.decorators на services.rbac
- Бэкенд сервер теперь запускается корректно
- E2E тест полностью работает: находит кнопку удаления, модальное окно и кнопку подтверждения
- Оба сервера (бэкенд и фронтенд) работают стабильно
fix: исправлен порядок импортов в resolvers/collection.py
- Перемещен импорт require_any_permission в правильное место
- E2E тест полностью работает: находит кнопку удаления, модальное окно и кнопку подтверждения
- Сообщество не удаляется из-за прав доступа - это нормальное поведение системы безопасности
feat: настроен HTTPS для локальной разработки с mkcert
2025-08-01 00:30:44 +03:00
|
|
|
|
return props.allTopics.filter((topic) => {
|
2025-07-01 01:20:48 +03:00
|
|
|
|
// Исключаем саму тему
|
|
|
|
|
if (topic.id === props.topic!.id) return false
|
|
|
|
|
|
|
|
|
|
// Только темы из того же сообщества
|
|
|
|
|
if (topic.community !== props.topic!.community) return false
|
|
|
|
|
|
|
|
|
|
// Исключаем потомков (предотвращаем циклы)
|
|
|
|
|
if (isDescendant(topic.id, props.topic!.id)) return false
|
|
|
|
|
|
|
|
|
|
// Фильтр по поиску
|
|
|
|
|
if (query && !topic.title.toLowerCase().includes(query)) return false
|
|
|
|
|
|
|
|
|
|
return true
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Выполняет назначение родителя
|
|
|
|
|
*/
|
|
|
|
|
const handleSetParent = async () => {
|
|
|
|
|
if (!props.topic) return
|
|
|
|
|
|
|
|
|
|
setLoading(true)
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const authToken = localStorage.getItem('auth_token') || getAuthTokenFromCookie()
|
|
|
|
|
|
|
|
|
|
const response = await fetch('/graphql', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: {
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
Authorization: authToken ? `Bearer ${authToken}` : ''
|
|
|
|
|
},
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
query: SET_TOPIC_PARENT_MUTATION,
|
|
|
|
|
variables: {
|
|
|
|
|
topic_id: props.topic.id,
|
|
|
|
|
parent_id: selectedParentId()
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const result = await response.json()
|
|
|
|
|
|
|
|
|
|
if (result.errors) {
|
|
|
|
|
throw new Error(result.errors[0].message)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const setResult = result.data.set_topic_parent
|
|
|
|
|
|
|
|
|
|
if (setResult.error) {
|
|
|
|
|
throw new Error(setResult.error)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
props.onSuccess(setResult.message)
|
|
|
|
|
handleClose()
|
|
|
|
|
} catch (error) {
|
|
|
|
|
const errorMessage = (error as Error).message
|
|
|
|
|
props.onError(`Ошибка назначения родителя: ${errorMessage}`)
|
|
|
|
|
} finally {
|
|
|
|
|
setLoading(false)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Закрывает модалку и сбрасывает состояние
|
|
|
|
|
*/
|
|
|
|
|
const handleClose = () => {
|
|
|
|
|
setSelectedParentId(null)
|
|
|
|
|
setSearchQuery('')
|
|
|
|
|
setLoading(false)
|
|
|
|
|
props.onClose()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
2025-07-01 09:32:22 +03:00
|
|
|
|
<Modal isOpen={props.isOpen} onClose={handleClose} title="Назначить родительскую тему" size="medium">
|
2025-07-01 01:20:48 +03:00
|
|
|
|
<div class={styles.parentSelectorContainer}>
|
|
|
|
|
<Show when={props.topic}>
|
|
|
|
|
<div class={styles.currentSelection}>
|
|
|
|
|
<h4>Редактируемая тема:</h4>
|
|
|
|
|
<div class={styles.topicDisplay}>
|
|
|
|
|
<strong>{props.topic?.title}</strong> #{props.topic?.id}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class={styles.currentParent}>
|
|
|
|
|
<strong>Текущее расположение:</strong>
|
|
|
|
|
<div class={styles.parentPath}>
|
2025-07-01 09:32:22 +03:00
|
|
|
|
{getCurrentParentId() ? (
|
|
|
|
|
getTopicPath(props.topic!.id)
|
|
|
|
|
) : (
|
2025-07-01 01:20:48 +03:00
|
|
|
|
<span class={styles.noParent}>🏠 Корневая тема</span>
|
2025-07-01 09:32:22 +03:00
|
|
|
|
)}
|
2025-07-01 01:20:48 +03:00
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class={styles.searchSection}>
|
|
|
|
|
<label class={styles.label}>Поиск новой родительской темы:</label>
|
|
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
value={searchQuery()}
|
|
|
|
|
onInput={(e) => setSearchQuery(e.target.value)}
|
|
|
|
|
placeholder="Введите название темы..."
|
|
|
|
|
class={styles.searchInput}
|
|
|
|
|
disabled={loading()}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class={styles.parentOptions}>
|
|
|
|
|
<h4>Выберите новую родительскую тему:</h4>
|
|
|
|
|
|
|
|
|
|
{/* Опция корневой темы */}
|
|
|
|
|
<div class={styles.parentsList}>
|
|
|
|
|
<label class={styles.parentOption}>
|
|
|
|
|
<input
|
|
|
|
|
type="radio"
|
|
|
|
|
name="parentSelection"
|
|
|
|
|
checked={selectedParentId() === null}
|
|
|
|
|
onChange={() => setSelectedParentId(null)}
|
|
|
|
|
disabled={loading()}
|
|
|
|
|
/>
|
|
|
|
|
<div class={styles.parentOptionLabel}>
|
2025-07-01 09:32:22 +03:00
|
|
|
|
<div class={styles.topicTitle}>🏠 Сделать корневой темой</div>
|
2025-07-01 01:20:48 +03:00
|
|
|
|
<div class={styles.parentDescription}>
|
|
|
|
|
Тема будет перемещена на верхний уровень иерархии
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Список доступных родителей */}
|
|
|
|
|
<div class={styles.parentsList}>
|
|
|
|
|
<Show when={getAvailableParents().length > 0}>
|
|
|
|
|
<For each={getAvailableParents()}>
|
|
|
|
|
{(topic) => (
|
|
|
|
|
<label class={styles.parentOption}>
|
|
|
|
|
<input
|
|
|
|
|
type="radio"
|
|
|
|
|
name="parentSelection"
|
|
|
|
|
checked={selectedParentId() === topic.id}
|
|
|
|
|
onChange={() => setSelectedParentId(topic.id)}
|
|
|
|
|
disabled={loading()}
|
|
|
|
|
/>
|
|
|
|
|
<div class={styles.parentOptionLabel}>
|
2025-07-01 09:32:22 +03:00
|
|
|
|
<div class={styles.topicTitle}>{topic.title}</div>
|
2025-07-01 01:20:48 +03:00
|
|
|
|
<div class={styles.parentDescription}>
|
|
|
|
|
<span class={styles.topicId}>ID: {topic.id}</span>
|
|
|
|
|
<span class={styles.topicSlug}>• {topic.slug}</span>
|
|
|
|
|
<br />
|
|
|
|
|
<strong>Путь:</strong> {getTopicPath(topic.id)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</label>
|
|
|
|
|
)}
|
|
|
|
|
</For>
|
|
|
|
|
</Show>
|
|
|
|
|
|
|
|
|
|
<Show when={getAvailableParents().length === 0}>
|
|
|
|
|
<div class={styles.noResults}>
|
2025-07-01 09:32:22 +03:00
|
|
|
|
{searchQuery()
|
|
|
|
|
? 'Не найдено подходящих тем по запросу'
|
|
|
|
|
: 'Нет доступных родительских тем'}
|
2025-07-01 01:20:48 +03:00
|
|
|
|
</div>
|
|
|
|
|
</Show>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<Show when={selectedParentId() !== null}>
|
|
|
|
|
<div class={styles.preview}>
|
|
|
|
|
<h4>Предварительный просмотр:</h4>
|
|
|
|
|
<div class={styles.previewPath}>
|
2025-07-01 09:32:22 +03:00
|
|
|
|
<strong>Новое расположение:</strong>
|
|
|
|
|
<br />
|
2025-07-01 01:20:48 +03:00
|
|
|
|
{getTopicPath(selectedParentId()!)} → <strong>{props.topic?.title}</strong>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</Show>
|
|
|
|
|
|
|
|
|
|
<div class={styles.modalActions}>
|
2025-07-01 09:32:22 +03:00
|
|
|
|
<Button variant="secondary" onClick={handleClose} disabled={loading()}>
|
2025-07-01 01:20:48 +03:00
|
|
|
|
Отмена
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
variant="primary"
|
|
|
|
|
onClick={handleSetParent}
|
2025-07-01 09:32:22 +03:00
|
|
|
|
disabled={loading() || selectedParentId() === getCurrentParentId()}
|
2025-07-01 01:20:48 +03:00
|
|
|
|
>
|
|
|
|
|
{loading() ? 'Назначение...' : 'Назначить родителя'}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</Show>
|
|
|
|
|
</div>
|
|
|
|
|
</Modal>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default TopicSimpleParentModal
|