From 1f7505f949293bc7c755ac2b7ae04d002a29c68e Mon Sep 17 00:00:00 2001 From: MomdAli Date: Fri, 12 Sep 2025 16:51:42 +0200 Subject: [PATCH 1/5] PTBAS-741: MappingDialog tooltips, search, and mapping deletion options * Added tooltip * Enabled search in comboboxes * Expired token now shown in red * Fixed SyncDialog auto-close * Made auto-deletion of obsolete mappings optional (Keep/Remove) --- .../keeptime/controller/HeimatController.java | 239 ++++++++++++------ .../rest/integration/heimat/JwtDecoder.java | 6 +- .../view/ExternalProjectsMapController.java | 155 ++++++++---- .../view/ExternalProjectsSyncController.java | 21 +- .../keeptime/view/SettingsController.java | 20 +- .../keeptime/viewpopup/SearchPopup.java | 79 +++--- .../layouts/externalProjectSync.fxml | 14 +- src/main/resources/layouts/settings.fxml | 34 +-- .../controller/HeimatControllerTest.java | 39 ++- 9 files changed, 396 insertions(+), 211 deletions(-) diff --git a/src/main/java/de/doubleslash/keeptime/controller/HeimatController.java b/src/main/java/de/doubleslash/keeptime/controller/HeimatController.java index 8af29050..1d3c53b6 100644 --- a/src/main/java/de/doubleslash/keeptime/controller/HeimatController.java +++ b/src/main/java/de/doubleslash/keeptime/controller/HeimatController.java @@ -162,31 +162,76 @@ public List getTableRows(final LocalDate currentReportDate, final List< new Text("\n(" + externalProjectMapping.getExternalProjectName() + ")")); } - final String bookingHint = heimatTasks.stream() - .filter(ht -> ht.id() == optHeimatMapping.get().getExternalTaskId()) - .map(HeimatTask::bookingHint) - .findAny() - .orElseGet(String::new); + final String bookingHint = optHeimatMapping.map(externalProjectMapping -> heimatTasks.stream() + .filter(ht -> ht.id() + == externalProjectMapping.getExternalTaskId()) + .map(HeimatTask::bookingHint) + .findAny() + .orElseGet(String::new)).orElse(""); if (optionalExistingMapping.isPresent()) { final Mapping existingMapping = optionalExistingMapping.get(); + + // Ensure we merge projects robustly: include any projects mapped to the same Heimat task final ArrayList projects = new ArrayList<>(existingMapping.projects()); - projects.add(project); + if (optHeimatMapping.isPresent()) { + final long mappedTaskId = optHeimatMapping.get().getExternalTaskId(); + final List allMappedForTask = mappedProjects.stream() + .filter(mp -> mp.getExternalTaskId() == mappedTaskId) + .map(ExternalProjectMapping::getProject) + .toList(); + for (Project p : allMappedForTask) { + boolean alreadyContains = projects.stream().anyMatch(pp -> pp.getId() == p.getId()); + if (!alreadyContains) { + projects.add(p); + } + } + } else { + // fallback: ensure current project is present + boolean alreadyContains = projects.stream().anyMatch(p -> p.getId() == project.getId()); + if (!alreadyContains) { + projects.add(project); + } + } + final long keepTimeSeconds = existingMapping.keeptimeSeconds() + projectWorkSeconds; final long heimatSeconds = existingMapping.heimatSeconds(); final boolean shouldBeSynced = isMappedInHeimat && differenceGreaterOrEqual15Minutes(heimatSeconds, keepTimeSeconds); + + final String mergedKeeptimeNotes; + if (existingMapping.keeptimeNotes() == null || existingMapping.keeptimeNotes().isEmpty()) { + mergedKeeptimeNotes = keeptimeNotes; + } else if (keeptimeNotes == null || keeptimeNotes.isEmpty()) { + mergedKeeptimeNotes = existingMapping.keeptimeNotes(); + } else { + mergedKeeptimeNotes = existingMapping.keeptimeNotes() + ". " + keeptimeNotes; + } + final Mapping mapping = new Mapping(isMappedInHeimat ? optHeimatMapping.get().getExternalTaskId() : -1, isMappedInHeimat, shouldBeSynced, canBeSyncedMessage, bookingHint, existingMapping.existingTimes(), projects, - existingMapping.heimatNotes(), existingMapping.keeptimeNotes() + ". " + keeptimeNotes, heimatSeconds, + existingMapping.heimatNotes(), mergedKeeptimeNotes, heimatSeconds, keepTimeSeconds); list.remove(existingMapping); list.add(mapping); } else { + // FIX: when creating a new mapping row for a project that is mapped to a Heimat task, + // include ALL Projects that are mapped to the same Heimat task so that projects without + // KeepTime entries (no worked time) are also shown in the same row (case 4). + final List projects; + if (optHeimatMapping.isPresent()) { + final long mappedTaskId = optHeimatMapping.get().getExternalTaskId(); + projects = mappedProjects.stream() + .filter(mp -> mp.getExternalTaskId() == mappedTaskId) + .map(ExternalProjectMapping::getProject) + .collect(Collectors.toList()); + } else { + projects = Collections.singletonList(project); + } + final boolean shouldBeSynced = isMappedInHeimat && differenceGreaterOrEqual15Minutes(heimatTimeSeconds, projectWorkSeconds); - final List projects = Collections.singletonList(project); final Mapping mapping = new Mapping(isMappedInHeimat ? optHeimatMapping.get().getExternalTaskId() : -1, isMappedInHeimat, shouldBeSynced, canBeSyncedMessage, bookingHint, optionalAlreadyBookedTimes, projects, heimatNotes, keeptimeNotes, heimatTimeSeconds, projectWorkSeconds); @@ -216,37 +261,56 @@ public List getTableRows(final LocalDate currentReportDate, final List< }); taskIdToHeimatTimesMap.forEach((id, times) -> { - final Optional mapping = mappedProjects.stream() - .filter(mp -> mp.getExternalTaskId() == id) - .findAny(); - if (mapping.isEmpty()) - return; - final ExternalProjectMapping externalProjectMapping = mapping.get(); - final Optional optionalProject = workedProjectsSet.stream() - .filter(wp -> wp.getId() - == externalProjectMapping.getProject() - .getId()) - .findAny(); - if (optionalProject.isPresent()) { - return; + final List mappingsForTask = mappedProjects.stream() + .filter(mp -> mp.getExternalTaskId() == id) + .toList(); + for (ExternalProjectMapping externalProjectMapping : mappingsForTask) { + final Optional optionalProject = workedProjectsSet.stream() + .filter(wp -> wp.getId() == externalProjectMapping.getProject().getId()) + .findAny(); + if (optionalProject.isPresent()) { + continue; + } + + String heimatNotes = addHeimatNotes(times); + long heimatTimeSeconds = addHeimatTimes(times); + + final Optional existingMappingInList = list.stream() + .filter(m -> m.heimatTaskId == id) + .findAny(); + if (existingMappingInList.isPresent()) { + Mapping existing = existingMappingInList.get(); + // only add if not already present + boolean alreadyContains = existing.projects().stream() + .anyMatch(p -> p.getId() == externalProjectMapping.getProject().getId()); + if (!alreadyContains) { + ArrayList newProjects = new ArrayList<>(existing.projects()); + newProjects.add(externalProjectMapping.getProject()); + Mapping updated = new Mapping(existing.heimatTaskId, existing.canBeSynced, existing.shouldBeSynced, + existing.syncMessage, existing.bookingHint, existing.existingTimes, newProjects, + existing.heimatNotes, existing.keeptimeNotes, existing.heimatSeconds, existing.keeptimeSeconds); + list.remove(existing); + list.add(updated); + } + // skip creating a separate entry + continue; + } + + Text externalTaskName = new Text(externalProjectMapping.getExternalTaskName()); + externalTaskName.setStyle("-fx-font-weight: bold;"); + TextFlow syncMessage = new TextFlow(new Text("Present in HEIMAT but not KeepTime\n\nSync to "), externalTaskName, + new Text("\n(" + externalProjectMapping.getExternalProjectName() + ")")); + + List allMappedProjects = mappingsForTask.stream() + .map(ExternalProjectMapping::getProject) + .toList(); + + final Mapping mapping2 = new Mapping(id, true, false, + syncMessage, "", times, + allMappedProjects, + heimatNotes, "", heimatTimeSeconds, 0); + list.add(mapping2); } - String heimatNotes = addHeimatNotes(times); - long heimatTimeSeconds = addHeimatTimes(times); - - Text externalTaskName = new Text(externalProjectMapping.getExternalTaskName()); - externalTaskName.setStyle("-fx-font-weight: bold;"); - TextFlow syncMessage = new TextFlow(new Text("Present in HEIMAT but not KeepTime\n\nSync to "), externalTaskName, - new Text("\n(" + externalProjectMapping.getExternalProjectName() + ")")); - - final Mapping mapping2 = new Mapping(id, true, false, - syncMessage, "", times, mappedProjects.stream() - .filter( - mp -> mp.getExternalTaskId() - == id) - .map(ExternalProjectMapping::getProject) - .toList(), - heimatNotes, "", heimatTimeSeconds, 0); - list.add(mapping2); }); return list; @@ -389,52 +453,55 @@ private String getAsJson(final HeimatTask heimatTask) { } } + private HeimatTask getHeimatTaskFromMapping(ExternalProjectMapping mapping) { + try { + String json = mapping.getExternalTaskMetadata(); + if (json == null || json.isEmpty()) { + return null; + } + return objectMapper.readValue(json, HeimatTask.class); + } catch (Exception e) { + LOG.warn("Unable to deserialize HeimatTask from mapping metadata", e); + return null; + } + } + public ExistingAndInvalidMappings getExistingProjectMappings(List externalProjects) { final List alreadyMappedProjects = externalProjectsMappingsRepository.findByExternalSystemId( ExternalSystem.Heimat); - final List invalidExternalMappings = new ArrayList<>(); - final List validProjectMappings = model.getSortedAvailableProjects().stream().map(p -> { + List projectMappings = new ArrayList<>(); + List invalidMappingsAsString = new ArrayList<>(); + + for (Project p : model.getSortedAvailableProjects()) { final Optional mapping = alreadyMappedProjects.stream() - .filter(mp -> mp.getProject().getId() - == p.getId()) + .filter(mp -> mp.getProject().getId() == p.getId()) .findAny(); if (mapping.isEmpty()) { - return new ProjectMapping(p, null); + projectMappings.add(new ProjectMapping(p, null, false)); + continue; } + final ExternalProjectMapping mapped = mapping.get(); + + HeimatTask mappedTask = getHeimatTaskFromMapping(mapped); + final Optional any = externalProjects.stream() - .filter(ep -> ep.id() == mapping.get().getExternalTaskId()) + .filter(ep -> ep.id() == mapped.getExternalTaskId()) .findAny(); if (any.isEmpty()) { - LOG.warn("A mapping exists but task does not exist anymore in HEIMAT! '{}'->'{}'.", - mapping.get().getProject(), mapping.get().getExternalTaskId()); - invalidExternalMappings.add(mapping.get()); - return new ProjectMapping(p, null); + // Heimat task not available today, but keep the mapping! + projectMappings.add(new ProjectMapping(p, mappedTask, true)); + invalidMappingsAsString.add("[" + mapped.getExternalProjectName() + " - " + + mapped.getExternalTaskName() + + "] was mapped to [" + mapped.getProject().getName() + "]"); + } else { + projectMappings.add(new ProjectMapping(p, any.get(), false)); } - return new ProjectMapping(p, any.get()); - }).toList(); - - final List invalidMappingsAsString = invalidExternalMappings.stream() - .map(em -> "Task no longer exists: " - + em.getExternalProjectName() + " - " - + em.getExternalTaskName() - + "'. Was mapped to '" + em.getProject() - .getName() - + "'.") - .collect(Collectors.toCollection( - ArrayList::new)); - /* - // I do not have all external projects here :( only already filtered ones - allExternalProjects.stream() - .filter(HeimatTask::isStartAndEndTimeRequired) - .map(p -> "Task " + p.taskHolderName() + " - " + p.name() - + " requires start+end time which is not supported.") - .forEach(invalidMappingsAsString::add); - */ - - return new ExistingAndInvalidMappings(validProjectMappings, invalidMappingsAsString); + } + return new ExistingAndInvalidMappings(projectMappings, invalidMappingsAsString); } + public record UserMapping(Mapping mapping, boolean shouldSync, String userNotes, int userMinutes) {} public record Mapping(long heimatTaskId, boolean canBeSynced, boolean shouldBeSynced, TextFlow syncMessage, String bookingHint, @@ -446,16 +513,17 @@ public record HeimatErrors(UserMapping mapping, String errorMessage) {} public static class ProjectMapping { private Project project; private HeimatTask heimatTask; + private boolean pendingRemoval; - public ProjectMapping(final Project project, final HeimatTask heimatTask) { + public ProjectMapping(final Project project, final HeimatTask heimatTask, boolean pendingRemoval) { this.project = project; this.heimatTask = heimatTask; + this.pendingRemoval = pendingRemoval; } public Project getProject() { return project; } - public void setProject(final Project project) { this.project = project; } @@ -463,9 +531,34 @@ public void setProject(final Project project) { public HeimatTask getHeimatTask() { return heimatTask; } - public void setHeimatTask(final HeimatTask heimatTask) { this.heimatTask = heimatTask; } + + public boolean isPendingRemoval() { + return pendingRemoval; + } + public void setPendingRemoval(boolean pendingRemoval) { + this.pendingRemoval = pendingRemoval; + } + } + + public List getAllKnownHeimatTasks(final LocalDate forDate) { + List apiTasks = getTasks(forDate); + + List mappings = externalProjectsMappingsRepository.findByExternalSystemId(ExternalSystem.Heimat); + List mappedTasks = mappings.stream() + .map(this::getHeimatTaskFromMapping) + .filter(Objects::nonNull) + .toList(); + + Map taskMap = new LinkedHashMap<>(); + for (HeimatTask t : mappedTasks) { + taskMap.put(t.id(), t); + } + for (HeimatTask t : apiTasks) { + taskMap.put(t.id(), t); // API result should overwrite mapped if present + } + return new ArrayList<>(taskMap.values()); } } diff --git a/src/main/java/de/doubleslash/keeptime/rest/integration/heimat/JwtDecoder.java b/src/main/java/de/doubleslash/keeptime/rest/integration/heimat/JwtDecoder.java index c5a903d4..3f26f2d1 100644 --- a/src/main/java/de/doubleslash/keeptime/rest/integration/heimat/JwtDecoder.java +++ b/src/main/java/de/doubleslash/keeptime/rest/integration/heimat/JwtDecoder.java @@ -30,7 +30,7 @@ public record JWTTokenAttributes( String header, String payload, LocalDateTime expiration - ) {} + ) { } public static JWTTokenAttributes parse(String bearerToken) { @@ -57,6 +57,10 @@ public static JWTTokenAttributes parse(String bearerToken) { return new JWTTokenAttributes(header, payload, expiration); } + public static boolean isExpired(JWTTokenAttributes token, LocalDateTime localDateTimeNow) { + return token.expiration.isAfter(localDateTimeNow); + } + private static String removeBearerPrefix(String token) { return token.startsWith("Bearer ") ? token.substring(7) : token; } diff --git a/src/main/java/de/doubleslash/keeptime/view/ExternalProjectsMapController.java b/src/main/java/de/doubleslash/keeptime/view/ExternalProjectsMapController.java index 53a94f64..0ac02f0e 100644 --- a/src/main/java/de/doubleslash/keeptime/view/ExternalProjectsMapController.java +++ b/src/main/java/de/doubleslash/keeptime/view/ExternalProjectsMapController.java @@ -24,6 +24,7 @@ import de.doubleslash.keeptime.model.Project; import de.doubleslash.keeptime.rest.integration.heimat.model.ExistingAndInvalidMappings; import de.doubleslash.keeptime.rest.integration.heimat.model.HeimatTask; +import de.doubleslash.keeptime.viewpopup.SearchPopup; import javafx.application.Platform; import javafx.beans.property.SimpleBooleanProperty; import javafx.beans.property.SimpleObjectProperty; @@ -35,6 +36,8 @@ import javafx.fxml.FXML; import javafx.scene.control.*; import javafx.scene.control.cell.CheckBoxTableCell; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; import javafx.scene.layout.VBox; import javafx.stage.Stage; import org.slf4j.Logger; @@ -86,15 +89,29 @@ private void initialize() { tasksForDateDatePicker.setValue(LocalDate.now()); tasksForDateDatePicker.setDisable(true); // TODO add listener on this thing - // but what happens with mapped projects not existing at that date? but actually not related to this feature alone - final List externalProjects = heimatController.getTasks(tasksForDateDatePicker.getValue()); + final List externalProjects = heimatController.getAllKnownHeimatTasks(tasksForDateDatePicker.getValue()); + final ExistingAndInvalidMappings existingAndInvalidMappings = heimatController.getExistingProjectMappings( externalProjects); - final List previousProjectMappings = existingAndInvalidMappings.validMappings(); + + final List previousProjectMappings = existingAndInvalidMappings.validMappings(); final ObservableList newProjectMappings = FXCollections.observableArrayList( previousProjectMappings); + + Platform.runLater(() -> { + List warnings = existingAndInvalidMappings.invalidMappingsAsString(); + if (!warnings.isEmpty()) { + if (showInvalidMappingsDialog(warnings)) { + newProjectMappings.stream() + .filter(HeimatController.ProjectMapping::isPendingRemoval) + .forEach(pm -> pm.setHeimatTask(null)); + mappingTableView.refresh(); + } + } + }); + final FilteredList value = new FilteredList<>(newProjectMappings, pm -> pm.getProject().isWork()); mappingTableView.setItems(value); @@ -103,58 +120,56 @@ private void initialize() { TableColumn keepTimeColumn = new TableColumn<>("KeepTime project"); keepTimeColumn.setCellValueFactory(data -> new SimpleStringProperty(data.getValue().getProject().getName())); + keepTimeColumn.setCellFactory(col -> new TableCell<>() { + @Override + protected void updateItem(String item, boolean empty) { + super.updateItem(item, empty); + if (empty || item == null) { + setText(null); + setTooltip(null); + } else { + setText(item); + Tooltip tooltip = new Tooltip(item); + setTooltip(tooltip); + } + } + }); + // External Project column with dropdown final ObservableList externalProjectsObservableList = FXCollections.observableArrayList( externalProjects); - externalProjectsObservableList.add(0, null); // option to clear selection TableColumn externalColumn = new TableColumn<>("HEIMAT project"); externalColumn.setCellValueFactory(data -> new SimpleObjectProperty<>(data.getValue().getHeimatTask())); externalColumn.setCellFactory(col -> new TableCell<>() { - // TODO search in box would be nice - private final ComboBox comboBox = new ComboBox<>(externalProjectsObservableList); + private final SearchPopup searchPopup = new SearchPopup<>(externalProjectsObservableList); + + { + searchPopup.setDisplayTextFunction(ht -> ht == null ? "" : ht.taskHolderName() + " - " + ht.name()); + searchPopup.setClearFieldAfterSelection(false); + searchPopup.setPromptText("Search Project..."); + searchPopup.setOnItemSelected((selectedTask, popup) -> { + HeimatController.ProjectMapping mapping = getTableView().getItems().get(getIndex()); + mapping.setHeimatTask(selectedTask); + searchPopup.setComboBoxTooltip(selectedTask.name() + " - " + selectedTask.id()); + updateItem(selectedTask, false); + }); + } @Override protected void updateItem(HeimatTask item, boolean empty) { super.updateItem(item, empty); - // selected item - comboBox.setButtonCell(new ListCell<>() { - @Override - protected void updateItem(HeimatTask item, boolean empty) { - super.updateItem(item, empty); - if (empty || item == null) { - setText(null); - } else { - setText(item.taskHolderName() + " - " + item.name()); - } - } - }); - - // Dropdown - comboBox.setCellFactory(param -> new ListCell<>() { - @Override - protected void updateItem(HeimatTask item, boolean empty) { - super.updateItem(item, empty); - if (item == null || empty) { - setGraphic(null); - setText(null); - } else { - // TODO maybe show if the project was already mapped - setText(item.taskHolderName() + " - " + item.name()); - } - } - }); - if (empty) { setGraphic(null); setText(null); } else { - comboBox.setValue(getTableView().getItems().get(getIndex()).getHeimatTask()); - comboBox.setOnAction(e -> { - HeimatController.ProjectMapping mapping = getTableView().getItems().get(getIndex()); - mapping.setHeimatTask(comboBox.getValue()); - }); - setGraphic(comboBox); + searchPopup.setSelectedItem(item); + if (item != null) { + searchPopup.setComboBoxTooltip(item.name() + " - " + item.id()); + } else { + searchPopup.setComboBoxTooltip(""); + } + setGraphic(searchPopup.getComboBox()); setText(null); } } @@ -179,7 +194,7 @@ protected void updateItem(HeimatTask item, boolean empty) { final Project project = controller.addNewProject( new Project(toBeCreatedHeimatTask.name() + " - " + toBeCreatedHeimatTask.taskHolderName(), toBeCreatedHeimatTask.bookingHint(), ColorHelper.randomColor(), true, sortIndex)); - newProjectMappings.add(new HeimatController.ProjectMapping(project, toBeCreatedHeimatTask)); + newProjectMappings.add(new HeimatController.ProjectMapping(project, toBeCreatedHeimatTask, false)); } }); @@ -189,11 +204,6 @@ protected void updateItem(HeimatTask item, boolean empty) { }); cancelButton.setOnAction(ae -> thisStage.close()); - - List warnings = existingAndInvalidMappings.invalidMappingsAsString(); - if (!warnings.isEmpty()) { - Platform.runLater(() -> showInvalidMappingsDialog(warnings)); - } } private List showMultiSelectDialog(final List externalProjects, @@ -210,6 +220,11 @@ private List showMultiSelectDialog(final List externalPr ButtonType cancelButtonType = new ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE); dialog.getDialogPane().getButtonTypes().addAll(okButtonType, cancelButtonType); + // Observable and filtered list + ObservableList baseList = FXCollections.observableArrayList(externalProjects); + FilteredList filteredList = new FilteredList<>(baseList, t -> true); + + // Name Column TableView tableView = new TableView<>(); TableColumn nameColumn = new TableColumn<>("HEIMAT project"); nameColumn.setCellValueFactory(data -> new SimpleObjectProperty<>(data.getValue())); @@ -238,9 +253,10 @@ protected void updateItem(HeimatTask item, boolean empty) { tableView.setEditable(false); tableView.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE); - tableView.setItems(FXCollections.observableArrayList(externalProjects)); + tableView.setItems(filteredList); - Button selectAllUnmappedButton = new Button("Select unmapped projects (" + unmappedHeimatTasks.size() + ")"); + Button selectAllUnmappedButton = new Button("Select unmapped projects (" + + unmappedHeimatTasks.size() + ")"); selectAllUnmappedButton.getStyleClass().add("secondary-button"); selectAllUnmappedButton.setOnAction(e -> { tableView.getSelectionModel().clearSelection(); @@ -250,7 +266,27 @@ protected void updateItem(HeimatTask item, boolean empty) { tableView.requestFocus(); }); - VBox content = new VBox(10, selectAllUnmappedButton, tableView); + TextField searchField = new TextField(); + searchField.setPromptText("Search..."); + searchField.textProperty().addListener((obs, oldText, newText) -> { + String filter = newText == null ? "" : newText.trim().toLowerCase(); + filteredList.setPredicate(task -> { + if (filter.isEmpty()) return true; + return task.taskHolderName().toLowerCase().contains(filter) + || task.name().toLowerCase().contains(filter); + }); + + long visibleUnmapped = filteredList.stream().filter(unmappedHeimatTasks::contains).count(); + selectAllUnmappedButton.setText("Select unmapped projects (" + + visibleUnmapped + ")"); + }); + searchField.getStyleClass().add("text-field"); + searchField.setMaxWidth(Double.MAX_VALUE); + HBox.setHgrow(searchField, Priority.ALWAYS); + + HBox headContent = new HBox(50, selectAllUnmappedButton, searchField); + + VBox content = new VBox(10, headContent, tableView); dialog.getDialogPane().setContent(content); final List emptyList = List.of(); dialog.setResultConverter(dialogButton -> { @@ -279,11 +315,17 @@ protected void updateItem(HeimatTask item, boolean empty) { return result.orElse(emptyList); } - private void showInvalidMappingsDialog(final List warnings) { - Dialog dialog = new Dialog<>(); + private boolean showInvalidMappingsDialog(final List warnings) { + Dialog dialog = new Dialog<>(); + dialog.initOwner(this.thisStage); + + Stage dialogStage = (Stage) dialog.getDialogPane().getScene().getWindow(); + dialogStage.getIcons().addAll(this.thisStage.getIcons()); + dialog.setTitle("Invalid mappings"); - dialog.setHeaderText("Please note to following issue:"); + dialog.setHeaderText("The following projects are no longer available.\n" + + "Would you like to remove them from your mapping list?"); VBox warningBox = new VBox(10); for (String warning : warnings) { @@ -299,10 +341,11 @@ private void showInvalidMappingsDialog(final List warnings) { dialog.getDialogPane().setContent(scrollPane); dialog.getDialogPane().setMinWidth(400); - // Add OK button - ButtonType okButton = new ButtonType("OK", ButtonBar.ButtonData.OK_DONE); - dialog.getDialogPane().getButtonTypes().add(okButton); + ButtonType removeButton = new ButtonType("Remove", ButtonBar.ButtonData.YES); + ButtonType keepButton = new ButtonType("Keep", ButtonBar.ButtonData.NO); + dialog.getDialogPane().getButtonTypes().setAll(removeButton, keepButton); - dialog.showAndWait(); + Optional result = dialog.showAndWait(); + return result.isPresent() && result.get() == removeButton; } } diff --git a/src/main/java/de/doubleslash/keeptime/view/ExternalProjectsSyncController.java b/src/main/java/de/doubleslash/keeptime/view/ExternalProjectsSyncController.java index 9ddc7008..cb3cd00c 100644 --- a/src/main/java/de/doubleslash/keeptime/view/ExternalProjectsSyncController.java +++ b/src/main/java/de/doubleslash/keeptime/view/ExternalProjectsSyncController.java @@ -137,6 +137,7 @@ public class ExternalProjectsSyncController { private LocalDate currentReportDate; private Stage thisStage; + private Timeline closingTimeline; private final HeimatController heimatController; private final RotateTransition loadingSpinnerAnimation = new RotateTransition(Duration.seconds(1), syncingIconRegion); @@ -236,7 +237,8 @@ public void initForDate(LocalDate currentReportDate, List currentWorkItems mappingTableView.scrollTo(items.size() - 1); }); heimatTaskSearchPopup.setClearFieldAfterSelection(true); - + heimatTaskSearchPopup.setMaxSuggestionHeight(220); + heimatTaskSearchPopup.setPromptText("Select Project..."); heimatTaskSearchContainer.getChildren().add(heimatTaskSearchPopup.getComboBox()); HBox.setHgrow(heimatTaskSearchPopup.getComboBox(), Priority.ALWAYS); } @@ -523,10 +525,14 @@ protected List call() { loadingSuccess); } + if (closingTimeline != null) { + closingTimeline.stop(); + } + final AtomicInteger remainingSeconds = new AtomicInteger(closingSeconds); loadingClosingMessage.setText("Closing in " + remainingSeconds + " seconds..."); loadingClosingMessage.setVisible(true); - Timeline timeline = new Timeline(new KeyFrame(Duration.seconds(1), event -> { + closingTimeline = new Timeline(new KeyFrame(Duration.seconds(1), event -> { remainingSeconds.getAndDecrement(); loadingClosingMessage.setText("Closing in " + remainingSeconds + " seconds..."); if (remainingSeconds.get() <= 0) { @@ -535,8 +541,8 @@ protected List call() { loadingClosingMessage.setVisible(false); } })); - timeline.setCycleCount(remainingSeconds.get()); - timeline.play(); + closingTimeline.setCycleCount(remainingSeconds.get()); + closingTimeline.play(); }); task.setOnFailed(e -> { @@ -735,6 +741,13 @@ public static LocalTime decrementToNextHour(LocalTime time) { public void setStage(final Stage thisStage) { this.thisStage = thisStage; + + thisStage.setOnCloseRequest(e -> { + if (closingTimeline != null) { + closingTimeline.stop(); + closingTimeline = null; + } + }); } public static class TableRow { diff --git a/src/main/java/de/doubleslash/keeptime/view/SettingsController.java b/src/main/java/de/doubleslash/keeptime/view/SettingsController.java index 4167cd28..a1e7dd6e 100644 --- a/src/main/java/de/doubleslash/keeptime/view/SettingsController.java +++ b/src/main/java/de/doubleslash/keeptime/view/SettingsController.java @@ -23,6 +23,7 @@ import java.io.InputStream; import java.nio.file.Paths; import java.sql.SQLException; +import java.time.LocalDateTime; import java.util.Comparator; import java.util.HashMap; import java.util.Map; @@ -215,6 +216,9 @@ public class SettingsController { @FXML private Label heimatExpiresLabel; + @FXML + private Label expirationDateLabel; + @FXML private Button heimatValidateConnectionButton; @@ -411,9 +415,21 @@ private void initializeHeimat() { heimatPatTextField.textProperty().addListener((observable, oldValue, newValue)->{ try{ final JwtDecoder.JWTTokenAttributes jwt = JwtDecoder.parse(newValue); - heimatExpiresLabel.setText(jwt.expiration().toString()); + if (!JwtDecoder.isExpired(jwt, LocalDateTime.now())) { + heimatExpiresLabel.setText("Expired:"); + heimatExpiresLabel.setTextFill(Color.RED); + expirationDateLabel.setTextFill(Color.RED); + } else { + heimatExpiresLabel.setText("Expires:"); + heimatExpiresLabel.setTextFill(Color.BLACK); + expirationDateLabel.setTextFill(Color.BLACK); + } + + expirationDateLabel.setText(jwt.expiration().toString()); + } catch(Exception e){ - heimatExpiresLabel.setText("Does not seem to be valid"); + heimatExpiresLabel.setText(""); + expirationDateLabel.setText("Does not seem to be valid"); } }); heimatValidateConnectionLabel.setText("Not validated."); diff --git a/src/main/java/de/doubleslash/keeptime/viewpopup/SearchPopup.java b/src/main/java/de/doubleslash/keeptime/viewpopup/SearchPopup.java index 3306915b..151e9f3d 100644 --- a/src/main/java/de/doubleslash/keeptime/viewpopup/SearchPopup.java +++ b/src/main/java/de/doubleslash/keeptime/viewpopup/SearchPopup.java @@ -33,7 +33,8 @@ public class SearchPopup { private String promptText = "Select item…"; private double maxSuggestionHeight = 200; - private BiConsumer> onItemSelected = (item, popup) -> {}; + private BiConsumer> onItemSelected = (item, popup) -> { + }; private boolean clearFieldAfterSelection = false; public SearchPopup() { @@ -70,11 +71,12 @@ private void setupUI() { private final StackPane pane = new StackPane(label); { label.setWrapText(true); - label.setStyle("-fx-padding: 5;"); + label.setStyle("-fx-padding: 2;"); pane.setAlignment(Pos.CENTER_LEFT); pane.setMinWidth(0); pane.setPrefWidth(1); } + @Override protected void updateItem(T item, boolean empty) { super.updateItem(item, empty); @@ -95,14 +97,15 @@ private void setupListeners() { }); ChangeListener hidePopupListener = (obs, was, isNow) -> { - if (!searchField.isFocused() && !suggestionList.isFocused()) popup.hide(); + if (!searchField.isFocused() && !suggestionList.isFocused()) + popup.hide(); }; searchField.focusedProperty().addListener((obs, wasFocused, isNowFocused) -> { if (isNowFocused && !clearFieldAfterSelection) { filterList(""); // Show all items show(searchField); - searchField.selectAll(); // <--- This line selects all text! + searchField.selectAll(); } }); @@ -131,7 +134,8 @@ private void setupListeners() { suggestionList.setOnKeyPressed(ev -> { if (ev.getCode() == KeyCode.ENTER) { T selected = suggestionList.getSelectionModel().getSelectedItem(); - if (selected != null) handleSelection(selected); + if (selected != null) + handleSelection(selected); } else if (ev.getCode() == KeyCode.UP && suggestionList.getSelectionModel().getSelectedIndex() == 0) { searchField.requestFocus(); } else if (ev.getCode() == KeyCode.ESCAPE) { @@ -142,21 +146,19 @@ private void setupListeners() { suggestionList.setOnMouseClicked(ev -> { T selected = suggestionList.getSelectionModel().getSelectedItem(); - if (selected != null) handleSelection(selected); + if (selected != null) + handleSelection(selected); }); - searchField.textProperty().addListener((obs, oldText, newText) -> { - filterList(newText); - }); + searchField.textProperty().addListener((obs, oldText, newText) -> filterList(newText)); } private void filterList(String input) { String filter = (input == null) ? "" : input.trim().toLowerCase(); - ObservableList filtered = FXCollections.observableArrayList( - allItems.stream() - .filter(item -> displayTextFunction.apply(item).toLowerCase().contains(filter)) - .collect(Collectors.toList()) - ); + ObservableList filtered = FXCollections.observableArrayList(allItems.stream() + .filter(item -> displayTextFunction.apply( + item).toLowerCase().contains(filter)) + .collect(Collectors.toList())); suggestionList.setItems(filtered); if (!filtered.isEmpty() && searchField.isFocused()) { show(searchField); @@ -200,14 +202,13 @@ public void setMaxSuggestionHeight(double height) { suggestionList.setMaxHeight(height); } - public HBox getComboBox() { - return container; - } + public HBox getComboBox() { return container; } public void show(Node owner) { - if (owner == null || suggestionList.getItems().isEmpty()) return; + if (owner == null || suggestionList.getItems().isEmpty()) + return; Bounds bounds = owner.localToScreen(owner.getBoundsInLocal()); - suggestionList.setPrefWidth(searchField.getWidth()); + suggestionList.setPrefWidth(container.getWidth()); popup.show(owner, bounds.getMinX(), bounds.getMaxY()); } @@ -225,38 +226,44 @@ public void setSelectedItem(T item) { public T getSelectedItem() { String text = searchField.getText(); for (T item : allItems) { - if (displayTextFunction.apply(item).equals(text)) return item; + if (displayTextFunction.apply(item).equals(text)) + return item; } return null; } - public TextField getSearchField() { - return searchField; - } + public TextField getSearchField() { return searchField; } - public ListView getSuggestionList() { - return suggestionList; - } + public ListView getSuggestionList() { return suggestionList; } - public Button getShowSuggestionsButton() { - return showSuggestionsButton; - } + public Button getShowSuggestionsButton() { return showSuggestionsButton; } - public Function getDisplayTextFunction() { - return displayTextFunction; - } + public Function getDisplayTextFunction() { return displayTextFunction; } public void setOnItemSelected(BiConsumer> handler) { - this.onItemSelected = handler != null ? handler : (item, popup) -> {}; + this.onItemSelected = handler != null ? handler : (item, popup) -> { + }; } - public void setClearFieldAfterSelection(boolean c) { - this.clearFieldAfterSelection = c; - } + public void setClearFieldAfterSelection(boolean c) { this.clearFieldAfterSelection = c; } public void clear() { searchField.clear(); if (!promptText.isEmpty()) searchField.setPromptText(promptText); } + + public void setComboBoxTooltip(String tooltipText) { + if (tooltipText != null && !tooltipText.isBlank()) { + Tooltip tooltip = new Tooltip(tooltipText); + + Tooltip.install(container, tooltip); + Tooltip.install(searchField, tooltip); + Tooltip.install(showSuggestionsButton, tooltip); + } else { + Tooltip.uninstall(container, null); + Tooltip.uninstall(searchField, null); + Tooltip.uninstall(showSuggestionsButton, null); + } + } } \ No newline at end of file diff --git a/src/main/resources/layouts/externalProjectSync.fxml b/src/main/resources/layouts/externalProjectSync.fxml index b95a6f27..7d0f2aa1 100644 --- a/src/main/resources/layouts/externalProjectSync.fxml +++ b/src/main/resources/layouts/externalProjectSync.fxml @@ -1,5 +1,6 @@ + @@ -8,7 +9,7 @@ - + @@ -42,17 +43,20 @@ - + - - + +