Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
30 changes: 11 additions & 19 deletions docs/pages.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -44,6 +46,7 @@ public class ResourceDetailsController extends Controller {
@FXML TableColumn<FileInfo, Void> downloadColumn;
@FXML Button backButton;
@FXML Button editButton;
@FXML Button deleteButton;

private UUID resourceId;

Expand Down Expand Up @@ -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();
}

Expand Down Expand Up @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -33,19 +35,22 @@ public class ResourceListController extends Controller {
@FXML private Label pageInfoLabel;
@FXML private Label statusLabel;
@FXML private TextField searchField;
@FXML private TextField descriptionField;
@FXML private ComboBox<String> sortCombo;
@FXML private TableView<Resource> resourceTable;
@FXML private TableColumn<Resource, String> titleColumn;
@FXML private TableColumn<Resource, String> descriptionColumn;
@FXML private TableColumn<Resource, String> createdColumn;
@FXML private TableColumn<Resource, String> updatedColumn;
@FXML private Button createButton;
@FXML private Button deleteButton;
@FXML private Button prevButton;
@FXML private Button nextButton;
@FXML private Button logoutButton;

private final ObservableList<Resource> rows = FXCollections.observableArrayList();
private long offset = 0;
private boolean canModify = false;

// ===== Lifecycle =====

Expand All @@ -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);

Expand Down Expand Up @@ -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 -> {
Expand All @@ -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 =====
Expand All @@ -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;

Expand Down Expand Up @@ -171,6 +185,17 @@ protected List<Resource> 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");
Expand All @@ -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<Void>() {
@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();
Expand Down
4 changes: 4 additions & 0 deletions src/main/resources/fxml/ResourceDetails.fxml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@
text="Редактировать"
onAction="#onEdit"
styleClass="btn-ghost"/>
<Button fx:id="deleteButton"
text="Удалить"
onAction="#onDelete"
styleClass="btn-ghost"/>
<Region HBox.hgrow="ALWAYS"/>
<Label fx:id="statusLabel" styleClass="pagination-info"/>
</HBox>
Expand Down
9 changes: 9 additions & 0 deletions src/main/resources/fxml/ResourceList.fxml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@
<Label text="Список всех документов в архиве" styleClass="card-subtitle"/>
</VBox>
<Region HBox.hgrow="ALWAYS"/>
<Button fx:id="deleteButton"
text="Удалить"
onAction="#onDelete"
styleClass="btn-ghost"/>
<Button fx:id="createButton"
text="+ Создать документ"
onAction="#onCreate"
Expand All @@ -52,6 +56,11 @@
onAction="#onSearch"
styleClass="text-input"
HBox.hgrow="ALWAYS"/>
<TextField fx:id="descriptionField"
promptText="Поиск по описанию..."
onAction="#onSearch"
styleClass="text-input"
HBox.hgrow="ALWAYS"/>
<ComboBox fx:id="sortCombo"
onAction="#onSort"
styleClass="combo"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
Expand Down Expand Up @@ -50,6 +51,9 @@ class ResourceDetailsControllerTest {

static class TestableResourceDetailsController extends ResourceDetailsController {
private File directory;
boolean confirmDeleteResult = true;
boolean stubConfirm = true;
int confirmCalls = 0;

void setDirectory(File directory) {
this.directory = directory;
Expand All @@ -59,6 +63,15 @@ void setDirectory(File directory) {
protected File chooseDirectory(Window owner) {
return directory;
}

@Override
protected boolean confirmDelete() {
confirmCalls++;
if (stubConfirm) {
return confirmDeleteResult;
}
return super.confirmDelete();
}
}

@BeforeAll
Expand Down Expand Up @@ -177,6 +190,48 @@ void backNavigatesToResourceList() {
verify(navigator).goTo("/fxml/ResourceList.fxml");
}

@Test
void confirmedDeleteCallsServiceAndNavigatesToList() {
when(resourceService.getById(RESOURCE_ID)).thenReturn(resourceWithFiles());
controller.setResourceId(RESOURCE_ID);
controller.confirmDeleteResult = true;

controller.onDelete(new ActionEvent());

verify(resourceService).delete(RESOURCE_ID);
verify(navigator).goTo("/fxml/ResourceList.fxml");
assertFalse(controller.errorLabel.isVisible());
}

@Test
void cancelledDeleteDoesNotCallServiceNorNavigate() {
when(resourceService.getById(RESOURCE_ID)).thenReturn(resourceWithFiles());
controller.setResourceId(RESOURCE_ID);
controller.confirmDeleteResult = false;

controller.onDelete(new ActionEvent());

assertEquals(1, controller.confirmCalls);
verify(resourceService, never()).delete(any());
verifyNoInteractions(navigator);
}

@Test
void deleteErrorShowsInErrorLabelWithoutNavigation() {
when(resourceService.getById(RESOURCE_ID)).thenReturn(resourceWithFiles());
controller.setResourceId(RESOURCE_ID);
controller.confirmDeleteResult = true;
org.mockito.Mockito.doThrow(new ServiceException("Удаление запрещено"))
.when(resourceService)
.delete(RESOURCE_ID);

controller.onDelete(new ActionEvent());

assertTrue(controller.errorLabel.isVisible());
assertEquals("Удаление запрещено", controller.errorLabel.getText());
verify(navigator, never()).goTo(any());
}

private Resource resourceWithFiles() {
FileInfo first = new FileInfo(FILE_ID_1, "report.pdf", "application/pdf", 12345L);
FileInfo second = new FileInfo(FILE_ID_2, "photo.png", "image/png", 678L);
Expand Down
Loading
Loading