diff --git a/compose.yaml b/compose.yaml index f0aed11..fea50bf 100644 --- a/compose.yaml +++ b/compose.yaml @@ -31,11 +31,10 @@ services: condition: service_healthy minio: condition: service_healthy - healthcheck: - interval: 10s - retries: 5 - test: ["CMD-SHELL", "curl -f http://localhost:8080/java-pks/health/live || exit 1"] - timeout: 5s + # Без healthcheck: образ собран buildpacks без shell/curl/wget, поэтому внутреннюю + # HTTP-пробу выполнить нечем (CMD-SHELL падает с "stat /bin/sh: no such file or directory", + # давая ложный статус unhealthy). На health бэкенда ничего не завязано (depends_on + # на него нет); готовность видна по логам ("Started JavaPksApplication") и порту 8080. ports: - "8080:8080" environment: diff --git a/docs/pages.md b/docs/pages.md index a130d9c..6960bfd 100644 --- a/docs/pages.md +++ b/docs/pages.md @@ -18,32 +18,20 @@ UI не ходит в HTTP напрямую. Контроллер вызывае | № | Название страницы | Маршрут (FXML) | Доступ | Назначение | Ответственный | Статус | | --- | -------------------------------------------- | ----------------------------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | ----------------------------- | | 1 | Вход (email + пароль) | `/fxml/LoginForm.fxml` | гость (публичная) | Аутентификация через `AuthService`, после успеха → №2 | @vladcheck | ✅ готово | -| 2 | Список ресурсов и сортировка (главная) | `/fxml/ResourceList.fxml` | `USER` | Поиск по `title`, сортировка, пагинация `offset/count` (`PAGE_SIZE=20`) через `ResourceService.search` | @miyagi18 | ⚠️ частично (см. DOD №2) | +| 2 | Список ресурсов и сортировка (главная) | `/fxml/ResourceList.fxml` | `USER` | Поиск по `title` и `description`, удаление с подтверждением, сортировка, пагинация `offset/count` (`PAGE_SIZE=20`) через `ResourceService.search` | @miyagi18 | ✅ готово | | 3 | Просмотр метаданных ресурса | `/fxml/ResourceDetails.fxml` | `USER` | Только чтение: `ResourceService.getById` → `title, description, createdAt, updatedAt, uuid`; таблица файлов (имя/размер/тип + «Скачать») | @vladcheck | ⚠️ частично (см. DOD №3) | -| 4 | Форма ресурса (создание/редактирование) | `/fxml/ResourceForm.fxml` | `MODER` | `create/update` через `ResourceService`; удаление — действием из №2/№3 с подтверждением, без отдельной страницы | @miyagi18 | ⚠️ частично (см. DOD №4) | +| 4 | Форма ресурса (создание/редактирование) | `/fxml/ResourceForm.fxml` | `MODER` | `create/update` через `ResourceService`; удаление — действием из №2/№3 с подтверждением, без отдельной страницы | @miyagi18 | ✅ готово | | 5 | Список пользователей | `/fxml/UserList.fxml` (нет) | `ADMIN` | Поиск/фильтр через `UsersService.search` + `UserFilter`; удаление действием из списка | @deatheh | ❌ нет (сервис готов, UI нет) | | 6 | Форма пользователя (создание/редактирование) | `/fxml/UserForm.fxml` (нет) | `ADMIN` | `create` через `RegisterData`, `update` через `UpdateUserData`, `getById` для предзаполнения | @deatheh | ❌ нет (сервис готов, UI нет) | ## DOD для ⚠️-страниц -### №2 — Список ресурсов - -* Есть: `ResourceListController` + `ResourceList.fxml`: поле поиска по `title`, `ComboBox` сортировки (4 опции: `createdAt desc/asc`, `title asc/desc`), таблица (`title, description, createdAt, updatedAt`), пагинация `offset/count` с `PAGE_SIZE=20`, переход дабл-кликом в №3 через `Navigator.goTo(path, c -> setResourceId(uuid))`, кнопка «+ Создать документ» только для `MODER/ADMIN`. -* Нет: поля фильтра по `description` в UI нет (`ResourceFilter.description` всегда `null`); кнопки удаления в списке нет. -* Готово когда: заведено поле `description` (или зафиксировано решение «искать только по title») и заведена кнопка удаления с подтверждением (см. DOD №4). - ### №3 — Просмотр метаданных ресурса -* Есть: `ResourceDetailsController` (read-only): метаданные + таблица файлов (`name, size, contentType` + колонка «Скачать»), скачивание через `FileService.downloadFile` (`DirectoryChooser`, имя из `Content-Disposition`, fallback `fileUuid`), вход из №2 через `setResourceId(uuid)` + `load()`, кнопка «Редактировать» только для `MODER/ADMIN` (переход в №4). +* Есть: `ResourceDetailsController` (read-only): метаданные + таблица файлов (`name, size, contentType` + колонка «Скачать»), скачивание через `FileService.downloadFile` (`DirectoryChooser`, имя из `Content-Disposition`, fallback `fileUuid`), вход из №2 через `setResourceId(uuid)` + `load()`, кнопка «Редактировать» только для `MODER/ADMIN` (переход в №4), кнопка «Удалить» с диалогом подтверждения только для `MODER/ADMIN` (после удаления → №2). * Нет: кнопок загрузки/удаления файлов в UI нет; кнопки экспорта архива файлов нет. * Готово когда: заведены upload/delete (`FileService.{uploadFile, deleteFile}` уже есть) и экспорт (`ExportService.exportResourceFiles` уже есть) либо зафиксировано решение «в №3 только скачивание». -### №4 — Форма ресурса - -* Есть: `ResourceFormController` + `ResourceForm.fxml`: режимы `create/edit` по наличию `resourceId`, предзаполнение через `getById`, ручная проверка `title` (`обязательно, 3–255 символов`, пустой `description` → `null`), сохранение через `create/update`, отмена → №2. -* Нет: кнопки удаления в №2/№3/№4 нет, диалога подтверждения нет (`ResourceService.delete` реализован, UI не заведено). -* Готово когда: удаление вызывается действием из №2/№3 с диалогом подтверждения, без отдельной страницы. - ## Интеграции страниц с эндпоинтами (через сервисы) Базовый URL: `api.baseUrl` (`http://localhost:8080`, см. `src/main/resources/api.properties`). @@ -52,14 +40,14 @@ UI не ходит в HTTP напрямую. Контроллер вызывае | --- | --------------------------------------- | ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | | 1 | Войти | `AuthService.login(email, password)` | `POST /api/v1/auth/login` (`LoginRequest`), затем `GET /api/v1/user/me`; токены → `Session` | | 1 | Выйти | `AuthService.logout()` | `POST /api/v1/auth/logout` (`RefreshTokenRequest`), затем `Session.clear()` | -| 2 | Поиск/сортировка ресурсов (только UI-факт: поиск по `title`) | `ResourceService.search(filter, offset, count[, sortBy, sortDir])` | `GET /api/v1/resource?title=&description=&offset=&count=&sortBy=&sortDir=` (UI шлёт только `title`; `description` поддерживается сервисом, из UI всегда `null`; сортировка `createdAt/title asc/desc`) | +| 2 | Поиск/сортировка ресурсов | `ResourceService.search(filter, offset, count[, sortBy, sortDir])` | `GET /api/v1/resource?title=&description=&offset=&count=&sortBy=&sortDir=` (UI шлёт `title` и `description`; сортировка `createdAt/title asc/desc`) | | 3 | Открыть метаданные | `ResourceService.getById(id)` | `GET /api/v1/resource/{id}` | | 3 | Скачать файл (UI-факт) | `FileService.downloadFile` | `GET /api/v1/resource/{id}/file/{fileId}` (имя из `Content-Disposition`) | | 3 | Загрузить/удалить файл (сервис готов, UI не заведено) | `FileService.{uploadFile, deleteFile}` | `POST /api/v1/resource/{id}/file` (multipart), `DELETE /api/v1/resource/{id}/file/{fileId}` | | 3 | Экспорт файлов ресурса (сервис готов, UI не заведено) | `ExportService.exportResourceFiles(id, dir)` | `GET /api/v1/admin/resource/{id}/files/export` (бинарный ответ → файл `files-{uuid}.xlsx`) | | 4 | Создать ресурс | `ResourceService.create(title, description)` | `POST /api/v1/resource` (`CreateResourceRequest`) | | 4 | Обновить ресурс | `ResourceService.update(id, title, description)` | `PUT /api/v1/resource/{id}` (`UpdateResourceRequest`) | -| 4 | Удалить ресурс из №2/№3 (сервис готов, UI не заведено) | `ResourceService.delete(id)` | `DELETE /api/v1/resource/{id}` | +| 4 | Удалить ресурс из №2/№3 | `ResourceService.delete(id)` | `DELETE /api/v1/resource/{id}` | | 5 | Поиск/фильтр пользователей (сервис готов, UI не заведено) | `UsersService.search(filter, offset, count)` | `GET /api/v1/admin?email=&firstName=&lastName=&role=&enabled=&offset=&count=&sortBy=createdAt&sortDir=desc` (сортировка захардкожена в `UsersServiceImpl`) | | 5 | Удалить пользователя (сервис готов, UI не заведено) | `UsersService.delete(id)` | `DELETE /api/v1/admin/{id}` | | 5 | Экспорт пользователей/ресурсов (сервис готов, UI не заведено) | `ExportService.{exportUsers, exportResources}` | `GET /api/v1/admin/users/export` (→ `users.xlsx`), `GET /api/v1/admin/resource/export` (→ `resources.xlsx`) | @@ -77,7 +65,7 @@ UI не ходит в HTTP напрямую. Контроллер вызывае ## Примечания по реализации * №1: ✅ готово — `LoginFormController` только email + пароль: проверка непустоты + `EmailValidator.validate`, вызов `AuthService.login`, после успеха → №2. -* №2: переименование `Search.fxml → ResourceList.fxml` готово; список наполнен: `ResourceService.search` + пагинация `PAGE_SIZE=20`, фильтр `ResourceFilter` (факт: из UI заполняется только `title`). +* №2: ✅ готово — `ResourceListController`: поиск по `title` и `description` (`ResourceFilter`), сортировка, пагинация `PAGE_SIZE=20`, переход в №3 дабл-кликом, кнопки «+ Создать документ» и «Удалить» (с диалогом подтверждения) только для `MODER/ADMIN`. * №3: частично — `ResourceDetailsController` (read-only): метаданные + таблица файлов со скачиванием (`FileService.downloadFile`), вход из №2 — `Navigator.goTo(path, c -> ...)` с `setResourceId(uuid)` + `load()`. Upload/delete/экспорт не заведены. @@ -88,7 +76,7 @@ UI не ходит в HTTP напрямую. Контроллер вызывае * Роли: `AuthServiceImpl.hasRole` сравнивает по уровню (`USER=0 < MODER=1 < ADMIN=2`), поэтому `isModerator()` возвращает true и для `ADMIN` — соответствует RBAC выше. * SoC: списки (№2) не содержат логики форм; форма №4 работает в двух режимах `create/edit` по наличию `uuid`. -* Кнопки: `create` (№2) и `edit` (№3) рендерятся по роли (`isAdmin() || isModerator()` из `Session.getCurrentUser().role`), а не отдельными страницами. Кнопки `delete` не рендерятся нигде. +* Кнопки: `create`/`delete` (№2) и `edit`/`delete` (№3) рендерятся по роли (`isAdmin() || isModerator()` из `Session.getCurrentUser().role`), а не отдельными страницами. * Проверка ввода (факт): №1 — `EmailValidator`; №4 — ручные проверки длины; классы `PasswordValidator/FirstNameValidator/LastNameValidator/ThirdNameValidator` на FormsFX существуют как задел под №6, в текущих страницах не используются. * Терминология: в доке используется «ресурс» per `GLOSSARY.md` (avoid «документ»). @@ -105,6 +93,10 @@ UI не ходит в HTTP напрямую. Контроллер вызывае (`MockAuthService/MockUsersService/MockResourceService/MockFileService/MockExportService` + `MockStore`), `MOCK_DATA=true` / `INITIAL_PAGE_PATH` в `Program`. Покрыты: `LoginFormController`, `ResourceListController`, `ResourceDetailsController`, `ResourceFormController`, мапперы, валидаторы. +* Удаление ресурсов: компонентные тесты настоящей модалки (`ResourceListControllerTest`, + `ResourceDetailsControllerUiTest`: открытие/отмена/подтверждение/ошибка 404) + live-цикл + в `ResourceListIntegrationTest` (создание → удаление → проверка отсутствия на бэкенде; + кейс за MODER — `@Disabled`, сида MODER в бэкенде нет). * Live-интеграционные (`@Tag("integration")`, `src/test/.../ui/integration/`): исключены из дефолтного билда/CI через `excludedGroups` в surefire; требуют живой бэкенд `localhost:8080` и `.env` (`IT_ADMIN_EMAIL`, `IT_ADMIN_PASSWORD`, `dotenv.path`). diff --git a/src/main/java/com/DesktopApplicationClientJava/controllers/ResourceDetailsController.java b/src/main/java/com/DesktopApplicationClientJava/controllers/ResourceDetailsController.java index ff24f32..36b30ca 100644 --- a/src/main/java/com/DesktopApplicationClientJava/controllers/ResourceDetailsController.java +++ b/src/main/java/com/DesktopApplicationClientJava/controllers/ResourceDetailsController.java @@ -17,7 +17,9 @@ import javafx.event.ActionEvent; import javafx.fxml.FXML; import javafx.geometry.Pos; +import javafx.scene.control.Alert; import javafx.scene.control.Button; +import javafx.scene.control.ButtonType; import javafx.scene.control.Label; import javafx.scene.control.TableCell; import javafx.scene.control.TableColumn; @@ -44,6 +46,7 @@ public class ResourceDetailsController extends Controller { @FXML TableColumn downloadColumn; @FXML Button backButton; @FXML Button editButton; + @FXML Button deleteButton; private UUID resourceId; @@ -88,6 +91,8 @@ protected void updateItem(String item, boolean empty) { boolean canEdit = authService != null && (authService.isAdmin() || authService.isModerator()); editButton.setVisible(canEdit); editButton.setManaged(canEdit); + deleteButton.setVisible(canEdit); + deleteButton.setManaged(canEdit); autoSelectFirstInMockMode(); } @@ -197,4 +202,29 @@ private void onEdit(ActionEvent event) { navigator.goTo( "/fxml/ResourceForm.fxml", c -> ((ResourceFormController) c).setResourceId(resourceId)); } + + @FXML + void onDelete(ActionEvent event) { + if (resourceId == null) return; + if (!confirmDelete()) return; + try { + services.getResourceService().delete(resourceId); + } catch (ServiceException e) { + showError(e.getMessage()); + return; + } + navigator.goTo("/fxml/ResourceList.fxml"); + } + + /** + * Диалог подтверждения удаления. Выделен в метод ради тестов: headless {@code Alert} блокирует + * поток, поэтому тесты переопределяют его без UI. + */ + protected boolean confirmDelete() { + var alert = new Alert(Alert.AlertType.CONFIRMATION); + alert.setTitle("Удаление ресурса"); + alert.setHeaderText("Удалить ресурс?"); + alert.setContentText("Ресурс будет удалён без возможности восстановления."); + return alert.showAndWait().map(button -> button == ButtonType.OK).orElse(false); + } } diff --git a/src/main/java/com/DesktopApplicationClientJava/controllers/ResourceListController.java b/src/main/java/com/DesktopApplicationClientJava/controllers/ResourceListController.java index 4a1d971..e187096 100644 --- a/src/main/java/com/DesktopApplicationClientJava/controllers/ResourceListController.java +++ b/src/main/java/com/DesktopApplicationClientJava/controllers/ResourceListController.java @@ -13,7 +13,9 @@ import javafx.concurrent.Task; import javafx.event.ActionEvent; import javafx.fxml.FXML; +import javafx.scene.control.Alert; import javafx.scene.control.Button; +import javafx.scene.control.ButtonType; import javafx.scene.control.ComboBox; import javafx.scene.control.Label; import javafx.scene.control.TableColumn; @@ -33,6 +35,7 @@ public class ResourceListController extends Controller { @FXML private Label pageInfoLabel; @FXML private Label statusLabel; @FXML private TextField searchField; + @FXML private TextField descriptionField; @FXML private ComboBox sortCombo; @FXML private TableView resourceTable; @FXML private TableColumn titleColumn; @@ -40,12 +43,14 @@ public class ResourceListController extends Controller { @FXML private TableColumn createdColumn; @FXML private TableColumn updatedColumn; @FXML private Button createButton; + @FXML private Button deleteButton; @FXML private Button prevButton; @FXML private Button nextButton; @FXML private Button logoutButton; private final ObservableList rows = FXCollections.observableArrayList(); private long offset = 0; + private boolean canModify = false; // ===== Lifecycle ===== @@ -58,6 +63,7 @@ private void initialize() { setupRbac(); searchField.setOnAction(e -> reloadFromStart()); + descriptionField.setOnAction(e -> reloadFromStart()); sortCombo.setOnAction(e -> reloadFromStart()); resourceTable.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY_FLEX_LAST_COLUMN); @@ -94,6 +100,10 @@ private void setupTable() { resourceTable.setItems(rows); resourceTable.setPlaceholder(new Label("Документов пока нет")); + resourceTable + .getSelectionModel() + .selectedItemProperty() + .addListener((obs, oldSel, newSel) -> updateDeleteButtonState()); resourceTable.setRowFactory( tv -> { @@ -110,9 +120,18 @@ private void setupTable() { private void setupRbac() { var authService = services == null ? null : services.getAuthService(); - boolean canCreate = authService != null && (authService.isAdmin() || authService.isModerator()); - createButton.setVisible(canCreate); - createButton.setManaged(canCreate); + canModify = authService != null && (authService.isAdmin() || authService.isModerator()); + createButton.setVisible(canModify); + createButton.setManaged(canModify); + deleteButton.setVisible(canModify); + deleteButton.setManaged(canModify); + updateDeleteButtonState(); + } + + private void updateDeleteButtonState() { + boolean hasSelection = + resourceTable != null && resourceTable.getSelectionModel().getSelectedItem() != null; + deleteButton.setDisable(!(canModify && hasSelection)); } // ===== Data loading ===== @@ -126,12 +145,7 @@ private void loadAsync() { hideError(); statusLabel.setText("Загрузка…"); - var filter = - new ResourceFilter( - searchField.getText() == null || searchField.getText().isBlank() - ? null - : searchField.getText().trim(), - null); + var filter = buildFilter(); var sort = resolveSort(); long currentOffset = offset; @@ -171,6 +185,17 @@ protected List call() { private record SortSpec(String sortBy, String sortDir) {} + private ResourceFilter buildFilter() { + return new ResourceFilter(normalizeQuery(searchField), normalizeQuery(descriptionField)); + } + + private static String normalizeQuery(TextField field) { + if (field == null || field.getText() == null || field.getText().isBlank()) { + return null; + } + return field.getText().trim(); + } + private SortSpec resolveSort() { String choice = sortCombo.getValue(); if (choice == null) return new SortSpec("createdAt", "desc"); @@ -197,6 +222,60 @@ private void onCreate(ActionEvent e) { navigator.goTo("/fxml/ResourceForm.fxml"); } + @FXML + void onDelete(ActionEvent e) { + var selected = resourceTable.getSelectionModel().getSelectedItem(); + if (selected == null) { + return; + } + if (!confirmDelete(selected)) { + return; + } + deleteAsync(selected); + } + + private void deleteAsync(Resource resource) { + hideError(); + statusLabel.setText("Удаление…"); + + var task = + new Task() { + @Override + protected Void call() { + services.getResourceService().delete(resource.getUuid()); + return null; + } + }; + + task.setOnSucceeded(e -> loadAsync()); + + task.setOnFailed( + e -> { + var ex = task.getException(); + String msg = + ex instanceof ServiceException se ? se.getMessage() : "Не удалось удалить ресурс"; + showError(msg); + statusLabel.setText(""); + }); + + var thread = new Thread(task, "resource-list-delete"); + thread.setDaemon(true); + thread.start(); + } + + /** + * Диалог подтверждения удаления. Выделен в метод ради тестов: headless {@code Alert} блокирует + * поток, поэтому тесты переопределяют его без UI. + */ + protected boolean confirmDelete(Resource resource) { + var alert = new Alert(Alert.AlertType.CONFIRMATION); + alert.setTitle("Удаление ресурса"); + alert.setHeaderText("Удалить ресурс?"); + alert.setContentText( + "«" + resource.getTitle() + "» будет удалён без возможности восстановления."); + return alert.showAndWait().map(button -> button == ButtonType.OK).orElse(false); + } + @FXML private void onLogout(ActionEvent e) { services.getAuthService().logout(); diff --git a/src/main/resources/fxml/ResourceDetails.fxml b/src/main/resources/fxml/ResourceDetails.fxml index 7143b84..846e6a0 100644 --- a/src/main/resources/fxml/ResourceDetails.fxml +++ b/src/main/resources/fxml/ResourceDetails.fxml @@ -28,6 +28,10 @@ text="Редактировать" onAction="#onEdit" styleClass="btn-ghost"/> +