導入
一覧のどのタスクにも「完了」ボタンが押せてしまうのはおかしな話です。すでに完了しているタスクや、存在しないタスクに対しては、ボタンが自動的にグレーアウトしてほしいところです。
このコースでの簡略化: 実務では行ごとに「完了」「削除」のボタンを置きますが、このコースの共通のAPI契約には
complete(id)しかなく、削除(remove)は含まれていません(学習の範囲を絞るための意図的な簡略化です)。ここでは「完了」のcanExecuteを、一覧の状態(対象のタスクが存在し、まだ完了していないか)から組み立てる練習をします。実務でDelete機能を足すときも、考え方(一覧の状態からCanExecuteを計算する)はまったく同じです。
図解
flowchart TB
T["viewModel.getTasks()"] -->|"対象idが存在し、未完了か"| CE{"canExecute"}
CE -->|"true"| ON["ボタンが押せる"]
CE -->|"false"| OFF["ボタンがグレーアウト"]
説明
実務のJavaFXでは、一覧の各行に完了ボタンを置くとき、ListViewのsetCellFactoryの中でその行専用のCommandを組み立てます。
taskListView.setCellFactory(list -> new ListCell<>() {
private final Button completeButton = new Button("完了");
{
completeButton.setOnAction(e -> {
TaskDto task = getItem();
if (task != null) viewModel.complete(task.id());
});
}
@Override
protected void updateItem(TaskDto task, boolean empty) {
super.updateItem(task, empty);
if (empty || task == null) {
setGraphic(null);
} else {
completeButton.setDisable(task.completed());
setGraphic(completeButton);
}
}
});
このレッスンでは、上のコードが呼び出すことになる「対象のタスクに対する完了操作」を、TaskListViewModelとは別の小さなクラスTaskRowActionsとして切り出し、canExecuteの組み立て方をテストで固めます。
やってみよう
下のエディタのTaskRowActions.javaには、まだhasPendingTaskの中身がありません。まず「▶ 実行」を押して、どのテストが失敗するか確認してください。
演習
TaskRowActions.hasPendingTaskを実装してください。指定したidのタスクが一覧の中に存在し、かつまだ完了していない場合だけtrueを返します。Stream APIは使えないので拡張forで探します。
public interface IdGenerator { String next(); }
public interface Clock { long today(); }
public class TaskTitle {
private final String value;
public TaskTitle(String value) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("タイトルは空にできません");
}
if (value.length() > 50) {
throw new IllegalArgumentException("タイトルは50文字以内です");
}
this.value = value;
}
public String getValue() { return value; }
@Override
public String toString() { return value; }
}
public enum Priority {
LOW("低"), MEDIUM("中"), HIGH("高");
private final String label;
Priority(String label) { this.label = label; }
public String getLabel() { return label; }
}
public class TaskItem {
private final String id;
private TaskTitle title;
private final Priority priority;
private boolean completed;
public TaskItem(String id, TaskTitle title, Priority priority) {
this.id = id;
this.title = title;
this.priority = priority;
}
public String getId() { return id; }
public TaskTitle getTitle() { return title; }
public Priority getPriority() { return priority; }
public boolean isCompleted() { return completed; }
public void complete() { completed = true; }
public void rename(TaskTitle newTitle) { title = newTitle; }
}
public class TaskFactory {
private final IdGenerator idGenerator;
public TaskFactory(IdGenerator idGenerator) {
this.idGenerator = idGenerator;
}
public TaskItem create(String title, Priority priority) {
return new TaskItem(idGenerator.next(), new TaskTitle(title), priority);
}
public TaskItem create(String title) {
return create(title, Priority.MEDIUM);
}
}
import java.util.List;
import java.util.ArrayList;
import java.util.Optional;
public interface TaskRepository {
void add(TaskItem task);
List<TaskItem> findAll();
Optional<TaskItem> findById(String id);
}
public class InMemoryTaskRepository implements TaskRepository {
private final List<TaskItem> tasks = new ArrayList<>();
public void add(TaskItem task) {
tasks.add(task);
}
public List<TaskItem> findAll() {
return tasks;
}
public Optional<TaskItem> findById(String id) {
for (TaskItem task : tasks) {
if (task.getId().equals(id)) return Optional.of(task);
}
return Optional.empty();
}
}
import java.util.List;
import java.util.ArrayList;
public record TaskDto(String id, String title, String priorityLabel, boolean completed) { }
public class TaskMapper {
public static TaskDto toDto(TaskItem task) {
return new TaskDto(task.getId(), task.getTitle().getValue(), task.getPriority().getLabel(), task.isCompleted());
}
}
public class TaskService {
private final TaskRepository repository;
private final TaskFactory factory;
public TaskService(TaskRepository repository, TaskFactory factory) {
this.repository = repository;
this.factory = factory;
}
public void add(String title, Priority priority) {
TaskItem task = factory.create(title, priority);
repository.add(task);
}
public void complete(String id) {
repository.findById(id).ifPresent(task -> task.complete());
}
public List<TaskDto> list() {
List<TaskDto> result = new ArrayList<>();
for (TaskItem task : repository.findAll()) {
result.add(TaskMapper.toDto(task));
}
return result;
}
public int remainingCount() {
int count = 0;
for (TaskItem task : repository.findAll()) {
if (!task.isCompleted()) count = count + 1;
}
return count;
}
}
import java.util.List;
import java.util.ArrayList;
public interface PropertyChangedListener {
void onPropertyChanged(String propertyName);
}
public abstract class ViewModelBase {
private final List<PropertyChangedListener> listeners = new ArrayList<>();
public void addPropertyChangedListener(PropertyChangedListener listener) {
listeners.add(listener);
}
protected void raisePropertyChanged(String propertyName) {
for (PropertyChangedListener listener : listeners) {
listener.onPropertyChanged(propertyName);
}
}
}
import java.util.function.BooleanSupplier;
public interface Command {
void execute();
default boolean canExecute() { return true; }
}
public class RelayCommand implements Command {
private final Runnable action;
private final BooleanSupplier canExecute;
public RelayCommand(Runnable action) {
this(action, () -> true);
}
public RelayCommand(Runnable action, BooleanSupplier canExecute) {
this.action = action;
this.canExecute = canExecute;
}
public void execute() {
action.run();
}
public boolean canExecute() {
return canExecute.getAsBoolean();
}
}
import java.util.List;
public class TaskListViewModel extends ViewModelBase {
private final TaskService service;
private String newTitle = "";
private final Command addCommand = new RelayCommand(() -> add(), () -> canAdd());
public TaskListViewModel(TaskService service) {
this.service = service;
}
public String getNewTitle() { return newTitle; }
public void setNewTitle(String value) {
newTitle = value;
raisePropertyChanged("newTitle");
raisePropertyChanged("canAdd");
}
public List<TaskDto> getTasks() {
return service.list();
}
public int getRemainingCount() {
return service.remainingCount();
}
public Command getAddCommand() {
return addCommand;
}
public void complete(String id) {
service.complete(id);
raisePropertyChanged("tasks");
raisePropertyChanged("remainingCount");
}
private boolean canAdd() {
return newTitle != null && !newTitle.isBlank();
}
private void add() {
service.add(newTitle, Priority.MEDIUM);
setNewTitle("");
raisePropertyChanged("tasks");
raisePropertyChanged("remainingCount");
}
}
public class TaskRowActions {
// TODO: 指定した id のタスクが一覧の中に存在し、かつまだ完了していない場合だけ true を返す
// hasPendingTask を実装してください(Stream APIは使えないので拡張forで探します)
public static boolean hasPendingTask(TaskListViewModel viewModel, String taskId) {
}
public static Command completeCommandFor(TaskListViewModel viewModel, String taskId) {
return new RelayCommand(
() -> viewModel.complete(taskId),
() -> hasPendingTask(viewModel, taskId)
);
}
}
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
public class TaskRowActionsTest {
private TaskListViewModel viewModel;
@BeforeEach
void setUp() {
IdGenerator idGenerator = () -> "id-1";
TaskRepository repository = new InMemoryTaskRepository();
TaskFactory factory = new TaskFactory(idGenerator);
TaskService service = new TaskService(repository, factory);
viewModel = new TaskListViewModel(service);
viewModel.setNewTitle("牛乳を買う");
viewModel.getAddCommand().execute();
}
@Test
void mikanryoTaskHaCanExecuteGaShinDearu() {
String id = viewModel.getTasks().get(0).id();
Command completeCommand = TaskRowActions.completeCommandFor(viewModel, id);
assertTrue(completeCommand.canExecute(), "完了していないタスクは実行できる");
}
@Test
void sonzaiShinaiIdHaCanExecuteGaGiDearu() {
Command completeCommand = TaskRowActions.completeCommandFor(viewModel, "not-exist");
assertFalse(completeCommand.canExecute(), "一覧に無いidは実行できない");
}
@Test
void jikkoSuruToKanryoSareCanExecuteGaGiNiKawaru() {
String id = viewModel.getTasks().get(0).id();
Command completeCommand = TaskRowActions.completeCommandFor(viewModel, id);
completeCommand.execute();
assertFalse(completeCommand.canExecute(), "完了した後は同じボタンが押せなくなる");
}
}
- 期待される結果: 3件のテストがすべて成功
ヒント1を見る
for (TaskDto task : viewModel.getTasks()) { if (task.id().equals(taskId) && !task.completed()) return true; } return false;
ヒント2を見る
TaskDtoはrecordなので、task.id()・task.completed()のようにアクセサで値を取り出せます
まとめ
- 行ごとのボタンの
canExecuteは、一覧の現在の状態(viewModel.getTasks())を見て毎回計算する - 対象が存在しない、あるいは既に完了している場合は
falseを返すことで、Viewはボタンを自動的にグレーアウトできる - このコースの契約に無い機能(削除)が欲しくなったときは、第5章の
TaskServiceに対応するメソッドを足すところから始める
次回: getRemainingCount()のような計算プロパティを画面に表示します。