diff --git a/pom.xml b/pom.xml index f37843e..1094a2a 100644 --- a/pom.xml +++ b/pom.xml @@ -106,11 +106,6 @@ - - org.kordamp.bootstrapfx - bootstrapfx-core - 0.4.0 - org.mockito mockito-core diff --git a/src/main/java/com/DesktopApplicationClientJava/Program.java b/src/main/java/com/DesktopApplicationClientJava/Program.java index 9a02450..c8f43a4 100644 --- a/src/main/java/com/DesktopApplicationClientJava/Program.java +++ b/src/main/java/com/DesktopApplicationClientJava/Program.java @@ -11,13 +11,12 @@ import javafx.scene.Parent; import javafx.scene.Scene; import javafx.stage.Stage; -import org.kordamp.bootstrapfx.BootstrapFX; public class Program extends Application { private static final String INITIAL_PAGE_PATH = System.getenv().getOrDefault("INITIAL_PAGE_PATH", "/fxml/LoginForm.fxml"); - private static final boolean MOCK_DATA = - Boolean.parseBoolean(System.getenv().getOrDefault("MOCK_DATA", "false")); + private static final boolean MOCK_DATA = true; + // Boolean.parseBoolean(System.getenv().getOrDefault("MOCK_DATA", "false")); private static final double WINDOW_WIDTH = 800.0; private static final double WINDOW_HEIGHT = 600.0; @@ -25,10 +24,6 @@ public class Program extends Application { private final ApiClient apiClient = new ApiClient(session); private final ServiceRegistry services = createServices(); - public static void main(String[] args) { - launch(args); - } - @Override public void start(Stage stage) throws IOException { Navigator navigator = new Navigator(stage, session, services); @@ -47,7 +42,6 @@ public void start(Stage stage) throws IOException { Parent root = loader.load(); Scene scene = new Scene(root); - scene.getStylesheets().add(BootstrapFX.bootstrapFXStylesheet()); loadStyleSheet(scene, "/styles.css"); stage.setScene(scene); diff --git a/src/main/java/com/DesktopApplicationClientJava/controllers/LoginFormController.java b/src/main/java/com/DesktopApplicationClientJava/controllers/LoginFormController.java index a88dd82..5af6832 100644 --- a/src/main/java/com/DesktopApplicationClientJava/controllers/LoginFormController.java +++ b/src/main/java/com/DesktopApplicationClientJava/controllers/LoginFormController.java @@ -35,7 +35,6 @@ private void onSubmit(ActionEvent event) { } try { - // ← ВЫЗОВ СЕРВИСА (middleware): session уже обновлён сервисом при успехе User user = services.getAuthService().login(email, password); if (user == null) { return; diff --git a/src/main/java/com/DesktopApplicationClientJava/controllers/ResourceDetailsController.java b/src/main/java/com/DesktopApplicationClientJava/controllers/ResourceDetailsController.java index 7d7f92c..dfd28ab 100644 --- a/src/main/java/com/DesktopApplicationClientJava/controllers/ResourceDetailsController.java +++ b/src/main/java/com/DesktopApplicationClientJava/controllers/ResourceDetailsController.java @@ -13,8 +13,10 @@ import java.util.List; import java.util.UUID; import java.util.function.Consumer; +import javafx.beans.property.SimpleStringProperty; import javafx.event.ActionEvent; import javafx.fxml.FXML; +import javafx.geometry.Pos; import javafx.scene.control.Button; import javafx.scene.control.Label; import javafx.scene.control.TableCell; @@ -23,7 +25,6 @@ import javafx.scene.control.cell.PropertyValueFactory; import javafx.stage.DirectoryChooser; import javafx.stage.Window; -import lombok.Setter; public class ResourceDetailsController extends Controller { @@ -38,20 +39,55 @@ public class ResourceDetailsController extends Controller { @FXML private Label statusLabel; @FXML private TableView filesTable; @FXML private TableColumn nameColumn; - @FXML private TableColumn sizeColumn; + @FXML private TableColumn sizeColumn; @FXML private TableColumn contentTypeColumn; @FXML private TableColumn downloadColumn; @FXML private Button backButton; + @FXML private Button editButton; - @Setter private UUID resourceId; + private UUID resourceId; + + /** + * Navigator sets the id after FXML load (see {@code Navigator.goTo}), so the setter itself + * must load the resource when the view is already initialized. + */ + public void setResourceId(UUID resourceId) { + this.resourceId = resourceId; + if (titleLabel != null && resourceId != null && services != null) { + load(); + } + } @FXML private void initialize() { hideError(); + nameColumn.setCellValueFactory(new PropertyValueFactory<>("name")); - sizeColumn.setCellValueFactory(new PropertyValueFactory<>("size")); contentTypeColumn.setCellValueFactory(new PropertyValueFactory<>("contentType")); + sizeColumn.setCellValueFactory( + c -> new SimpleStringProperty(formatSize(c.getValue().getSize()))); + sizeColumn.setCellFactory( + col -> { + TableCell cell = + new TableCell<>() { + @Override + protected void updateItem(String item, boolean empty) { + super.updateItem(item, empty); + setText(empty ? null : item); + setAlignment(Pos.CENTER_RIGHT); + } + }; + return cell; + }); + downloadColumn.setCellFactory(col -> new DownloadCell(this::onDownload)); + filesTable.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY_FLEX_LAST_COLUMN); + filesTable.setPlaceholder(new Label("У этого документа нет файлов")); + // NB: services/getAuthService may be a partial mock in tests — stay null-safe. + var authService = services == null ? null : services.getAuthService(); + boolean canEdit = authService != null && (authService.isAdmin() || authService.isModerator()); + editButton.setVisible(canEdit); + editButton.setManaged(canEdit); autoSelectFirstInMockMode(); } @@ -92,18 +128,17 @@ protected File chooseDirectory(Window owner) { * resource list. Never triggers against the real backend. */ private void autoSelectFirstInMockMode() { - if (resourceId != null || !(services.getResourceService() instanceof MockService mock)) { + if (resourceId != null + || services == null + || services.getResourceService() == null + || !(services.getResourceService() instanceof MockService mock)) { return; } mock.doIfInitialPage(); services.getResourceService().search(ResourceFilter.empty(), 0, 1).stream() .map(Resource::getUuid) .findFirst() - .ifPresent( - id -> { - setResourceId(id); - load(); - }); + .ifPresent(this::setResourceId); // the setter loads the resource } private void showResource(Resource resource) { @@ -120,6 +155,14 @@ private static String formatDateTime(LocalDateTime value) { return value == null ? "" : DATE_TIME_FORMAT.format(value); } + private static String formatSize(Long bytes) { + if (bytes == null) return ""; + if (bytes < 1024) return bytes + " B"; + if (bytes < 1024 * 1024) return String.format("%.1f KB", bytes / 1024.0); + if (bytes < 1024L * 1024 * 1024) return String.format("%.1f MB", bytes / (1024.0 * 1024)); + return String.format("%.1f GB", bytes / (1024.0 * 1024 * 1024)); + } + private void showError(String message) { errorLabel.setText(message); errorLabel.setVisible(true); @@ -136,7 +179,7 @@ private static final class DownloadCell extends TableCell { private final Button button = new Button("Скачать"); DownloadCell(Consumer onDownload) { - button.getStyleClass().setAll("btn", "btn-default"); + button.getStyleClass().setAll("btn-ghost"); button.setOnAction(event -> onDownload.accept(getTableView().getItems().get(getIndex()))); } @@ -146,4 +189,11 @@ protected void updateItem(Void item, boolean empty) { setGraphic(empty ? null : button); } } + + @FXML + private void onEdit(ActionEvent event) { + if (resourceId == null) return; + navigator.goTo( + "/fxml/ResourceForm.fxml", c -> ((ResourceFormController) c).setResourceId(resourceId)); + } } diff --git a/src/main/java/com/DesktopApplicationClientJava/controllers/ResourceFormController.java b/src/main/java/com/DesktopApplicationClientJava/controllers/ResourceFormController.java new file mode 100644 index 0000000..5320129 --- /dev/null +++ b/src/main/java/com/DesktopApplicationClientJava/controllers/ResourceFormController.java @@ -0,0 +1,113 @@ +package com.DesktopApplicationClientJava.controllers; + +import com.DesktopApplicationClientJava.controllers.utils.Controller; +import com.DesktopApplicationClientJava.entities.Resource; +import com.DesktopApplicationClientJava.services.ServiceException; +import java.util.UUID; +import javafx.event.ActionEvent; +import javafx.fxml.FXML; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.TextArea; +import javafx.scene.control.TextField; + +public class ResourceFormController extends Controller { + + private static final int TITLE_MIN = 3; + private static final int TITLE_MAX = 255; + + @FXML private Label formTitleLabel; + @FXML private TextField titleField; + @FXML private TextArea descriptionArea; + @FXML private Label errorLabel; + @FXML private Button cancelButton; + @FXML private Button saveButton; + + private UUID resourceId; + + /** + * Navigator sets the id after FXML load (see {@code Navigator.goTo}), so the setter itself + * must switch the form into edit mode when the view is already initialized. Setting the id before + * load is also honored by {@link #initialize()}. + */ + public void setResourceId(UUID resourceId) { + this.resourceId = resourceId; + if (formTitleLabel != null && resourceId != null && services != null) { + formTitleLabel.setText("Редактирование документа"); + loadExisting(); + } + } + + @FXML + private void initialize() { + hideError(); + if (resourceId == null) { + formTitleLabel.setText("Новый документ"); + } else { + formTitleLabel.setText("Редактирование документа"); + loadExisting(); + } + } + + private void loadExisting() { + try { + Resource r = services.getResourceService().getById(resourceId); + titleField.setText(r.getTitle() == null ? "" : r.getTitle()); + descriptionArea.setText(r.getDescription() == null ? "" : r.getDescription()); + } catch (ServiceException e) { + showError(e.getMessage()); + } + } + + @FXML + private void onSave(ActionEvent event) { + hideError(); + String title = titleField.getText() == null ? "" : titleField.getText().trim(); + String description = descriptionArea.getText() == null ? "" : descriptionArea.getText().trim(); + String descriptionOrNull = description.isBlank() ? null : description; + + if (title.isBlank()) { + showError("Название обязательно для заполнения"); + titleField.requestFocus(); + return; + } + if (title.length() < TITLE_MIN) { + showError("Название не может быть короче " + TITLE_MIN + " символов"); + titleField.requestFocus(); + return; + } + if (title.length() > TITLE_MAX) { + showError("Название не может быть длиннее " + TITLE_MAX + " символов"); + titleField.requestFocus(); + return; + } + + try { + if (resourceId == null) { + services.getResourceService().create(title, descriptionOrNull); + } else { + services.getResourceService().update(resourceId, title, descriptionOrNull); + } + navigator.goTo("/fxml/ResourceList.fxml"); + } catch (ServiceException e) { + showError(e.getMessage()); + } + } + + @FXML + private void onCancel(ActionEvent event) { + navigator.goTo("/fxml/ResourceList.fxml"); + } + + private void showError(String message) { + errorLabel.setText(message); + errorLabel.setVisible(true); + errorLabel.setManaged(true); + } + + private void hideError() { + errorLabel.setText(""); + errorLabel.setVisible(false); + errorLabel.setManaged(false); + } +} diff --git a/src/main/java/com/DesktopApplicationClientJava/controllers/ResourceListController.java b/src/main/java/com/DesktopApplicationClientJava/controllers/ResourceListController.java index 690b421..4a1d971 100644 --- a/src/main/java/com/DesktopApplicationClientJava/controllers/ResourceListController.java +++ b/src/main/java/com/DesktopApplicationClientJava/controllers/ResourceListController.java @@ -1,5 +1,253 @@ package com.DesktopApplicationClientJava.controllers; import com.DesktopApplicationClientJava.controllers.utils.Controller; +import com.DesktopApplicationClientJava.entities.Resource; +import com.DesktopApplicationClientJava.services.ServiceException; +import com.DesktopApplicationClientJava.services.filter.ResourceFilter; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; +import javafx.beans.property.SimpleStringProperty; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.concurrent.Task; +import javafx.event.ActionEvent; +import javafx.fxml.FXML; +import javafx.scene.control.Button; +import javafx.scene.control.ComboBox; +import javafx.scene.control.Label; +import javafx.scene.control.TableColumn; +import javafx.scene.control.TableRow; +import javafx.scene.control.TableView; +import javafx.scene.control.TextField; +import javafx.scene.control.cell.PropertyValueFactory; -public class ResourceListController extends Controller {} +public class ResourceListController extends Controller { + + private static final DateTimeFormatter DATE_TIME_FORMAT = + DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm"); + private static final int PAGE_SIZE = 20; + + @FXML private Label userInfoLabel; + @FXML private Label errorLabel; + @FXML private Label pageInfoLabel; + @FXML private Label statusLabel; + @FXML private TextField searchField; + @FXML private ComboBox sortCombo; + @FXML private TableView resourceTable; + @FXML private TableColumn titleColumn; + @FXML private TableColumn descriptionColumn; + @FXML private TableColumn createdColumn; + @FXML private TableColumn updatedColumn; + @FXML private Button createButton; + @FXML private Button prevButton; + @FXML private Button nextButton; + @FXML private Button logoutButton; + + private final ObservableList rows = FXCollections.observableArrayList(); + private long offset = 0; + + // ===== Lifecycle ===== + + @FXML + private void initialize() { + hideError(); + setupUser(); + setupSort(); + setupTable(); + setupRbac(); + + searchField.setOnAction(e -> reloadFromStart()); + sortCombo.setOnAction(e -> reloadFromStart()); + resourceTable.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY_FLEX_LAST_COLUMN); + + // Initial load on open. Guarded for FXML tooling previews where no services are wired. + if (services != null && services.getResourceService() != null) { + reloadFromStart(); + } + } + + // ===== UI setup ===== + + private void setupUser() { + // NB: services/getAuthService may be a partial mock in tests — stay null-safe. + var authService = services == null ? null : services.getAuthService(); + var me = authService == null ? null : authService.getCurrentUser(); + userInfoLabel.setText( + me == null ? "" : me.getFirstName() + " " + me.getLastName() + " · " + me.getRole()); + } + + private void setupSort() { + sortCombo + .getItems() + .setAll("Сначала новые", "Сначала старые", "По названию (А-Я)", "По названию (Я-А)"); + sortCombo.getSelectionModel().selectFirst(); + } + + private void setupTable() { + titleColumn.setCellValueFactory(new PropertyValueFactory<>("title")); + descriptionColumn.setCellValueFactory(new PropertyValueFactory<>("description")); + createdColumn.setCellValueFactory( + c -> new SimpleStringProperty(formatDate(c.getValue().getCreatedAt()))); + updatedColumn.setCellValueFactory( + c -> new SimpleStringProperty(formatDate(c.getValue().getUpdatedAt()))); + + resourceTable.setItems(rows); + resourceTable.setPlaceholder(new Label("Документов пока нет")); + + resourceTable.setRowFactory( + tv -> { + var row = new TableRow(); + row.setOnMouseClicked( + e -> { + if (e.getClickCount() == 2 && !row.isEmpty()) { + openDetails(row.getItem()); + } + }); + return row; + }); + } + + private void setupRbac() { + var authService = services == null ? null : services.getAuthService(); + boolean canCreate = authService != null && (authService.isAdmin() || authService.isModerator()); + createButton.setVisible(canCreate); + createButton.setManaged(canCreate); + } + + // ===== Data loading ===== + + private void reloadFromStart() { + offset = 0; + loadAsync(); + } + + private void loadAsync() { + hideError(); + statusLabel.setText("Загрузка…"); + + var filter = + new ResourceFilter( + searchField.getText() == null || searchField.getText().isBlank() + ? null + : searchField.getText().trim(), + null); + var sort = resolveSort(); + long currentOffset = offset; + + var task = + new Task>() { + @Override + protected List call() { + return services + .getResourceService() + .search(filter, currentOffset, PAGE_SIZE, sort.sortBy(), sort.sortDir()); + } + }; + + task.setOnSucceeded( + e -> { + var page = task.getValue(); + rows.setAll(page); + updatePaginationUi(page.size()); + statusLabel.setText(""); + }); + + task.setOnFailed( + e -> { + var ex = task.getException(); + String msg = + ex instanceof ServiceException se ? se.getMessage() : "Не удалось загрузить список"; + showError(msg); + rows.clear(); + updatePaginationUi(0); + statusLabel.setText(""); + }); + + var thread = new Thread(task, "resource-list-load"); + thread.setDaemon(true); + thread.start(); + } + + private record SortSpec(String sortBy, String sortDir) {} + + private SortSpec resolveSort() { + String choice = sortCombo.getValue(); + if (choice == null) return new SortSpec("createdAt", "desc"); + return switch (choice) { + case "Сначала старые" -> new SortSpec("createdAt", "asc"); + case "По названию (А-Я)" -> new SortSpec("title", "asc"); + case "По названию (Я-А)" -> new SortSpec("title", "desc"); + default -> new SortSpec("createdAt", "desc"); + }; + } + + private void updatePaginationUi(int pageSize) { + long from = rows.isEmpty() ? 0 : offset + 1; + long to = offset + pageSize; + pageInfoLabel.setText("Показано " + from + "–" + to); + prevButton.setDisable(offset == 0); + nextButton.setDisable(pageSize < PAGE_SIZE); + } + + // ===== Event handlers ===== + + @FXML + private void onCreate(ActionEvent e) { + navigator.goTo("/fxml/ResourceForm.fxml"); + } + + @FXML + private void onLogout(ActionEvent e) { + services.getAuthService().logout(); + navigator.goTo("/fxml/LoginForm.fxml"); + } + + @FXML + private void onSearch(ActionEvent e) { + offset = 0; + loadAsync(); + } + + @FXML + private void onSort(ActionEvent e) { + offset = 0; + loadAsync(); + } + + @FXML + private void onNext(ActionEvent e) { + offset += PAGE_SIZE; + loadAsync(); + } + + @FXML + private void onPrev(ActionEvent e) { + offset = Math.max(0, offset - PAGE_SIZE); + loadAsync(); + } + + private void openDetails(Resource resource) { + navigator.goTo( + "/fxml/ResourceDetails.fxml", + c -> ((ResourceDetailsController) c).setResourceId(resource.getUuid())); + } + + // ===== helpers ===== + + private static String formatDate(LocalDateTime value) { + return value == null ? "" : DATE_TIME_FORMAT.format(value); + } + + private void showError(String message) { + errorLabel.setText(message); + errorLabel.setVisible(true); + errorLabel.setManaged(true); + } + + private void hideError() { + errorLabel.setText(""); + errorLabel.setVisible(false); + errorLabel.setManaged(false); + } +} diff --git a/src/main/java/com/DesktopApplicationClientJava/services/ResourceService.java b/src/main/java/com/DesktopApplicationClientJava/services/ResourceService.java index 4075e5b..74f916b 100644 --- a/src/main/java/com/DesktopApplicationClientJava/services/ResourceService.java +++ b/src/main/java/com/DesktopApplicationClientJava/services/ResourceService.java @@ -8,6 +8,9 @@ public interface ResourceService { List search(ResourceFilter filter, long offset, long count); + List search( + ResourceFilter filter, long offset, long count, String sortBy, String sortDir); + Resource create(String title, String description); Resource getById(UUID resourceId); diff --git a/src/main/java/com/DesktopApplicationClientJava/services/impl/ResourceServiceImpl.java b/src/main/java/com/DesktopApplicationClientJava/services/impl/ResourceServiceImpl.java index b2473cf..febaa69 100644 --- a/src/main/java/com/DesktopApplicationClientJava/services/impl/ResourceServiceImpl.java +++ b/src/main/java/com/DesktopApplicationClientJava/services/impl/ResourceServiceImpl.java @@ -60,6 +60,22 @@ public Resource getById(UUID resourceId) { } } + @Override + public List search( + ResourceFilter filter, long offset, long count, String sortBy, String sortDir) { + ResourceFilterRequest req = + new ResourceFilterRequest(filter.getTitle(), filter.getDescription(), null, null); + try { + return apiClient.findResourcesByFilters(req, offset, count, sortBy, sortDir).stream() + .map(this::toEntity) + .toList(); + } catch (ApiException e) { + throw new ServiceException(e.getMessage(), e.getStatus()); + } catch (IOException | InterruptedException e) { + throw new ServiceException("Ошибка соединения с сервером"); + } + } + @Override public Resource update(UUID resourceId, String title, String description) { UpdateResourceRequest req = new UpdateResourceRequest(title, description); diff --git a/src/main/java/com/DesktopApplicationClientJava/services/mock/MockResourceService.java b/src/main/java/com/DesktopApplicationClientJava/services/mock/MockResourceService.java index 7689d38..a205aa1 100644 --- a/src/main/java/com/DesktopApplicationClientJava/services/mock/MockResourceService.java +++ b/src/main/java/com/DesktopApplicationClientJava/services/mock/MockResourceService.java @@ -6,8 +6,10 @@ import com.DesktopApplicationClientJava.services.filter.ResourceFilter; import java.time.LocalDateTime; import java.util.ArrayList; +import java.util.Comparator; import java.util.List; import java.util.UUID; +import java.util.stream.Collectors; /** In-memory {@link ResourceService} over {@link MockStore}. */ public class MockResourceService implements ResourceService, MockService { @@ -45,6 +47,37 @@ public Resource getById(UUID resourceId) { return resource; } + @Override + public List search( + ResourceFilter filter, long offset, long count, String sortBy, String sortDir) { + // 1. Сначала фильтрация (как в текущей 3-аргументной версии) + List filtered = + store.getResources().values().stream() + .filter(r -> matches(r, filter)) + .collect(Collectors.toList()); + + // 2. Сортировка по sortBy/sortDir + Comparator comparator = + switch (sortBy == null ? "createdAt" : sortBy) { + case "title" -> + Comparator.comparing( + r -> r.getTitle() == null ? "" : r.getTitle(), String.CASE_INSENSITIVE_ORDER); + default -> + Comparator.comparing( + Resource::getCreatedAt, Comparator.nullsLast(Comparator.naturalOrder())); + }; + if ("desc".equalsIgnoreCase(sortDir)) { + comparator = comparator.reversed(); + } + filtered.sort(comparator); + + // 3. Пагинация + int from = (int) Math.max(0, offset); + int to = (int) Math.min(filtered.size(), from + count); + if (from >= filtered.size()) return List.of(); + return List.copyOf(filtered.subList(from, to)); + } + @Override public Resource update(UUID resourceId, String title, String description) { Resource resource = getById(resourceId); diff --git a/src/main/resources/fxml/LoginForm.fxml b/src/main/resources/fxml/LoginForm.fxml index 52d75ac..74357d9 100644 --- a/src/main/resources/fxml/LoginForm.fxml +++ b/src/main/resources/fxml/LoginForm.fxml @@ -7,33 +7,52 @@ - - - - - - - -