Skip to content

Commit 00ac0eb

Browse files
authored
Страница №3: просмотр метаданных документа (read-only) (#18)
## Что сделано Read-only страница «Просмотр метаданных документа» (№3 из docs/страницы.md, доступ USER): - Метаданные: title, description, createdAt/updatedAt (формат dd.MM.yyyy HH:mm), uuid моноширинным. - Таблица файлов: имя, размер, content-type + кнопка «Скачать» в каждой строке (DirectoryChooser → FileService.downloadFile → статус «Сохранено: <имя>»). - Кнопка «Назад» → ResourceList. Ошибки сервиса (в т.ч. 404) — в errorLabel, как на логине. ## Подготовка (коммит 1) 1. **Navigator.goTo с параметром**: перегрузка `goTo(fxml, Consumer<Object> withController)` — отдаёт контроллер в колбэк после wiring, до смены root. Старый `goTo(path)` без изменений. 2. **Файлы в модели**: `Resource.files: List<FileInfo>`, маппинг FileDto→FileInfo в toEntity (null → пустой список). 3. **Иерархия ролей**: `hasRole` сравнивает по уровню USER=0 < MODER=1 < ADMIN=2, поэтому `isModerator()` true и для ADMIN (соответствует RBAC в доке). Сегодня hasRole в прод-коде нигде не использовался — ничего не ломает. ## Тесты (коммит 2) - Unit (TestFX + моки): загрузка (лейблы + 2 строки), 404 → errorLabel, скачивание (статус / ошибка / отмена диалога — выбор папки вынесен в переопределяемый `chooseDirectory`), Назад → ResourceList. - AuthServiceTest: иерархия USER⊂MODER⊂ADMIN, гость → false. - Интеграционные (@tag integration, без моков): 404 на случайном UUID; create/getById/delete под admin. ## Проверка - `mvn verify`: 42 unit-теста зелёные, spotless + checkstyle чистые (хуки тоже прошли). - ⚠️ Интеграционные локально **пропущены**: colima/docker демон сейчас не запущен, бэкенд недоступен. Прогнать при поднятом бэкенде: `mvn test -Dgroups=integration -DexcludedGroups=` - Ручной прогон едет вместе со списком №2: временный INITIAL_PAGE_PATH не ставил (без uuid страница покажет ошибку) — входной точкой будет `setResourceId` из ResourceList. Вне скоупа (следующие шаги): загрузка/удаление файлов, экспорт, редактирование/удаление ресурса.
1 parent f79f066 commit 00ac0eb

33 files changed

Lines changed: 1474 additions & 136 deletions

‎.env.example‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,15 @@ MINIO_ROOT_PASSWORD=changeme-minio-root
1717
MINIO_BUCKET_NAME=local-bucket
1818
MINIO_ACCESS_TOKEN=changeme-access
1919
MINIO_SECRET_KEY=changeme-secret
20+
21+
# --- App ---
22+
# First page on startup (handy for manual UI checks, e.g. /fxml/ResourceDetails.fxml).
23+
INITIAL_PAGE_PATH=/fxml/LoginForm.fxml
24+
# Run without a backend, on in-memory mock data (for UI testing). Window title gets [MOCK].
25+
MOCK_DATA=false
26+
27+
# --- Integration tests (mvn test -Dgroups=integration -DexcludedGroups=) ---
28+
# Seed admin of the local dev backend; used by *IntegrationTest (skipped if unset).
29+
# Never commit real values here.
30+
IT_ADMIN_EMAIL=admin@example.com
31+
IT_ADMIN_PASSWORD=changeme

‎.vscode/extensions.json‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"recommendations": ["vscjava.vscode-lombok"]
3+
}

‎README.md‎

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ git pull --rebase
66

77
## Запуск
88

9-
Требования: JDK 25, Maven, запущенный бэкенд (иначе вход покажет ошибку соединения).
9+
Требования: JDK 25, Maven.
10+
11+
### С реальным бэкендом
1012

1113
```bash
1214
# 1. Инфраструктура бэкенда (из корня этого репозитория):
@@ -20,9 +22,32 @@ docker compose up -d postgres redis
2022
mvn javafx:run
2123
```
2224

23-
Откроется окно 800×600 со страницы входа (`LoginForm.fxml` — она же `INITIAL_PAGE_PATH`
24-
в `Program.java`). Для проверки вёрстки отдельной страницы можно временно подменить
25-
`INITIAL_PAGE_PATH` на нужный FXML.
25+
### Без бэкенда (мок-данные для проверки UI)
26+
27+
```bash
28+
MOCK_DATA=true mvn javafx:run
29+
```
30+
31+
Приложение стартует на in-memory данных (`services/mock`, окно помечено `[MOCK]`):
32+
вход — любой непустой email/пароль, роль по префиксу email (`admin*` → ADMIN,
33+
`moder*` → MODER, иначе USER). Засижены 3 пользователя и 2 ресурса с файлами,
34+
скачивание пишет реальный файл-плейсхолдер.
35+
36+
### Переменные окружения (.env)
37+
38+
| Переменная | Дефолт | Назначение |
39+
| ---------- | ------ | ---------- |
40+
| `INITIAL_PAGE_PATH` | `/fxml/LoginForm.fxml` | Стартовая страница (удобно открывать страницу напрямую для проверки вёрстки) |
41+
| `MOCK_DATA` | `false` | `true` — работать на моках без бэкенда |
42+
| `IT_ADMIN_EMAIL` / `IT_ADMIN_PASSWORD` | — | Сид-админ локального бэкенда для интеграционных тестов (тесты сами читают `.env`; без пароля скипаются) |
43+
| `POSTGRES_*`, `REDIS_PASSWORD`, `APP_PORT` | см. `.env.example` | Инфраструктура и бэкенд |
44+
| `MINIO_*` | см. `.env.example` | Только с `docker compose --profile files up -d` |
45+
46+
Свои значения — в `.env` (скопировать из `.env.example`): приложение читает
47+
переменные из окружения (`set -a && source .env && set +a` перед запуском),
48+
интеграционные тесты подхватывают `.env` сами.
49+
50+
Откроется окно 800×600 со стартовой страницы (по умолчанию вход, `LoginForm.fxml`).
2651

2752
## Git hooks
2853

‎docs/страницы.md‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ UI не ходит в HTTP напрямую. Контроллер вызывае
1919
| --- | -------------------------------------------- | ------------------------------------ | ----------------- | --------------------------------------------------------------------------------------------------------------- | ------------- |
2020
| 1 | Вход (email + пароль) | `/fxml/LoginForm.fxml` | гость (публичная) | Аутентификация через `AuthService`, после успеха → №2 | @vladcheck |
2121
| 2 | Список ресурсов и сортировка (главная) | `/fxml/ResourceList.fxml` | `USER` | Поиск/фильтр/сортировка, пагинация `offset/count` через `ResourceService.search` | @miyagi18 |
22-
| 3 | Просмотр метаданных документа | `/fxml/ResourceDetails.fxml` (новая) | `USER` | Только чтение: `ResourceService.getById` → `title, description, createdAt, updatedAt, uuid` | @vladcheck |
22+
| 3 | Просмотр метаданных документа | `/fxml/ResourceDetails.fxml` | `USER` | Только чтение: `ResourceService.getById` → `title, description, createdAt, updatedAt, uuid` | @vladcheck |
2323
| 4 | Форма ресурса (создание/редактирование) | `/fxml/ResourceForm.fxml` (новая) | `MODER` | `create/update` через `ResourceService`; удаление — действием из №2/№3 с подтверждением, без отдельной страницы | @miyagi18 |
2424
| 5 | Список пользователей | `/fxml/UserList.fxml` (новая) | `ADMIN` | Поиск/фильтр через `UsersService.search` + `UserFilter`; удаление действием из списка | @deatheh |
2525
| 6 | Форма пользователя (создание/редактирование) | `/fxml/UserForm.fxml` (новая) | `ADMIN` | `create` через `RegisterData`, `update` через `UpdateUserData`, `getById` для предзаполнения | @deatheh |
@@ -58,6 +58,11 @@ UI не ходит в HTTP напрямую. Контроллер вызывае
5858
* №1: ✅ готово — `LoginFormController` только email + пароль, вызов `AuthService`.
5959
* №2: ✅ переименование `Search.fxml → ResourceList.fxml` готово; наполнить список:
6060
`ResourceService.search` + пагинация, фильтры `ResourceFilter(title, description)`.
61+
* №3: ✅ готово — `ResourceDetailsController` (read-only): метаданные + таблица файлов
62+
со скачиванием (`FileService.downloadFile`), вход из №2 — `Navigator.goTo(path, c -> ...)`
63+
с `setResourceId(uuid)` + `load()`.
64+
* Роли: `AuthServiceImpl.hasRole` сравнивает по уровню (`USER=0 < MODER=1 < ADMIN=2`),
65+
поэтому `isModerator()` возвращает true и для `ADMIN` — соответствует RBAC выше.
6166
* SoC: списки (№2, №5) не содержат логики форм; формы (№4, №6) работают в двух режимах `create/edit` по наличию `uuid`, валидация через существующие `validation/*Validator`.
6267
* Кнопки create/edit/delete рендерятся по роли из `Session.getCurrentUser().role`, а не отдельными страницами.
6368

‎pom.xml‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,12 @@
7373
<version>2.2</version>
7474
<scope>test</scope>
7575
</dependency>
76+
<dependency>
77+
<groupId>io.github.cdimascio</groupId>
78+
<artifactId>dotenv-java</artifactId>
79+
<version>3.2.0</version>
80+
<scope>test</scope>
81+
</dependency>
7682
<dependency>
7783
<groupId>org.openjfx</groupId>
7884
<artifactId>javafx-controls</artifactId>
@@ -146,6 +152,11 @@
146152
Run them explicitly:
147153
mvn test -Dgroups=integration -DexcludedGroups= -->
148154
<excludedGroups>${excludedGroups}</excludedGroups>
155+
<!-- Path to the local .env file for integration tests (java-dotenv).
156+
Missing file is fine: tests skip via assumeTrue. -->
157+
<systemPropertyVariables>
158+
<dotenv.path>${project.basedir}/.env</dotenv.path>
159+
</systemPropertyVariables>
149160
</configuration>
150161
</plugin>
151162
<plugin>

‎src/main/java/com/DesktopApplicationClientJava/Api/ApiClient.java‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import com.DesktopApplicationClientJava.Api.Dto.response.ResourceDto;
1515
import com.DesktopApplicationClientJava.Api.Dto.response.UserDto;
1616
import com.DesktopApplicationClientJava.session.Session;
17+
import com.fasterxml.jackson.databind.DeserializationFeature;
1718
import com.fasterxml.jackson.databind.ObjectMapper;
1819
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
1920
import java.io.ByteArrayOutputStream;
@@ -49,6 +50,9 @@ public ApiClient(Session session) {
4950
.build();
5051
this.objectMapper = new ObjectMapper();
5152
this.objectMapper.registerModule(new JavaTimeModule());
53+
// FIXME: temporary — backend returns firstname/lastname instead of firstName/lastName.
54+
// Remove once backend uses camelCase; then unknown properties should fail fast again.
55+
this.objectMapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
5256
}
5357

5458
// ======================== AUTH ========================

‎src/main/java/com/DesktopApplicationClientJava/Api/ApiException.java‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package com.DesktopApplicationClientJava.Api;
22

33
import com.DesktopApplicationClientJava.Api.Dto.error.ErrorResponse;
4+
import lombok.Getter;
45

6+
@Getter
57
public class ApiException extends RuntimeException {
68
private final ErrorResponse errorResponse;
79

@@ -10,10 +12,6 @@ public ApiException(ErrorResponse errorResponse) {
1012
this.errorResponse = errorResponse;
1113
}
1214

13-
public ErrorResponse getErrorResponse() {
14-
return errorResponse;
15-
}
16-
1715
public int getStatus() {
1816
return errorResponse.status();
1917
}

‎src/main/java/com/DesktopApplicationClientJava/Program.java‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,16 @@
1414
import org.kordamp.bootstrapfx.BootstrapFX;
1515

1616
public class Program extends Application {
17-
private static final String INITIAL_PAGE_PATH = "/fxml/LoginForm.fxml";
17+
private static final String INITIAL_PAGE_PATH =
18+
System.getenv().getOrDefault("INITIAL_PAGE_PATH", "/fxml/LoginForm.fxml");
19+
private static final boolean MOCK_DATA =
20+
Boolean.parseBoolean(System.getenv().getOrDefault("MOCK_DATA", "false"));
1821
private static final double WINDOW_WIDTH = 800.0;
1922
private static final double WINDOW_HEIGHT = 600.0;
2023

2124
private final Session session = new Session();
2225
private final ApiClient apiClient = new ApiClient(session);
23-
private final ServiceRegistry services = new ServiceRegistry(apiClient, session);
26+
private final ServiceRegistry services = createServices();
2427

2528
public static void main(String[] args) {
2629
launch(args);
@@ -48,13 +51,20 @@ public void start(Stage stage) throws IOException {
4851
loadStyleSheet(scene, "/styles.css");
4952

5053
stage.setScene(scene);
51-
stage.setTitle("JavaFX Application");
54+
stage.setTitle("JavaFX Application" + (MOCK_DATA ? " [MOCK]" : ""));
5255
stage.setWidth(WINDOW_WIDTH);
5356
stage.setHeight(WINDOW_HEIGHT);
5457
stage.centerOnScreen();
5558
stage.show();
5659
}
5760

61+
private ServiceRegistry createServices() {
62+
if (MOCK_DATA) {
63+
return ServiceRegistry.mock(session);
64+
}
65+
return new ServiceRegistry(apiClient, session);
66+
}
67+
5868
private void loadStyleSheet(Scene scene, String path) {
5969
var url = getClass().getResource(path);
6070
if (url != null) {
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
package com.DesktopApplicationClientJava.controllers;
2+
3+
import com.DesktopApplicationClientJava.controllers.utils.Controller;
4+
import com.DesktopApplicationClientJava.entities.FileInfo;
5+
import com.DesktopApplicationClientJava.entities.Resource;
6+
import com.DesktopApplicationClientJava.services.ServiceException;
7+
import com.DesktopApplicationClientJava.services.filter.ResourceFilter;
8+
import com.DesktopApplicationClientJava.services.mock.MockService;
9+
import java.io.File;
10+
import java.nio.file.Path;
11+
import java.time.LocalDateTime;
12+
import java.time.format.DateTimeFormatter;
13+
import java.util.List;
14+
import java.util.UUID;
15+
import java.util.function.Consumer;
16+
import javafx.event.ActionEvent;
17+
import javafx.fxml.FXML;
18+
import javafx.scene.control.Button;
19+
import javafx.scene.control.Label;
20+
import javafx.scene.control.TableCell;
21+
import javafx.scene.control.TableColumn;
22+
import javafx.scene.control.TableView;
23+
import javafx.scene.control.cell.PropertyValueFactory;
24+
import javafx.stage.DirectoryChooser;
25+
import javafx.stage.Window;
26+
import lombok.Setter;
27+
28+
public class ResourceDetailsController extends Controller {
29+
30+
static final DateTimeFormatter DATE_TIME_FORMAT = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm");
31+
32+
@FXML private Label titleLabel;
33+
@FXML private Label descriptionLabel;
34+
@FXML private Label createdLabel;
35+
@FXML private Label updatedLabel;
36+
@FXML private Label uuidLabel;
37+
@FXML private Label errorLabel;
38+
@FXML private Label statusLabel;
39+
@FXML private TableView<FileInfo> filesTable;
40+
@FXML private TableColumn<FileInfo, String> nameColumn;
41+
@FXML private TableColumn<FileInfo, Long> sizeColumn;
42+
@FXML private TableColumn<FileInfo, String> contentTypeColumn;
43+
@FXML private TableColumn<FileInfo, Void> downloadColumn;
44+
@FXML private Button backButton;
45+
46+
@Setter private UUID resourceId;
47+
48+
@FXML
49+
private void initialize() {
50+
hideError();
51+
nameColumn.setCellValueFactory(new PropertyValueFactory<>("name"));
52+
sizeColumn.setCellValueFactory(new PropertyValueFactory<>("size"));
53+
contentTypeColumn.setCellValueFactory(new PropertyValueFactory<>("contentType"));
54+
downloadColumn.setCellFactory(col -> new DownloadCell(this::onDownload));
55+
autoSelectFirstInMockMode();
56+
}
57+
58+
public void load() {
59+
hideError();
60+
try {
61+
showResource(services.getResourceService().getById(resourceId));
62+
} catch (ServiceException e) {
63+
showError(e.getMessage());
64+
}
65+
}
66+
67+
@FXML
68+
private void onBack(ActionEvent event) {
69+
navigator.goTo("/fxml/ResourceList.fxml");
70+
}
71+
72+
private void onDownload(FileInfo file) {
73+
File dir = chooseDirectory(backButton.getScene().getWindow());
74+
if (dir == null) {
75+
return;
76+
}
77+
try {
78+
Path saved = services.getFileService().downloadFile(resourceId, file.getUuid(), dir.toPath());
79+
statusLabel.setText("Сохранено: " + saved.getFileName());
80+
} catch (ServiceException e) {
81+
showError(e.getMessage());
82+
}
83+
}
84+
85+
protected File chooseDirectory(Window owner) {
86+
return new DirectoryChooser().showDialog(owner);
87+
}
88+
89+
/**
90+
* Dev-only fallback: when the page is opened without an id (e.g. straight via INITIAL_PAGE_PATH)
91+
* against a mock service, show the first seeded resource so the UI is clickable without the
92+
* resource list. Never triggers against the real backend.
93+
*/
94+
private void autoSelectFirstInMockMode() {
95+
if (resourceId != null || !(services.getResourceService() instanceof MockService mock)) {
96+
return;
97+
}
98+
mock.doIfInitialPage();
99+
services.getResourceService().search(ResourceFilter.empty(), 0, 1).stream()
100+
.map(Resource::getUuid)
101+
.findFirst()
102+
.ifPresent(
103+
id -> {
104+
setResourceId(id);
105+
load();
106+
});
107+
}
108+
109+
private void showResource(Resource resource) {
110+
titleLabel.setText(resource.getTitle());
111+
descriptionLabel.setText(resource.getDescription());
112+
createdLabel.setText(formatDateTime(resource.getCreatedAt()));
113+
updatedLabel.setText(formatDateTime(resource.getUpdatedAt()));
114+
uuidLabel.setText(resource.getUuid() == null ? "" : resource.getUuid().toString());
115+
List<FileInfo> files = resource.getFiles() == null ? List.of() : resource.getFiles();
116+
filesTable.getItems().setAll(files);
117+
}
118+
119+
private static String formatDateTime(LocalDateTime value) {
120+
return value == null ? "" : DATE_TIME_FORMAT.format(value);
121+
}
122+
123+
private void showError(String message) {
124+
errorLabel.setText(message);
125+
errorLabel.setVisible(true);
126+
errorLabel.setManaged(true);
127+
}
128+
129+
private void hideError() {
130+
errorLabel.setText("");
131+
errorLabel.setVisible(false);
132+
errorLabel.setManaged(false);
133+
}
134+
135+
private static final class DownloadCell extends TableCell<FileInfo, Void> {
136+
private final Button button = new Button("Скачать");
137+
138+
DownloadCell(Consumer<FileInfo> onDownload) {
139+
button.getStyleClass().setAll("btn", "btn-default");
140+
button.setOnAction(event -> onDownload.accept(getTableView().getItems().get(getIndex())));
141+
}
142+
143+
@Override
144+
protected void updateItem(Void item, boolean empty) {
145+
super.updateItem(item, empty);
146+
setGraphic(empty ? null : button);
147+
}
148+
}
149+
}

‎src/main/java/com/DesktopApplicationClientJava/controllers/utils/Controller.java‎

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,25 +6,12 @@
66
import com.DesktopApplicationClientJava.services.ServicesAware;
77
import com.DesktopApplicationClientJava.session.Session;
88
import com.DesktopApplicationClientJava.session.SessionAware;
9+
import lombok.Setter;
910

11+
@Setter
1012
public abstract class Controller implements SessionAware, NavigatorAware, ServicesAware {
1113

1214
protected Session session;
1315
protected Navigator navigator;
1416
protected ServiceRegistry services;
15-
16-
@Override
17-
public void setSession(Session session) {
18-
this.session = session;
19-
}
20-
21-
@Override
22-
public void setNavigator(Navigator navigator) {
23-
this.navigator = navigator;
24-
}
25-
26-
@Override
27-
public void setServices(ServiceRegistry services) {
28-
this.services = services;
29-
}
3017
}

0 commit comments

Comments
 (0)