diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml new file mode 100644 index 00000000..8ea494c3 --- /dev/null +++ b/.github/workflows/test-build.yml @@ -0,0 +1,100 @@ +name: Test Build + +on: + pull_request: + branches: + - main + - "release/**" + +permissions: + contents: read + checks: write + pull-requests: write + packages: write + +jobs: + build-and-test: + runs-on: ubuntu-latest + outputs: + pr_number: ${{ github.event.pull_request.number }} + short_sha: ${{ steps.short_sha.outputs.short_sha }} + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Get Short SHA + id: short_sha + run: echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT + + - name: Set up JDK + uses: actions/setup-java@v4 + with: + java-version: "21" + distribution: "corretto" + cache: "maven" + + - name: Cache Maven packages + uses: actions/cache@v4 + with: + path: | + ~/.m2/repository + !~/.m2/repository/org/devgateway/tcdi + key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-maven- + + - name: Build and Test + run: mvn -B verify -Dcheckstyle.skip=true -Dtest='!**/DatasetClientTest' --file pom.xml + + - name: Publish Test Results + uses: EnricoMi/publish-unit-test-result-action@v2 + if: always() + with: + files: | + **/target/surefire-reports/*.xml + + - name: Upload Build Artifacts + uses: actions/upload-artifact@v4 + with: + name: build-artifacts + path: | + **/target/*.jar + retention-days: 7 + + build-and-push-docker-image: + needs: build-and-test + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + with: + platforms: linux/amd64,linux/arm64 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Login to Docker Registry + uses: docker/login-action@v3 + with: + registry: ${{ vars.DOCKER_REGISTRY }} + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + platforms: linux/amd64,linux/arm64 + cache-from: type=gha, scope=data-viz-admin + cache-to: type=gha, scope=data-viz-admin + context: . + push: true + build-args: | + VERSION=pr-${{ needs.build-and-test.outputs.pr_number }} + TAG=${{ needs.build-and-test.outputs.short_sha }} + GIT_BRANCH=${{ github.head_ref }} + tags: | + ${{ vars.DOCKER_REGISTRY }}/data-viz-admin:pr-${{ needs.build-and-test.outputs.pr_number }} + ${{ vars.DOCKER_REGISTRY }}/data-viz-admin:pr-${{ needs.build-and-test.outputs.pr_number }}-${{ needs.build-and-test.outputs.short_sha }} diff --git a/docker-compose-test.README.md b/docker-compose-test.README.md new file mode 100644 index 00000000..4cab39cb --- /dev/null +++ b/docker-compose-test.README.md @@ -0,0 +1,131 @@ +# Docker Compose Test Setup + +This docker-compose file provides a local testing environment for the TCDI Admin application with all required dependencies. + +## Services + +| Service | Port | Description | +|---------|------|-------------| +| postgres | 5432 | PostgreSQL database for the admin application | +| postgres-interference | 5433 | PostgreSQL database for the interference service | +| eureka | 8761 | Netflix Eureka service registry | +| mock-interference-service | 8090 | MockServer simulating the interference service API | + +## Prerequisites + +- Docker and Docker Compose installed +- The admin application built (`mvn install -Dcheckstyle.skip=true -DskipTests`) + +## Quick Start + +1. **Start the infrastructure:** + ```bash + docker-compose -f docker-compose-test.yml up -d + ``` + +2. **Wait for services to be healthy:** + ```bash + docker-compose -f docker-compose-test.yml ps + ``` + +3. **Run the admin application:** + ```bash + mvn spring-boot:run -pl forms -Dcheckstyle.skip=true \ + -Dspring-boot.run.profiles=dev \ + -Dspring-boot.run.arguments="--eureka.client.serviceUrl.defaultZone=http://localhost:8761/eureka/" + ``` + +4. **Access the application:** + - Admin UI: http://localhost:8080 + - Eureka Dashboard: http://localhost:8761 + +## Testing CSV Dataset Upload + +### Scenario: Test normal upload flow + +1. Navigate to the CSV Datasets section in the admin UI +2. Create a new dataset for the "INTERFERENCE" service +3. Upload the test CSV file: `forms/src/test/resources/world_industry_interference_2025.csv` +4. Click "Save and Publish" +5. The mock service will return a successful response + +### Scenario: Test stuck publishing detection + +1. Create and publish a dataset +2. The job checker runs every minute (`@Scheduled(cron = "0 * * * * *")`) +3. If a job is in PUBLISHING state for longer than `dataset.publishing.timeout.minutes` (default: 30), it will be marked as ERROR_IN_PUBLISHING + +### Scenario: Test Cancel Publishing button + +1. Create a dataset and start publishing +2. While in PUBLISHING state, a "Cancel Publishing" button should appear +3. Click the button to cancel and mark as ERROR_IN_PUBLISHING +4. This allows retrying the upload after fixing issues + +## Configuration + +### Publishing Timeout + +Configure the timeout for stuck jobs in `application.properties` or via environment variable: + +```properties +dataset.publishing.timeout.minutes=30 +``` + +### Mock Service Responses + +The mock interference service (MockServer) is configured via `mockserver-init.json`. It simulates: + +- `GET /health` - Health check endpoint +- `POST /datasets` - Dataset upload (returns PROCESSING status) +- `GET /jobs/code/tcdi-*` - Job status check (returns COMPLETED) +- `DELETE /datasets/tcdi-*` - Dataset deletion +- `GET /template/download` - CSV template download +- `GET /dimensions` - Get available dimensions +- `GET /measures` - Get available measures + +## Cleanup + +Stop and remove all containers and volumes: + +```bash +docker-compose -f docker-compose-test.yml down -v +``` + +## Troubleshooting + +### Database connection issues + +Check if PostgreSQL is healthy: +```bash +docker-compose -f docker-compose-test.yml logs postgres +``` + +### Eureka connection issues + +Ensure Eureka is running and accessible: +```bash +curl http://localhost:8761/actuator/health +``` + +### Mock service not responding + +Check MockServer logs: +```bash +docker-compose -f docker-compose-test.yml logs mock-interference-service +``` + +## Running Unit Tests + +The `DatasetClientServiceTest` class contains unit tests for the new functionality: + +```bash +mvn test -pl forms -Dtest=DatasetClientServiceTest -Dcheckstyle.skip=true +``` + +Tests cover: +- Cancel publishing functionality +- Status transitions (PUBLISHING → PUBLISHED, UNPUBLISHING → DRAFT) +- Error status transitions (PUBLISHING → ERROR_IN_PUBLISHING, UNPUBLISHING → ERROR_IN_UNPUBLISHING) +- Timeout detection for stuck jobs +- Dataset saving based on type (CSV vs Tetsim) diff --git a/docker-compose-test.yml b/docker-compose-test.yml new file mode 100644 index 00000000..e34c4281 --- /dev/null +++ b/docker-compose-test.yml @@ -0,0 +1,98 @@ +services: + # PostgreSQL database for the admin application + postgres: + image: postgis/postgis:15-3.3-alpine + restart: unless-stopped + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + environment: + POSTGRES_PASSWORD: admin + POSTGRES_USER: postgres + POSTGRES_DB: tcdi_admin + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d tcdi_admin"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - tcdi-network + + # PostgreSQL database for the interference service (remote service) + postgres-interference: + image: postgis/postgis:15-3.3-alpine + restart: unless-stopped + ports: + - "5433:5432" + volumes: + - postgres_interference_data:/var/lib/postgresql/data + environment: + POSTGRES_PASSWORD: admin + POSTGRES_USER: postgres + POSTGRES_DB: interference + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d interference"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - tcdi-network + + # Eureka service registry + eureka: + image: steeltoeoss/eureka-server:latest + restart: unless-stopped + ports: + - "8761:8761" + environment: + EUREKA_SERVER_ENABLE_SELF_PRESERVATION: "false" + healthcheck: + test: + [ + "CMD", + "wget", + "-q", + "--spider", + "http://localhost:8761/actuator/health", + ] + interval: 10s + timeout: 5s + retries: 10 + networks: + - tcdi-network + + # Mock interference service for testing CSV uploads + # This is a simple mock that simulates the remote interference service API + mock-interference-service: + image: mockserver/mockserver:5.15.0 + restart: unless-stopped + ports: + - "8090:1080" + environment: + MOCKSERVER_INITIALIZATION_JSON_PATH: /config/mockserver-init.json + MOCKSERVER_LOG_LEVEL: INFO + volumes: + - ./mockserver-init.json:/config/mockserver-init.json:ro + healthcheck: + test: + [ + "CMD", + "wget", + "-q", + "--spider", + "http://localhost:1080/mockserver/status", + ] + interval: 10s + timeout: 5s + retries: 5 + networks: + - tcdi-network + +networks: + tcdi-network: + driver: bridge + +volumes: + postgres_data: + postgres_interference_data: diff --git a/forms/src/main/java/org/devgateway/toolkit/forms/service/DatasetClientService.java b/forms/src/main/java/org/devgateway/toolkit/forms/service/DatasetClientService.java index 6a4f30a0..586c6dd6 100644 --- a/forms/src/main/java/org/devgateway/toolkit/forms/service/DatasetClientService.java +++ b/forms/src/main/java/org/devgateway/toolkit/forms/service/DatasetClientService.java @@ -1,8 +1,24 @@ package org.devgateway.toolkit.forms.service; +import static org.devgateway.toolkit.forms.client.ClientConstants.CODE_PREFIX; +import static org.devgateway.toolkit.forms.client.ClientConstants.JobStatus.COMPLETED; +import static org.devgateway.toolkit.forms.client.ClientConstants.JobStatus.ERROR; +import static org.devgateway.toolkit.persistence.dao.DBConstants.Status.DRAFT; +import static org.devgateway.toolkit.persistence.dao.DBConstants.Status.ERROR_IN_PUBLISHING; +import static org.devgateway.toolkit.persistence.dao.DBConstants.Status.ERROR_IN_UNPUBLISHING; +import static org.devgateway.toolkit.persistence.dao.DBConstants.Status.PUBLISHED; +import static org.devgateway.toolkit.persistence.dao.DBConstants.Status.PUBLISHING; + +import java.io.File; +import java.io.IOException; +import java.time.Duration; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.List; import org.apache.commons.io.FileUtils; import org.devgateway.toolkit.forms.client.DataSetClientException; import org.devgateway.toolkit.forms.client.DatasetClient; +import org.devgateway.toolkit.forms.client.DatasetJobStatus; import org.devgateway.toolkit.persistence.dao.data.CSVDataset; import org.devgateway.toolkit.persistence.dao.data.Dataset; import org.devgateway.toolkit.persistence.dao.data.TetsimDataset; @@ -12,27 +28,24 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; -import java.io.File; -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; - -import static org.devgateway.toolkit.forms.client.ClientConstants.CODE_PREFIX; -import static org.devgateway.toolkit.forms.client.ClientConstants.JobStatus.COMPLETED; -import static org.devgateway.toolkit.forms.client.ClientConstants.JobStatus.ERROR; -import static org.devgateway.toolkit.persistence.dao.DBConstants.Status.DRAFT; -import static org.devgateway.toolkit.persistence.dao.DBConstants.Status.ERROR_IN_PUBLISHING; -import static org.devgateway.toolkit.persistence.dao.DBConstants.Status.ERROR_IN_UNPUBLISHING; -import static org.devgateway.toolkit.persistence.dao.DBConstants.Status.PUBLISHED; -import static org.devgateway.toolkit.persistence.dao.DBConstants.Status.PUBLISHING; - @Service public class DatasetClientService { - private static final Logger logger = LoggerFactory.getLogger(DatasetClientService.class); + private static final Logger logger = LoggerFactory.getLogger( + DatasetClientService.class + ); + + /** + * Timeout in minutes for publishing jobs. If a job has been in PUBLISHING state + * for longer than this duration, it will be marked as ERROR_IN_PUBLISHING. + * Default is 30 minutes. + */ + @Value("${dataset.publishing.timeout.minutes:30}") + private int publishingTimeoutMinutes; @Autowired private TetsimDatasetService tetsimDatasetService; @@ -55,49 +68,205 @@ public void triggerCheckDatasetsJob() { private void checkDatasetJobs(List datasets) { datasets.forEach(d -> { - ServiceMetadata serviceMetadata = eurekaClientService.findByName(d.getDestinationService()); - DatasetClient client = new DatasetClient(serviceMetadata.getUrl()); - String status = client.getDatasetJobStatus(CODE_PREFIX + d.getId()).getStatus(); - String initialStatus = d.getStatus(); - if (COMPLETED.equals(status)) { - String completedStatus = getCompletedStatus(initialStatus); - d.setStatus(completedStatus); - logger.info(String.format("The dataset with id %s changed the status from %s to %s", - d.getId(), initialStatus, completedStatus)); - } else if (ERROR.equals(status)) { - String errorStatus = getErrorStatus(initialStatus); - d.setStatus(errorStatus); - logger.info(String.format("The dataset with id %s changed the status from %s to %s", - d.getId(), initialStatus, errorStatus)); - } + try { + ServiceMetadata serviceMetadata = + eurekaClientService.findByName(d.getDestinationService()); + DatasetClient client = new DatasetClient( + serviceMetadata.getUrl() + ); + DatasetJobStatus jobStatus = client.getDatasetJobStatus( + CODE_PREFIX + d.getId() + ); + String initialStatus = d.getStatus(); + + if (jobStatus == null) { + // Job not found on remote service - check if it's been stuck for too long + if (isJobStuck(d)) { + markAsErrorDueToTimeout(d, initialStatus); + } + return; + } - if (!initialStatus.equals(d.getStatus())) { - if (d instanceof TetsimDataset) { - tetsimDatasetService.save((TetsimDataset) d); - } else if (d instanceof CSVDataset) { - csvDatasetService.save((CSVDataset) d); + String remoteStatus = jobStatus.getStatus(); + + if (COMPLETED.equals(remoteStatus)) { + String completedStatus = getCompletedStatus(initialStatus); + d.setStatus(completedStatus); + logger.info( + String.format( + "The dataset with id %s changed the status from %s to %s", + d.getId(), + initialStatus, + completedStatus + ) + ); + } else if (ERROR.equals(remoteStatus)) { + String errorStatus = getErrorStatus(initialStatus); + d.setStatus(errorStatus); + logger.info( + String.format( + "The dataset with id %s changed the status from %s to %s. Error message: %s", + d.getId(), + initialStatus, + errorStatus, + jobStatus.getMessage() + ) + ); } else { - throw new RuntimeException("Invalid dataset class"); + // Job is still processing - check for timeout + if (isJobStuck(d, jobStatus)) { + markAsErrorDueToTimeout(d, initialStatus); + } + } + + if (!initialStatus.equals(d.getStatus())) { + saveDataset(d); + } + } catch (Exception e) { + logger.error( + String.format( + "Error checking job status for dataset %s: %s", + d.getId(), + e.getMessage() + ), + e + ); + // Check if the job has been stuck for too long even if we can't reach the service + if (isJobStuck(d)) { + String initialStatus = d.getStatus(); + markAsErrorDueToTimeout(d, initialStatus); + saveDataset(d); } } }); } + /** + * Check if a job has been stuck in PUBLISHING/UNPUBLISHING state for too long + * based on the dataset's last modified date. + */ + private boolean isJobStuck(Dataset d) { + if (d.getLastModifiedDate() == null) { + return false; + } + ZonedDateTime lastModified = d.getLastModifiedDate().orElse(null); + if (lastModified == null) { + return false; + } + Duration duration = Duration.between(lastModified, ZonedDateTime.now()); + return duration.toMinutes() > publishingTimeoutMinutes; + } + + /** + * Check if a job has been stuck based on the remote job's created date. + */ + private boolean isJobStuck(Dataset d, DatasetJobStatus jobStatus) { + if (jobStatus.getCreatedDate() == null) { + return isJobStuck(d); + } + Duration duration = Duration.between( + jobStatus.getCreatedDate(), + ZonedDateTime.now() + ); + return duration.toMinutes() > publishingTimeoutMinutes; + } + + private void markAsErrorDueToTimeout(Dataset d, String initialStatus) { + String errorStatus = getErrorStatus(initialStatus); + d.setStatus(errorStatus); + logger.warn( + String.format( + "Dataset with id %s has been in %s state for more than %d minutes. " + + "Marking as %s due to timeout. This may indicate a problem with the remote service.", + d.getId(), + initialStatus, + publishingTimeoutMinutes, + errorStatus + ) + ); + } + + private void saveDataset(Dataset d) { + if (d instanceof TetsimDataset) { + tetsimDatasetService.save((TetsimDataset) d); + } else if (d instanceof CSVDataset) { + csvDatasetService.save((CSVDataset) d); + } else { + throw new RuntimeException("Invalid dataset class"); + } + } + private String getCompletedStatus(final String status) { return PUBLISHING.equals(status) ? PUBLISHED : DRAFT; } private String getErrorStatus(final String status) { - return PUBLISHING.equals(status) ? ERROR_IN_PUBLISHING : ERROR_IN_UNPUBLISHING; + return PUBLISHING.equals(status) + ? ERROR_IN_PUBLISHING + : ERROR_IN_UNPUBLISHING; + } + + /** + * Cancel a stuck publishing job by resetting the dataset status to ERROR_IN_PUBLISHING. + * This allows the user to retry the upload after fixing any issues. + * + * @param dataset the dataset to cancel publishing for + */ + public void cancelPublishing(Dataset dataset) { + String initialStatus = dataset.getStatus(); + if (!PUBLISHING.equals(initialStatus)) { + throw new IllegalStateException( + String.format( + "Cannot cancel publishing for dataset %s: current status is %s, expected PUBLISHING", + dataset.getId(), + initialStatus + ) + ); + } + + // Try to delete the dataset from the remote service to clean up partial data + try { + String code = CODE_PREFIX + dataset.getId(); + String serviceURL = getDestinationService(dataset).getUrl(); + new DatasetClient(serviceURL).unpublishDataset(code); + logger.info( + "Successfully requested cleanup of partial data for dataset {} on remote service", + dataset.getId() + ); + } catch (DataSetClientException e) { + logger.warn( + "Could not clean up partial data on remote service for dataset {}: {}. " + + "Manual database cleanup may be required.", + dataset.getId(), + e.getMessage() + ); + } catch (Exception e) { + logger.warn( + "Could not clean up partial data on remote service for dataset {}: {}. " + + "Manual database cleanup may be required.", + dataset.getId(), + e.getMessage() + ); + } + + dataset.setStatus(ERROR_IN_PUBLISHING); + saveDataset(dataset); + logger.info( + "Cancelled publishing for dataset {}. Status changed from {} to {}", + dataset.getId(), + initialStatus, + ERROR_IN_PUBLISHING + ); } - public void publishDataset(Dataset dataset, String fileName, byte[] content) throws DataSetClientException { + public void publishDataset(Dataset dataset, String fileName, byte[] content) + throws DataSetClientException { String serviceURL = getDestinationService(dataset).getUrl(); DatasetClient client = new DatasetClient(serviceURL); String description = dataset.getDescription(); String name = "Dataset " + dataset.getYear(); - if (description != null){ + if (description != null) { name = description; } String code = CODE_PREFIX + dataset.getId(); @@ -114,7 +283,8 @@ public void publishDataset(Dataset dataset, String fileName, byte[] content) thr client.publishDataset(name, code, tempUploadFile); } - public void unpublishDataset(Dataset dataset) throws DataSetClientException { + public void unpublishDataset(Dataset dataset) + throws DataSetClientException { String code = CODE_PREFIX + dataset.getId(); String serviceURL = getDestinationService(dataset).getUrl(); diff --git a/forms/src/main/java/org/devgateway/toolkit/forms/service/EurekaClientService.java b/forms/src/main/java/org/devgateway/toolkit/forms/service/EurekaClientService.java index e00b43fe..2ecbb427 100644 --- a/forms/src/main/java/org/devgateway/toolkit/forms/service/EurekaClientService.java +++ b/forms/src/main/java/org/devgateway/toolkit/forms/service/EurekaClientService.java @@ -1,18 +1,17 @@ package org.devgateway.toolkit.forms.service; +import static org.devgateway.toolkit.forms.WebConstants.SERVICE_DATA_TYPE; + import com.netflix.appinfo.InstanceInfo; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; import org.devgateway.toolkit.persistence.dto.ServiceMetadata; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.cloud.client.discovery.DiscoveryClient; import org.springframework.cloud.netflix.eureka.EurekaServiceInstance; import org.springframework.stereotype.Service; -import java.util.ArrayList; -import java.util.List; -import java.util.stream.Collectors; - -import static org.devgateway.toolkit.forms.WebConstants.SERVICE_DATA_TYPE; - @Service public class EurekaClientService { @@ -21,34 +20,58 @@ public class EurekaClientService { public List findAll() { List services = new ArrayList<>(); - discoveryClient.getServices().forEach(s -> { - discoveryClient.getInstances(s).stream().forEach(instance -> { - InstanceInfo instanceInfo = ((EurekaServiceInstance) instance).getInstanceInfo(); - ServiceMetadata service = new ServiceMetadata(); - service.setName(instanceInfo.getAppName()); - service.setUrl(instanceInfo.getHomePageUrl()); - service.setId(instanceInfo.getId()); - service.setType(instance.getMetadata().getOrDefault("type", null)); - service.setLabel(instance.getMetadata().getOrDefault("label", instanceInfo.getAppName())); - service.setStatus(instanceInfo.getStatus().toString()); - service.setTetsim(Boolean.valueOf(instanceInfo.getMetadata().getOrDefault("tetsim", "false"))); - services.add(service); + discoveryClient + .getServices() + .forEach(s -> { + discoveryClient + .getInstances(s) + .stream() + .forEach(instance -> { + InstanceInfo instanceInfo = ( + (EurekaServiceInstance) instance + ).getInstanceInfo(); + ServiceMetadata service = new ServiceMetadata(); + service.setName(instanceInfo.getAppName()); + service.setUrl(instanceInfo.getHomePageUrl()); + service.setId(instanceInfo.getId()); + service.setType( + instance.getMetadata().getOrDefault("type", null) + ); + service.setLabel( + instance + .getMetadata() + .getOrDefault( + "label", + instanceInfo.getAppName() + ) + ); + service.setStatus(instanceInfo.getStatus().toString()); + service.setTetsim( + Boolean.valueOf( + instanceInfo + .getMetadata() + .getOrDefault("tetsim", "false") + ) + ); + services.add(service); + }); }); - }); return services; } public List findAllWithData() { - return findAll().stream() - .filter(s -> SERVICE_DATA_TYPE.equalsIgnoreCase(s.getType())) - .collect(Collectors.toList()); + return findAll() + .stream() + .filter(s -> SERVICE_DATA_TYPE.equalsIgnoreCase(s.getType())) + .collect(Collectors.toList()); } public ServiceMetadata findByName(String name) { - return findAll().stream() - .filter(s -> s.getName().equals(name)) - .findFirst().get(); + return findAll() + .stream() + .filter(s -> s.getName().equals(name)) + .findFirst() + .get(); } - } diff --git a/forms/src/main/java/org/devgateway/toolkit/forms/wicket/page/edit/AbstractEditStatusEntityPage.html b/forms/src/main/java/org/devgateway/toolkit/forms/wicket/page/edit/AbstractEditStatusEntityPage.html index 3baacb0a..edc60956 100644 --- a/forms/src/main/java/org/devgateway/toolkit/forms/wicket/page/edit/AbstractEditStatusEntityPage.html +++ b/forms/src/main/java/org/devgateway/toolkit/forms/wicket/page/edit/AbstractEditStatusEntityPage.html @@ -1,99 +1,143 @@ - + - - + + +
+
+ ErrorInPublishing +
+
-
-
- ErrorInPublishing -
-
+ - +
+
+
-
-
-
+
+
+
+ + Status Label +
-
-
-
+
+
-
- - Status Label -
+
+
+ + + + + + + + + + -
-
+ + + + + + + + + +
#Status changeCommentUserDate & Time
+ + + + + + + + + +
+
+
+
-
-
- - - - - - - - - - + + - - - - - - - - - -
#Status changeCommentUserDate & Time
- - - - - - - - - -
-
-
-
+
+
+ AutoSaveLabel + CheckedOutTo +
+
- - +
+
+
+
+
-
-
- AutoSaveLabel - CheckedOutTo -
-
+ + + + + + + + + + -
-
-
-
-
+
+
+
- - - - - - - - - - -
-
- - - - + + + diff --git a/forms/src/main/java/org/devgateway/toolkit/forms/wicket/page/edit/AbstractEditStatusEntityPage.java b/forms/src/main/java/org/devgateway/toolkit/forms/wicket/page/edit/AbstractEditStatusEntityPage.java index 8b678455..b2e621a8 100644 --- a/forms/src/main/java/org/devgateway/toolkit/forms/wicket/page/edit/AbstractEditStatusEntityPage.java +++ b/forms/src/main/java/org/devgateway/toolkit/forms/wicket/page/edit/AbstractEditStatusEntityPage.java @@ -11,11 +11,21 @@ *******************************************************************************/ package org.devgateway.toolkit.forms.wicket.page.edit; +import static org.devgateway.toolkit.forms.WebConstants.PARAM_AUTO_SAVE; +import static org.devgateway.toolkit.persistence.dao.DBConstants.Status.DRAFT; +import static org.devgateway.toolkit.persistence.dao.DBConstants.Status.ERROR_IN_PUBLISHING; +import static org.devgateway.toolkit.persistence.dao.DBConstants.Status.ERROR_IN_UNPUBLISHING; +import static org.devgateway.toolkit.persistence.dao.DBConstants.Status.PUBLISHED; +import static org.devgateway.toolkit.persistence.dao.DBConstants.Status.PUBLISHING; +import static org.devgateway.toolkit.persistence.dao.DBConstants.Status.SAVED; +import static org.devgateway.toolkit.persistence.dao.DBConstants.Status.UNPUBLISHING; + import de.agilecoders.wicket.core.markup.html.bootstrap.button.Buttons; import de.agilecoders.wicket.core.markup.html.bootstrap.dialog.TextContentModal; import de.agilecoders.wicket.core.markup.html.bootstrap.form.BootstrapCheckbox; import de.agilecoders.wicket.extensions.markup.html.bootstrap.icon.FontAwesome5IconType; -import org.devgateway.toolkit.forms.wicket.components.buttons.ladda.LaddaAjaxButton; +import java.text.MessageFormat; +import java.time.Duration; import org.apache.wicket.AttributeModifier; import org.apache.wicket.Component; import org.apache.wicket.ajax.AbstractAjaxTimerBehavior; @@ -44,6 +54,7 @@ import org.apache.wicket.util.visit.IVisitor; import org.devgateway.toolkit.forms.WebConstants; import org.devgateway.toolkit.forms.security.SecurityConstants; +import org.devgateway.toolkit.forms.wicket.components.buttons.ladda.LaddaAjaxButton; import org.devgateway.toolkit.forms.wicket.components.form.BootstrapSubmitButton; import org.devgateway.toolkit.forms.wicket.components.form.CheckBoxBootstrapFormComponent; import org.devgateway.toolkit.forms.wicket.components.form.CheckBoxYesNoToggleBootstrapFormComponent; @@ -60,24 +71,16 @@ import org.wicketstuff.datetime.markup.html.basic.DateLabel; import org.wicketstuff.select2.Select2Choice; -import java.text.MessageFormat; -import java.time.Duration; - -import static org.devgateway.toolkit.forms.WebConstants.PARAM_AUTO_SAVE; -import static org.devgateway.toolkit.persistence.dao.DBConstants.Status.DRAFT; -import static org.devgateway.toolkit.persistence.dao.DBConstants.Status.ERROR_IN_PUBLISHING; -import static org.devgateway.toolkit.persistence.dao.DBConstants.Status.ERROR_IN_UNPUBLISHING; -import static org.devgateway.toolkit.persistence.dao.DBConstants.Status.PUBLISHED; -import static org.devgateway.toolkit.persistence.dao.DBConstants.Status.PUBLISHING; -import static org.devgateway.toolkit.persistence.dao.DBConstants.Status.SAVED; -import static org.devgateway.toolkit.persistence.dao.DBConstants.Status.UNPUBLISHING; - /** * @author mpostelnicu * Page used to make editing easy, extend to get easy access to one entity for editing */ -public abstract class AbstractEditStatusEntityPage - extends AbstractEditPage implements DefaultValidatorRoleAssignable, ResourceLockable { +public abstract class AbstractEditStatusEntityPage< + T extends AbstractStatusAuditableEntity + > + extends AbstractEditPage + implements DefaultValidatorRoleAssignable, ResourceLockable +{ protected Fragment entityButtonsFragment; @@ -97,6 +100,10 @@ public abstract class AbstractEditStatusEntityPage buttonModel; private ModalSaveEditPageButton modalSavePageButton; - public ButtonContentModal(String markupId, IModel model, IModel buttonModel, - Buttons.Type buttonType) { + public ButtonContentModal( + String markupId, + IModel model, + IModel buttonModel, + Buttons.Type buttonType + ) { super(markupId, model); addCloseButton(); this.buttonModel = buttonModel; this.buttonType = buttonType; } - public ButtonContentModal modalSavePageButton(ModalSaveEditPageButton modalSavePageButton) { + public ButtonContentModal modalSavePageButton( + ModalSaveEditPageButton modalSavePageButton + ) { this.modalSavePageButton = modalSavePageButton; return this; } @@ -167,11 +180,18 @@ protected void onSubmit(AjaxRequestTarget target) { } protected TextContentModal createApproveModal() { - final TextContentModal modal = new TextContentModal("approveModal", - Model.of(" Are you sure you want to Publish? This information will be visible in the website. " - + "Please remember to check if the text content needs to be updated too.")); - - final SaveEditPageButton publishButton = new SaveEditPageButton("button", Model.of("Yes")) { + final TextContentModal modal = new TextContentModal( + "approveModal", + Model.of( + " Are you sure you want to Publish? This information will be visible in the website. " + + "Please remember to check if the text content needs to be updated too." + ) + ); + + final SaveEditPageButton publishButton = new SaveEditPageButton( + "button", + Model.of("Yes") + ) { @Override protected String getOnClickScript() { return WebConstants.DISABLE_FORM_LEAVING_JS; @@ -192,10 +212,17 @@ protected void onSubmit(final AjaxRequestTarget target) { } protected TextContentModal createUnpublishModal() { - final TextContentModal modal = new TextContentModal("unpublishModal", - Model.of("Are you sure you want to Unpublish? This information will be removed from the website.")); - - final SaveEditPageButton unpublishButton = new SaveEditPageButton("button", Model.of("Yes")) { + final TextContentModal modal = new TextContentModal( + "unpublishModal", + Model.of( + "Are you sure you want to Unpublish? This information will be removed from the website." + ) + ); + + final SaveEditPageButton unpublishButton = new SaveEditPageButton( + "button", + Model.of("Yes") + ) { @Override protected String getOnClickScript() { return WebConstants.DISABLE_FORM_LEAVING_JS; @@ -217,21 +244,29 @@ protected void onSubmit(final AjaxRequestTarget target) { protected ButtonContentModal createTerminateModal() { ButtonContentModal buttonContentModal = new ButtonContentModal( - "terminateModal", - Model.of("Are you sure you want to TERMINATE the contracting process?"), - Model.of("TERMINATE"), Buttons.Type.Danger); + "terminateModal", + Model.of( + "Are you sure you want to TERMINATE the contracting process?" + ), + Model.of("TERMINATE"), + Buttons.Type.Danger + ); return buttonContentModal; } public class ModalSaveEditPageButton extends SaveEditPageButton { + private TextContentModal modal; - public ModalSaveEditPageButton(String id, IModel model, TextContentModal modal) { + public ModalSaveEditPageButton( + String id, + IModel model, + TextContentModal modal + ) { super(id, model); this.modal = modal; } - @Override protected String getOnClickScript() { return WebConstants.DISABLE_FORM_LEAVING_JS; @@ -260,7 +295,9 @@ protected void beforeSaveEntity(T saveable) { protected void onInitialize() { super.onInitialize(); - if (editForm.getModelObject().isDeleted() || !canEditLockableResource()) { + if ( + editForm.getModelObject().isDeleted() || !canEditLockableResource() + ) { setResponsePage(listPageClass); } @@ -275,7 +312,9 @@ protected void onInitialize() { visibleStatusComments = getVisibleStatusComments(); editForm.add(visibleStatusComments); - statusCommentsWrapper = new TransparentWebMarkupContainer("statusCommentsWrapper"); + statusCommentsWrapper = new TransparentWebMarkupContainer( + "statusCommentsWrapper" + ); statusCommentsWrapper.setOutputMarkupId(true); statusCommentsWrapper.setOutputMarkupPlaceholderTag(true); statusCommentsWrapper.setVisibilityAllowed(false); @@ -288,10 +327,18 @@ protected void onInitialize() { editForm.add(getErrorInPublishing()); - entityButtonsFragment = new Fragment("extraButtons", "entityButtons", this); + entityButtonsFragment = new Fragment( + "extraButtons", + "entityButtons", + this + ); editForm.replace(entityButtonsFragment); - Fragment fragment = new Fragment("extraStatusEntityButtons", "noButtons", this); + Fragment fragment = new Fragment( + "extraStatusEntityButtons", + "noButtons", + this + ); entityButtonsFragment.add(fragment); saveSubmitButton = getSaveSubmitPageButton(); @@ -316,9 +363,15 @@ protected void onInitialize() { revertToDraftPageButton = getRevertToDraftPageButton(); entityButtonsFragment.add(revertToDraftPageButton); + cancelPublishingButton = getCancelPublishingButton(); + entityButtonsFragment.add(cancelPublishingButton); + unpublishModal = createUnpublishModal(); editForm.add(unpublishModal); + cancelPublishingModal = createCancelPublishingModal(); + editForm.add(cancelPublishingModal); + applyDraftSaveBehavior(saveButton); applyDraftSaveBehavior(saveDraftContinueButton); applyDraftSaveBehavior(revertToDraftPageButton); @@ -329,8 +382,12 @@ protected void onInitialize() { } private TransparentWebMarkupContainer getErrorInPublishing() { - TransparentWebMarkupContainer errorInPublishing = new TransparentWebMarkupContainer("errorInPublishingPanel"); - Label errorInPublishingLabel = new Label("errorInPublishingTitle", getErrorInPublishingMessage()); + TransparentWebMarkupContainer errorInPublishing = + new TransparentWebMarkupContainer("errorInPublishingPanel"); + Label errorInPublishingLabel = new Label( + "errorInPublishingTitle", + getErrorInPublishingMessage() + ); errorInPublishing.setOutputMarkupId(true); errorInPublishing.setOutputMarkupPlaceholderTag(true); errorInPublishing.setVisibilityAllowed(isErrorInPublishingVisible()); @@ -340,21 +397,31 @@ private TransparentWebMarkupContainer getErrorInPublishing() { } private IModel getErrorInPublishingMessage() { - String status = ERROR_IN_PUBLISHING.equals(editForm.getModelObject().getStatus()) ? "publishing" : "unpublishing"; - return Model.of(MessageFormat.format(getString("errorInPublishingTitle"), status)); + String status = ERROR_IN_PUBLISHING.equals( + editForm.getModelObject().getStatus() + ) + ? "publishing" + : "unpublishing"; + return Model.of( + MessageFormat.format(getString("errorInPublishingTitle"), status) + ); } private boolean isErrorInPublishingVisible() { String status = editForm.getModelObject().getStatus(); - return ERROR_IN_PUBLISHING.equals(status) || ERROR_IN_UNPUBLISHING.equals(status); + return ( + ERROR_IN_PUBLISHING.equals(status) || + ERROR_IN_UNPUBLISHING.equals(status) + ); } @Override protected void afterSaveEntity(final T saveable) { super.afterSaveEntity(saveable); - getPageParameters().set(WebConstants.V_POSITION, verticalPosition.getValue()) - .set(WebConstants.MAX_HEIGHT, maxHeight.getValue()); + getPageParameters() + .set(WebConstants.V_POSITION, verticalPosition.getValue()) + .set(WebConstants.MAX_HEIGHT, maxHeight.getValue()); } @Override @@ -369,7 +436,9 @@ protected void onBeforeRender() { checkAndSendEventForDisableEditing(); - this.statusLabel.setVisibilityAllowed(editForm.getModelObject().getVisibleStatusLabel()); + this.statusLabel.setVisibilityAllowed( + editForm.getModelObject().getVisibleStatusLabel() + ); } protected void checkAndSendEventForDisableEditing() { @@ -379,10 +448,12 @@ protected void checkAndSendEventForDisableEditing() { } public boolean isDisableEditingEvent() { - return !Strings.isEqual(editForm.getModelObject().getStatus(), DRAFT) || isViewMode(); + return ( + !Strings.isEqual(editForm.getModelObject().getStatus(), DRAFT) || + isViewMode() + ); } - protected boolean isViewMode() { return false; } @@ -391,9 +462,13 @@ private void addCheckedOutTo() { if (getLockableResource().getCheckedOutUser() == null) { checkedOutToLabel = new Label("checkedOutTo", Model.of("")); } else { - checkedOutToLabel = new Label("checkedOutTo", - new StringResourceModel("checkedOutToMessage", this) - .setParameters(getCheckedOutUsername())); + checkedOutToLabel = new Label( + "checkedOutTo", + new StringResourceModel( + "checkedOutToMessage", + this + ).setParameters(getCheckedOutUsername()) + ); checkedOutToLabel.setOutputMarkupPlaceholderTag(true); checkedOutToLabel.setOutputMarkupId(true); } @@ -402,16 +477,24 @@ private void addCheckedOutTo() { private void addRemoveLock() { removeLock = new CheckBoxBootstrapFormComponent("removeLock"); -// removeLock.setVisibilityAllowed(getLockableResource().getCheckedOutUser() != null); + // removeLock.setVisibilityAllowed(getLockableResource().getCheckedOutUser() != null); removeLock.setVisibilityAllowed(false); editForm.add(removeLock); - MetaDataRoleAuthorizationStrategy.authorize(removeLock, Component.RENDER, SecurityConstants.Roles.ROLE_ADMIN); + MetaDataRoleAuthorizationStrategy.authorize( + removeLock, + Component.RENDER, + SecurityConstants.Roles.ROLE_ADMIN + ); } private void addAutosaveLabel() { Integer autosaveTime = adminSettingsService.getAutosaveTime(); - autoSaveLabel = new Label("autoSaveLabel", - new StringResourceModel("autoSaveLabelMessage", this).setParameters(autosaveTime)); + autoSaveLabel = new Label( + "autoSaveLabel", + new StringResourceModel("autoSaveLabelMessage", this).setParameters( + autosaveTime + ) + ); autoSaveLabel.setVisibilityAllowed(false); autoSaveLabel.setOutputMarkupPlaceholderTag(true); autoSaveLabel.setOutputMarkupId(true); @@ -419,7 +502,11 @@ private void addAutosaveLabel() { } private void addVerticalMaxPositionFields() { - verticalPosition = new HiddenField<>("verticalPosition", new Model<>(), Double.class); + verticalPosition = new HiddenField<>( + "verticalPosition", + new Model<>(), + Double.class + ); verticalPosition.setOutputMarkupId(true); editForm.add(verticalPosition); @@ -437,17 +524,27 @@ protected void enableDisableAutosaveFields(final AjaxRequestTarget target) { saveSubmitButton.setEnabled(true); if (target != null) { - target.add(saveButton, saveSubmitButton, saveDraftContinueButton, submitAndNext); + target.add( + saveButton, + saveSubmitButton, + saveDraftContinueButton, + submitAndNext + ); } } private void addAutosaveBehavior(final AjaxRequestTarget target) { // enable autosave - if (!ComponentUtil.isPrintMode() && adminSettingsService.getAutosaveTime() > 0 - && Strings.isEqual(editForm.getModelObject().getStatus(), DRAFT) - && !editForm.getModelObject().isNew()) { + if ( + !ComponentUtil.isPrintMode() && + adminSettingsService.getAutosaveTime() > 0 && + Strings.isEqual(editForm.getModelObject().getStatus(), DRAFT) && + !editForm.getModelObject().isNew() + ) { saveDraftContinueButton.add(getAutosaveBehavior()); - boolean isAutoSaveVisible = getPageParameters().get(PARAM_AUTO_SAVE).toBoolean(false); + boolean isAutoSaveVisible = getPageParameters() + .get(PARAM_AUTO_SAVE) + .toBoolean(false); autoSaveLabel.setVisibilityAllowed(isAutoSaveVisible); if (target != null) { target.add(autoSaveLabel); @@ -456,36 +553,64 @@ private void addAutosaveBehavior(final AjaxRequestTarget target) { } private AbstractAjaxTimerBehavior getAutosaveBehavior() { - final AbstractAjaxTimerBehavior ajaxTimerBehavior = new AbstractAjaxTimerBehavior( - Duration.ofMinutes(adminSettingsService.getAutosaveTime())) { - @Override - protected void onTimer(final AjaxRequestTarget target) { - // display block UI message until the page is reloaded - target.prependJavaScript(getShowBlockUICode()); - - // disable all fields from js and lose focus (execute this javascript code before components processed) - target.prependJavaScript("$(document.activeElement).blur();"); - - // invoke autosave from js (execute this javascript code before components processed) - target.prependJavaScript("$('#" + maxHeight.getMarkupId() + "').val($(document).height()); " - + "$('#" + verticalPosition.getMarkupId() + "').val($(window).scrollTop()); " - + "$('#" + saveDraftContinueButton.getMarkupId() + "').click();"); - - target.prependJavaScript(getShowBlockUICode()); - - // disable all buttons from js - target.prependJavaScript("$('#" + editForm.getMarkupId() + " button').prop('disabled', true);"); - target.prependJavaScript("$('#modals button').prop('disabled', false);"); - } - }; + final AbstractAjaxTimerBehavior ajaxTimerBehavior = + new AbstractAjaxTimerBehavior( + Duration.ofMinutes(adminSettingsService.getAutosaveTime()) + ) { + @Override + protected void onTimer(final AjaxRequestTarget target) { + // display block UI message until the page is reloaded + target.prependJavaScript(getShowBlockUICode()); + + // disable all fields from js and lose focus (execute this javascript code before components processed) + target.prependJavaScript( + "$(document.activeElement).blur();" + ); + + // invoke autosave from js (execute this javascript code before components processed) + target.prependJavaScript( + "$('#" + + maxHeight.getMarkupId() + + "').val($(document).height()); " + + "$('#" + + verticalPosition.getMarkupId() + + "').val($(window).scrollTop()); " + + "$('#" + + saveDraftContinueButton.getMarkupId() + + "').click();" + ); + + target.prependJavaScript(getShowBlockUICode()); + + // disable all buttons from js + target.prependJavaScript( + "$('#" + + editForm.getMarkupId() + + " button').prop('disabled', true);" + ); + target.prependJavaScript( + "$('#modals button').prop('disabled', false);" + ); + } + }; return ajaxTimerBehavior; } private Label addStatusLabel() { - statusLabel = new Label("statusLabel", editForm.getModelObject().getStatus()); - statusLabel.add(new AttributeModifier("class", new Model<>("badge " + getStatusLabelClass()))); - statusLabel.setVisibilityAllowed(editForm.getModelObject().getVisibleStatusLabel()); + statusLabel = new Label( + "statusLabel", + editForm.getModelObject().getStatus() + ); + statusLabel.add( + new AttributeModifier( + "class", + new Model<>("badge " + getStatusLabelClass()) + ) + ); + statusLabel.setVisibilityAllowed( + editForm.getModelObject().getVisibleStatusLabel() + ); return statusLabel; } @@ -513,51 +638,77 @@ private String getStatusLabelClass() { private CheckBoxYesNoToggleBootstrapFormComponent getVisibleStatusComments() { final CheckBoxYesNoToggleBootstrapFormComponent checkBoxBootstrapFormComponent = - new CheckBoxYesNoToggleBootstrapFormComponent("visibleStatusComments") { - @Override - protected void onUpdate(final AjaxRequestTarget target) { - statusCommentsWrapper.setVisibilityAllowed(editForm.getModelObject() - .getVisibleStatusComments()); - target.add(statusCommentsWrapper); - } - - @Override - public void onEvent(final IEvent event) { - // do nothing - keep this field enabled - } - }; + new CheckBoxYesNoToggleBootstrapFormComponent( + "visibleStatusComments" + ) { + @Override + protected void onUpdate(final AjaxRequestTarget target) { + statusCommentsWrapper.setVisibilityAllowed( + editForm.getModelObject().getVisibleStatusComments() + ); + target.add(statusCommentsWrapper); + } + + @Override + public void onEvent(final IEvent event) { + // do nothing - keep this field enabled + } + }; checkBoxBootstrapFormComponent.setVisibilityAllowed(!isViewMode()); checkBoxBootstrapFormComponent.setVisibilityAllowed(false); return checkBoxBootstrapFormComponent; } - private TextAreaFieldBootstrapFormComponent getNewStatusCommentField() { + private TextAreaFieldBootstrapFormComponent< + String + > getNewStatusCommentField() { final TextAreaFieldBootstrapFormComponent comment = - new OptionallyRequiredTextAreaFieldComponent("newStatusComment") { - - @Override - public void onEvent(final IEvent event) { - // do nothing - keep this field enabled - } - }; + new OptionallyRequiredTextAreaFieldComponent( + "newStatusComment" + ) { + @Override + public void onEvent(final IEvent event) { + // do nothing - keep this field enabled + } + }; comment.setShowTooltip(true); comment.setVisibilityAllowed(false); return comment; } private ListView getStatusCommentsListView() { - final ListView statusComments = new ListView("statusComments") { + final ListView statusComments = new ListView< + StatusChangedComment + >("statusComments") { @Override - protected void populateItem(final ListItem item) { + protected void populateItem( + final ListItem item + ) { item.setModel(new CompoundPropertyModel<>(item.getModel())); item.add(new Label("commentIdx", item.getIndex())); item.add(new Label("status")); item.add(new Label("comment")); - item.add(new Label("createdBy", item.getModelObject().getCreatedBy().get())); - item.add(DateLabel.forDateStyle("created", - Model.of(ComponentUtil - .getDateFromLocalDate(item.getModelObject().getCreatedDate().get().toLocalDate())), - "SS")); + item.add( + new Label( + "createdBy", + item.getModelObject().getCreatedBy().get() + ) + ); + item.add( + DateLabel.forDateStyle( + "created", + Model.of( + ComponentUtil.getDateFromLocalDate( + item + .getModelObject() + .getCreatedDate() + .get() + .toLocalDate() + ) + ), + "SS" + ) + ); } }; statusComments.setReuseItems(true); @@ -570,9 +721,15 @@ protected void populateItem(final ListItem item) { * Use this function to get the block UI message while the form is saved. */ private String getShowBlockUICode() { - return "blockUI('" - + new StringResourceModel("autosave_message", AbstractEditStatusEntityPage.this, null).getString() - + "')"; + return ( + "blockUI('" + + new StringResourceModel( + "autosave_message", + AbstractEditStatusEntityPage.this, + null + ).getString() + + "')" + ); } /******************************************************************************* @@ -585,9 +742,10 @@ private void applyDraftSaveBehavior(final BootstrapSubmitButton button) { @Override protected SaveEditPageButton getSaveEditPageButton() { - final SaveEditPageButton button = new SaveEditPageButton("save", - new StringResourceModel("saveButton", this, null)) { - + final SaveEditPageButton button = new SaveEditPageButton( + "save", + new StringResourceModel("saveButton", this, null) + ) { @Override protected String getOnClickScript() { return WebConstants.DISABLE_FORM_LEAVING_JS; @@ -595,8 +753,10 @@ protected String getOnClickScript() { @Override protected void onSubmit(final AjaxRequestTarget target) { - editForm.visitChildren(GenericBootstrapFormComponent.class, - new AllowNullForCertainInvalidFieldsVisitor()); + editForm.visitChildren( + GenericBootstrapFormComponent.class, + new AllowNullForCertainInvalidFieldsVisitor() + ); setStatusAppendComment(DRAFT); super.onSubmit(target); } @@ -606,8 +766,10 @@ protected void onSubmit(final AjaxRequestTarget target) { } private SaveEditPageButton getSaveSubmitPageButton() { - final SaveEditPageButton button = new SaveEditPageButton("saveSubmit", - new StringResourceModel("saveSubmit", this, null)) { + final SaveEditPageButton button = new SaveEditPageButton( + "saveSubmit", + new StringResourceModel("saveSubmit", this, null) + ) { @Override protected String getOnClickScript() { return WebConstants.DISABLE_FORM_LEAVING_JS; @@ -625,8 +787,10 @@ protected void onSubmit(final AjaxRequestTarget target) { } private SaveEditPageButton getSubmitAndNextPageButton() { - final SaveEditPageButton button = new SaveEditPageButton("submitAndNext", - new StringResourceModel("submitAndNext", this, null)) { + final SaveEditPageButton button = new SaveEditPageButton( + "submitAndNext", + new StringResourceModel("submitAndNext", this, null) + ) { @Override protected String getOnClickScript() { return WebConstants.DISABLE_FORM_LEAVING_JS; @@ -669,9 +833,10 @@ protected PageParameters parametersAfterSubmitAndNext() { } private SaveEditPageButton getSaveDraftAndContinueButton() { - final SaveEditPageButton button = new SaveEditPageButton("saveContinue", - new StringResourceModel("saveContinue", this, null)) { - + final SaveEditPageButton button = new SaveEditPageButton( + "saveContinue", + new StringResourceModel("saveContinue", this, null) + ) { @Override protected String getOnClickScript() { return WebConstants.DISABLE_FORM_LEAVING_JS; @@ -679,8 +844,10 @@ protected String getOnClickScript() { @Override protected void onSubmit(final AjaxRequestTarget target) { - editForm.visitChildren(GenericBootstrapFormComponent.class, - new AllowNullForCertainInvalidFieldsVisitor()); + editForm.visitChildren( + GenericBootstrapFormComponent.class, + new AllowNullForCertainInvalidFieldsVisitor() + ); setStatusAppendComment(DRAFT); super.onSubmit(target); } @@ -701,9 +868,10 @@ protected PageParameters getParameterPage() { } private SaveEditPageButton getSaveApprovePageButton() { - final SaveEditPageButton saveEditPageButton = new SaveEditPageButton("saveApprove", - new StringResourceModel("saveApprove", this, null)) { - + final SaveEditPageButton saveEditPageButton = new SaveEditPageButton( + "saveApprove", + new StringResourceModel("saveApprove", this, null) + ) { @Override protected String getOnClickScript() { return WebConstants.DISABLE_FORM_LEAVING_JS; @@ -726,9 +894,10 @@ protected void onError(final AjaxRequestTarget target) { } private SaveEditPageButton getApprovePageButton() { - final SaveEditPageButton saveEditPageButton = new SaveEditPageButton("approve", - new StringResourceModel("approve", this, null)) { - + final SaveEditPageButton saveEditPageButton = new SaveEditPageButton( + "approve", + new StringResourceModel("approve", this, null) + ) { @Override protected void onSubmit(final AjaxRequestTarget target) { approveModal.show(true); @@ -746,9 +915,10 @@ protected void onError(final AjaxRequestTarget target) { } protected SaveEditPageButton getRevertToDraftPageButton() { - final SaveEditPageButton saveEditPageButton = new SaveEditPageButton("revertToDraft", - new StringResourceModel("revertToDraft", this, null)) { - + final SaveEditPageButton saveEditPageButton = new SaveEditPageButton( + "revertToDraft", + new StringResourceModel("revertToDraft", this, null) + ) { @Override protected void onSubmit(final AjaxRequestTarget target) { unpublishModal.show(true); @@ -760,25 +930,96 @@ protected void onError(final AjaxRequestTarget target) { super.onError(target); target.add(feedbackPanel); } - }; saveEditPageButton.setIconType(FontAwesome5IconType.thumbs_down_s); return saveEditPageButton; } - protected void onAfterRevertToDraft(AjaxRequestTarget target) { + protected void onAfterRevertToDraft(AjaxRequestTarget target) {} + + protected void onApprove(AjaxRequestTarget target) {} + + /** + * Called when the user confirms cancellation of a stuck publishing job. + * Override this method in subclasses to implement the actual cancellation logic. + */ + protected void onCancelPublishing(AjaxRequestTarget target) {} + + protected TextContentModal createCancelPublishingModal() { + TextContentModal modal = new TextContentModal( + "cancelPublishingModal", + Model.of( + "Are you sure you want to cancel the publishing process? " + + "This will mark the dataset as failed and allow you to retry the upload. " + + "Note: You may need to clean up partial data on the remote service." + ) + ); + modal.header(Model.of("Cancel Publishing")); + + LaddaAjaxButton cancelButton = new LaddaAjaxButton( + "button", + Buttons.Type.Danger + ) { + @Override + protected String getOnClickScript() { + return WebConstants.DISABLE_FORM_LEAVING_JS; + } + + @Override + protected void onSubmit(final AjaxRequestTarget target) { + onCancelPublishing(target); + super.onSubmit(target); + } + }; + cancelButton.setLabel(Model.of("Yes, Cancel Publishing")); + cancelButton.setType(Buttons.Type.Danger); + cancelButton.setIconType(FontAwesome5IconType.times_circle_s); + modal.addButton(cancelButton); + modal.addCloseButton(Model.of("No")); + return modal; } - protected void onApprove(AjaxRequestTarget target) { + protected SaveEditPageButton getCancelPublishingButton() { + final SaveEditPageButton button = new SaveEditPageButton( + "cancelPublishing", + new StringResourceModel("cancelPublishing", this, null) + ) { + @Override + protected void onSubmit(final AjaxRequestTarget target) { + cancelPublishingModal.show(true); + target.add(cancelPublishingModal); + } + @Override + protected void onError(final AjaxRequestTarget target) { + super.onError(target); + target.add(feedbackPanel); + } + }; + button.setDefaultFormProcessing(false); + button.setIconType(FontAwesome5IconType.times_circle_s); + return button; + } + + protected void addCancelPublishingButtonPermissions( + final Component button + ) { + addDefaultAllButtonsPermissions(button); + button.setVisibilityAllowed( + button.isVisibilityAllowed() && + PUBLISHING.equals(editForm.getModelObject().getStatus()) + ); } protected void setStatusAppendComment(final String status) { final T saveable = editForm.getModelObject(); // do not save an empty comment if previous status is same as current status and comment box is empty - if (status.equals(saveable.getStatus()) && ObjectUtils.isEmpty(saveable.getNewStatusComment())) { + if ( + status.equals(saveable.getStatus()) && + ObjectUtils.isEmpty(saveable.getNewStatusComment()) + ) { saveable.setStatus(status); return; } @@ -798,6 +1039,7 @@ protected void setButtonsPermissions() { addSaveApproveButtonPermissions(saveApproveButton); addApproveButtonPermissions(approveButton); addSaveRevertButtonPermissions(revertToDraftPageButton); + addCancelPublishingButtonPermissions(cancelPublishingButton); addDeleteButtonPermissions(deleteButton); // no need to display the buttons on print view so we overwrite the above permissions @@ -812,65 +1054,108 @@ protected void setButtonsPermissions() { } protected void addDeleteButtonPermissions(final Component button) { - button.setVisibilityAllowed(entityId != null && !isViewMode() - && !(PUBLISHING.equals(editForm.getModelObject().getStatus()) - || PUBLISHED.equals(editForm.getModelObject().getStatus()))); + button.setVisibilityAllowed( + entityId != null && + !isViewMode() && + !(PUBLISHING.equals(editForm.getModelObject().getStatus()) || + PUBLISHED.equals(editForm.getModelObject().getStatus())) + ); } protected void addSaveRevertButtonPermissions(final Component button) { addDefaultAllButtonsPermissions(button); - button.setVisibilityAllowed(button.isVisibilityAllowed() - && (PUBLISHED.equals(editForm.getModelObject().getStatus()) - || ERROR_IN_UNPUBLISHING.equals(editForm.getModelObject().getStatus()))); + button.setVisibilityAllowed( + button.isVisibilityAllowed() && + (PUBLISHED.equals(editForm.getModelObject().getStatus()) || + ERROR_IN_UNPUBLISHING.equals( + editForm.getModelObject().getStatus() + )) + ); } protected void addSaveApproveButtonPermissions(final Component button) { addDefaultAllButtonsPermissions(button); MetaDataRoleAuthorizationStrategy.authorize( - button, Component.RENDER, getValidatorRole()); - button.setVisibilityAllowed(button.isVisibilityAllowed() - && DRAFT.equals(editForm.getModelObject().getStatus())); + button, + Component.RENDER, + getValidatorRole() + ); + button.setVisibilityAllowed( + button.isVisibilityAllowed() && + DRAFT.equals(editForm.getModelObject().getStatus()) + ); } protected void addApproveButtonPermissions(final Component button) { addDefaultAllButtonsPermissions(button); MetaDataRoleAuthorizationStrategy.authorize( - button, Component.RENDER, getValidatorRole()); - button.setVisibilityAllowed(button.isVisibilityAllowed() - && (SAVED.equals(editForm.getModelObject().getStatus()) - || ERROR_IN_PUBLISHING.equals(editForm.getModelObject().getStatus()))); + button, + Component.RENDER, + getValidatorRole() + ); + button.setVisibilityAllowed( + button.isVisibilityAllowed() && + (SAVED.equals(editForm.getModelObject().getStatus()) || + ERROR_IN_PUBLISHING.equals( + editForm.getModelObject().getStatus() + )) + ); } protected void addSaveSubmitButtonPermissions(final Component button) { addDefaultAllButtonsPermissions(button); - MetaDataRoleAuthorizationStrategy.authorize(button, Component.RENDER, getCommaCombinedRoles()); - button.setVisibilityAllowed(button.isVisibilityAllowed() - && DRAFT.equals(editForm.getModelObject().getStatus())); + MetaDataRoleAuthorizationStrategy.authorize( + button, + Component.RENDER, + getCommaCombinedRoles() + ); + button.setVisibilityAllowed( + button.isVisibilityAllowed() && + DRAFT.equals(editForm.getModelObject().getStatus()) + ); } protected void addSaveButtonsPermissions(final Component button) { addDefaultAllButtonsPermissions(button); - MetaDataRoleAuthorizationStrategy.authorize(button, Component.RENDER, getCommaCombinedRoles()); - button.setVisibilityAllowed(button.isVisibilityAllowed() - && (DRAFT.equals(editForm.getModelObject().getStatus()) - || SAVED.equals(editForm.getModelObject().getStatus()) - || ERROR_IN_PUBLISHING.equals(editForm.getModelObject().getStatus()) - || ERROR_IN_UNPUBLISHING.equals(editForm.getModelObject().getStatus()))); + MetaDataRoleAuthorizationStrategy.authorize( + button, + Component.RENDER, + getCommaCombinedRoles() + ); + button.setVisibilityAllowed( + button.isVisibilityAllowed() && + (DRAFT.equals(editForm.getModelObject().getStatus()) || + SAVED.equals(editForm.getModelObject().getStatus()) || + ERROR_IN_PUBLISHING.equals( + editForm.getModelObject().getStatus() + ) || + ERROR_IN_UNPUBLISHING.equals( + editForm.getModelObject().getStatus() + )) + ); } - protected void addDefaultAllButtonsPermissions(final Component button) { - MetaDataRoleAuthorizationStrategy.authorize(button, Component.RENDER, SecurityConstants.Roles.ROLE_USER); + MetaDataRoleAuthorizationStrategy.authorize( + button, + Component.RENDER, + SecurityConstants.Roles.ROLE_USER + ); } - private void scrollToPreviousPosition(final IHeaderResponse response) { - response.render(OnDomReadyHeaderItem.forScript(String.format( - "var vPosition= +%s, mHeight = +%s, cmHeight=$(document).height();" - + "if(mHeight!=0) $(window).scrollTop(vPosition*cmHeight/mHeight)", - getPageParameters().get(WebConstants.V_POSITION).toDouble(0), - getPageParameters().get(WebConstants.MAX_HEIGHT).toDouble(0) - ))); + response.render( + OnDomReadyHeaderItem.forScript( + String.format( + "var vPosition= +%s, mHeight = +%s, cmHeight=$(document).height();" + + "if(mHeight!=0) $(window).scrollTop(vPosition*cmHeight/mHeight)", + getPageParameters() + .get(WebConstants.V_POSITION) + .toDouble(0), + getPageParameters().get(WebConstants.MAX_HEIGHT).toDouble(0) + ) + ) + ); } protected PageParameters getParamsWithServiceInformation() { @@ -881,7 +1166,9 @@ protected PageParameters getParamsWithServiceInformation() { public void renderHead(final IHeaderResponse response) { super.renderHead(response); - response.render(JavaScriptHeaderItem.forReference(BlockUiJavaScript.INSTANCE)); + response.render( + JavaScriptHeaderItem.forReference(BlockUiJavaScript.INSTANCE) + ); scrollToPreviousPosition(response); } @@ -893,15 +1180,23 @@ public void renderHead(final IHeaderResponse response) { * @author mpostelnicu */ public class AllowNullForCertainInvalidFieldsVisitor - implements IVisitor, Void> { + implements IVisitor, Void> + { + @Override - public void component(final GenericBootstrapFormComponent object, final IVisit visit) { + public void component( + final GenericBootstrapFormComponent object, + final IVisit visit + ) { // we found the GenericBootstrapFormComponent, stop doing useless // things like traversing inside the GenericBootstrapFormComponent itself visit.dontGoDeeper(); // do not process disabled fields - if (!object.isEnabledInHierarchy() || object.getField() instanceof BootstrapCheckbox) { + if ( + !object.isEnabledInHierarchy() || + object.getField() instanceof BootstrapCheckbox + ) { return; } object.getField().processInput(); @@ -913,13 +1208,18 @@ public void component(final GenericBootstrapFormComponent object, final IV // of a certain type, we turn // the input into a null. This helps us to save empty REQUIRED // fields when saving as draft - if (!object.getField().isValid() && Strings.isEmpty(object.getField().getInput())) { + if ( + !object.getField().isValid() && + Strings.isEmpty(object.getField().getInput()) + ) { // for text/select fields we just make the object model null - if (object.getField() instanceof TextField || object.getField() instanceof TextArea - || object.getField() instanceof Select2Choice) { + if ( + object.getField() instanceof TextField || + object.getField() instanceof TextArea || + object.getField() instanceof Select2Choice + ) { object.getField().getModel().setObject(null); } - } } } diff --git a/forms/src/main/java/org/devgateway/toolkit/forms/wicket/page/edit/AbstractEditStatusEntityPage.properties b/forms/src/main/java/org/devgateway/toolkit/forms/wicket/page/edit/AbstractEditStatusEntityPage.properties index 14e2e466..8a31b1da 100644 --- a/forms/src/main/java/org/devgateway/toolkit/forms/wicket/page/edit/AbstractEditStatusEntityPage.properties +++ b/forms/src/main/java/org/devgateway/toolkit/forms/wicket/page/edit/AbstractEditStatusEntityPage.properties @@ -20,3 +20,4 @@ autosave_message=Form is saving autoSaveLabelMessage=Form auto-saved less than {0,number} minutes ago. checkedOutToMessage=Checked out to user {0}. errorInPublishingTitle=There was an error during {0}. Please check the data and try to republish it. If it doesn't work please contact the admin. +cancelPublishing=Cancel Publishing diff --git a/forms/src/main/java/org/devgateway/toolkit/forms/wicket/page/edit/dataset/EditCSVDatasetPage.java b/forms/src/main/java/org/devgateway/toolkit/forms/wicket/page/edit/dataset/EditCSVDatasetPage.java index 08bebd9f..770b429a 100644 --- a/forms/src/main/java/org/devgateway/toolkit/forms/wicket/page/edit/dataset/EditCSVDatasetPage.java +++ b/forms/src/main/java/org/devgateway/toolkit/forms/wicket/page/edit/dataset/EditCSVDatasetPage.java @@ -1,9 +1,15 @@ package org.devgateway.toolkit.forms.wicket.page.edit.dataset; +import static org.devgateway.toolkit.persistence.dao.DBConstants.Status.DELETED; +import static org.devgateway.toolkit.persistence.dao.DBConstants.Status.ERROR_IN_PUBLISHING; +import static org.devgateway.toolkit.persistence.dao.DBConstants.Status.PUBLISHING; +import static org.devgateway.toolkit.persistence.dao.DBConstants.Status.UNPUBLISHING; + import de.agilecoders.wicket.core.markup.html.bootstrap.button.BootstrapAjaxButton; import de.agilecoders.wicket.core.markup.html.bootstrap.button.Buttons; import de.agilecoders.wicket.extensions.markup.html.bootstrap.icon.FontAwesome5IconType; -import org.devgateway.toolkit.forms.wicket.components.buttons.ladda.LaddaAjaxButton; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; import org.apache.commons.lang3.StringUtils; import org.apache.wicket.ajax.AjaxRequestTarget; import org.apache.wicket.model.IModel; @@ -19,6 +25,7 @@ import org.devgateway.toolkit.forms.service.DatasetClientService; import org.devgateway.toolkit.forms.service.EurekaClientService; import org.devgateway.toolkit.forms.wicket.components.breadcrumbs.BreadCrumbPage; +import org.devgateway.toolkit.forms.wicket.components.buttons.ladda.LaddaAjaxButton; import org.devgateway.toolkit.forms.wicket.components.form.AJAXDownload; import org.devgateway.toolkit.forms.wicket.components.form.BootstrapCancelButton; import org.devgateway.toolkit.forms.wicket.components.form.FileInputBootstrapFormComponent; @@ -36,23 +43,20 @@ import org.springframework.orm.ObjectOptimisticLockingFailureException; import org.wicketstuff.annotation.mount.MountPath; -import jakarta.servlet.http.HttpServletResponse; -import java.io.IOException; - -import static org.devgateway.toolkit.persistence.dao.DBConstants.Status.DELETED; -import static org.devgateway.toolkit.persistence.dao.DBConstants.Status.PUBLISHING; -import static org.devgateway.toolkit.persistence.dao.DBConstants.Status.UNPUBLISHING; - /** * @author vchihai */ @MountPath(value = "/editCSVDataset") @BreadCrumbPage(parent = ListCSVDatasetPage.class, hasServiceParam = true) -public class EditCSVDatasetPage extends AbstractEditStatusEntityPage { +public class EditCSVDatasetPage + extends AbstractEditStatusEntityPage +{ private static final long serialVersionUID = -5231470856974604314L; - private static final Logger logger = LoggerFactory.getLogger(EditCSVDatasetPage.class); + private static final Logger logger = LoggerFactory.getLogger( + EditCSVDatasetPage.class + ); protected Select2ChoiceBootstrapFormComponent year; @@ -81,29 +85,37 @@ protected void onInitialize() { super.onInitialize(); if (entityId == null) { - String service = getPageParameters().get(WebConstants.PARAM_SERVICE).toString(); + String service = getPageParameters() + .get(WebConstants.PARAM_SERVICE) + .toString(); editForm.getModelObject().setDestinationService(service); } editForm.add(getYear()); final TextFieldBootstrapFormComponent description = - new TextFieldBootstrapFormComponent<>("description"); - description.getField().add(WebConstants.StringValidators.MAXIMUM_LENGTH_VALIDATOR_ONE_LINE_TEXT); + new TextFieldBootstrapFormComponent<>("description"); + description + .getField() + .add( + WebConstants.StringValidators.MAXIMUM_LENGTH_VALIDATOR_ONE_LINE_TEXT + ); editForm.add(description); - final FileInputBootstrapFormComponent files = new FileInputBootstrapFormComponent("files"); + final FileInputBootstrapFormComponent files = + new FileInputBootstrapFormComponent("files"); files.allowedFileExtensions("csv"); files.required(); files.maxFiles(1); - files.getFileInputBootstrapFormComponentWrapper().setAllowDownloadWhenReadonly(true); + files + .getFileInputBootstrapFormComponentWrapper() + .setAllowDownloadWhenReadonly(true); editForm.add(files); editForm.add(getService()); AJAXDownload downloadTemplateBehaviour = getDownloadTemplateBehaviour(); editForm.add(downloadTemplateBehaviour); editForm.add(getDownloadTemplateButton(downloadTemplateBehaviour)); - } private AJAXDownload getDownloadTemplateBehaviour() { @@ -113,20 +125,35 @@ protected IRequestHandler getHandler() { return new IRequestHandler() { @Override public void respond(final IRequestCycle requestCycle) { - final HttpServletResponse response = (HttpServletResponse) requestCycle.getResponse().getContainerResponse(); + final HttpServletResponse response = + (HttpServletResponse) requestCycle + .getResponse() + .getContainerResponse(); try { - String serviceName = editForm.getModelObject().getDestinationService(); + String serviceName = editForm + .getModelObject() + .getDestinationService(); - final byte[] bytes = datasetClientService.getTemplateDownload(serviceName); + final byte[] bytes = + datasetClientService.getTemplateDownload( + serviceName + ); response.setContentType("text/csv"); - response.setHeader("Content-Disposition", "attachment; filename=" + serviceName + "-template.csv"); + response.setHeader( + "Content-Disposition", + "attachment; filename=" + + serviceName + + "-template.csv" + ); response.getOutputStream().write(bytes); } catch (IOException e) { logger.error("Download Template error", e); } - RequestCycle.get().scheduleRequestHandlerAfterCurrent(null); + RequestCycle.get().scheduleRequestHandlerAfterCurrent( + null + ); } @Override @@ -140,10 +167,14 @@ public void detach(final IRequestCycle requestCycle) { return download; } - private BootstrapAjaxButton getDownloadTemplateButton(final AJAXDownload downloadTemplateBehaviour) { - final LaddaAjaxButton templateDownloadButton = new LaddaAjaxButton("templateDownloadButton", - new Model<>("Template Download"), - Buttons.Type.Warning) { + private BootstrapAjaxButton getDownloadTemplateButton( + final AJAXDownload downloadTemplateBehaviour + ) { + final LaddaAjaxButton templateDownloadButton = new LaddaAjaxButton( + "templateDownloadButton", + new Model<>("Template Download"), + Buttons.Type.Warning + ) { @Override protected void onSubmit(final AjaxRequestTarget target) { super.onSubmit(target); @@ -158,8 +189,10 @@ protected void onSubmit(final AjaxRequestTarget target) { } private Select2ChoiceBootstrapFormComponent getYear() { - year = new Select2ChoiceBootstrapFormComponent<>("year", - new GenericChoiceProvider<>(settingsUtils.getYearsRange())); + year = new Select2ChoiceBootstrapFormComponent<>( + "year", + new GenericChoiceProvider<>(settingsUtils.getYearsRange()) + ); editForm.add(year); year.required(); @@ -167,7 +200,9 @@ private Select2ChoiceBootstrapFormComponent getYear() { } private TextFieldBootstrapFormComponent getService() { - destinationService = new TextFieldBootstrapFormComponent("destinationService"); + destinationService = new TextFieldBootstrapFormComponent( + "destinationService" + ); destinationService.setEnabled(false); return destinationService; @@ -213,7 +248,11 @@ protected void onApprove(final AjaxRequestTarget target) { try { CSVDataset dataset = editForm.getModelObject(); - FileMetadata fileMetadata = dataset.getFiles().stream().findFirst().get(); + FileMetadata fileMetadata = dataset + .getFiles() + .stream() + .findFirst() + .get(); String fileName = fileMetadata.getName(); byte[] content = fileMetadata.getContent().getBytes(); @@ -226,14 +265,37 @@ protected void onApprove(final AjaxRequestTarget target) { setResponsePage(listPageClass); } + @Override + protected void onCancelPublishing(final AjaxRequestTarget target) { + try { + CSVDataset dataset = editForm.getModelObject(); + datasetClientService.cancelPublishing(dataset); + logger.info("Cancelled publishing for dataset {}", dataset.getId()); + } catch (Exception e) { + logger.error("Error cancelling publishing: " + e.getMessage(), e); + // Even if cleanup fails on remote, still mark as error locally so user can retry + CSVDataset dataset = editForm.getModelObject(); + dataset.setStatus(ERROR_IN_PUBLISHING); + csvDatasetService.save(dataset); + } + setResponsePage(listPageClass, getParamsWithServiceInformation()); + } + protected BootstrapCancelButton getCancelButton(final String id) { - return new CancelEditPageButton(id, new StringResourceModel("cancelButton", this, null)); + return new CancelEditPageButton( + id, + new StringResourceModel("cancelButton", this, null) + ); } public class CancelEditPageButton extends BootstrapCancelButton { + private static final long serialVersionUID = -1474498211555760931L; - public CancelEditPageButton(final String id, final IModel model) { + public CancelEditPageButton( + final String id, + final IModel model + ) { super(id, model); } @@ -248,7 +310,11 @@ protected void onSubmit(final AjaxRequestTarget target) { protected void enableDisableAutosaveFields(final AjaxRequestTarget target) { super.enableDisableAutosaveFields(target); - if (StringUtils.isBlank(editForm.getModelObject().getDestinationService())) { + if ( + StringUtils.isBlank( + editForm.getModelObject().getDestinationService() + ) + ) { saveApproveButton.setEnabled(false); approveButton.setEnabled(false); } @@ -267,7 +333,10 @@ protected PageParameters getCancelPageParameters() { protected PageParameters getParamsWithServiceInformation() { PageParameters pageParams = new PageParameters(); // add service to the page parameters - pageParams.add(WebConstants.PARAM_SERVICE, editForm.getModelObject().getDestinationService()); + pageParams.add( + WebConstants.PARAM_SERVICE, + editForm.getModelObject().getDestinationService() + ); return pageParams; } diff --git a/forms/src/test/java/org/devgateway/toolkit/forms/service/DatasetClientServiceTest.java b/forms/src/test/java/org/devgateway/toolkit/forms/service/DatasetClientServiceTest.java new file mode 100644 index 00000000..36b27f87 --- /dev/null +++ b/forms/src/test/java/org/devgateway/toolkit/forms/service/DatasetClientServiceTest.java @@ -0,0 +1,348 @@ +package org.devgateway.toolkit.forms.service; + +import static org.devgateway.toolkit.persistence.dao.DBConstants.Status.DRAFT; +import static org.devgateway.toolkit.persistence.dao.DBConstants.Status.ERROR_IN_PUBLISHING; +import static org.devgateway.toolkit.persistence.dao.DBConstants.Status.ERROR_IN_UNPUBLISHING; +import static org.devgateway.toolkit.persistence.dao.DBConstants.Status.PUBLISHED; +import static org.devgateway.toolkit.persistence.dao.DBConstants.Status.PUBLISHING; +import static org.devgateway.toolkit.persistence.dao.DBConstants.Status.UNPUBLISHING; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.ZonedDateTime; +import java.util.Optional; + +import org.devgateway.toolkit.forms.client.DataSetClientException; +import org.devgateway.toolkit.forms.client.DatasetClient; +import org.devgateway.toolkit.forms.client.DatasetJobStatus; +import org.devgateway.toolkit.persistence.dao.data.CSVDataset; +import org.devgateway.toolkit.persistence.dto.ServiceMetadata; +import org.devgateway.toolkit.persistence.service.data.CSVDatasetService; +import org.devgateway.toolkit.persistence.service.data.TetsimDatasetService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +/** + * Unit tests for DatasetClientService. + * Tests the cancel publishing, timeout detection, and job status checking logic. + */ +@ExtendWith(MockitoExtension.class) +public class DatasetClientServiceTest { + + @Mock + private TetsimDatasetService tetsimDatasetService; + + @Mock + private CSVDatasetService csvDatasetService; + + @Mock + private EurekaClientService eurekaClientService; + + @InjectMocks + private DatasetClientService datasetClientService; + + private CSVDataset testDataset; + private ServiceMetadata testServiceMetadata; + + @BeforeEach + void setUp() { + // Set the publishing timeout to 30 minutes (default) + ReflectionTestUtils.setField(datasetClientService, "publishingTimeoutMinutes", 30); + + // Create a test dataset + testDataset = new CSVDataset(); + ReflectionTestUtils.setField(testDataset, "id", 1L); + testDataset.setYear(2025); + testDataset.setDestinationService("INTERFERENCE"); + testDataset.setStatus(PUBLISHING); + + // Create test service metadata + testServiceMetadata = new ServiceMetadata(); + testServiceMetadata.setName("INTERFERENCE"); + testServiceMetadata.setUrl("http://localhost:8090"); + } + + @Nested + @DisplayName("Cancel Publishing Tests") + class CancelPublishingTests { + + @Test + @DisplayName("Should mark dataset as ERROR_IN_PUBLISHING when cancelling") + void shouldMarkDatasetAsErrorWhenCancelling() { + // Given + when(eurekaClientService.findByName("INTERFERENCE")).thenReturn(testServiceMetadata); + + // When + datasetClientService.cancelPublishing(testDataset); + + // Then + assertEquals(ERROR_IN_PUBLISHING, testDataset.getStatus()); + verify(csvDatasetService, times(1)).save(testDataset); + } + + @Test + @DisplayName("Should throw exception when trying to cancel non-publishing dataset") + void shouldThrowExceptionWhenNotPublishing() { + // Given + testDataset.setStatus(PUBLISHED); + + // When/Then + IllegalStateException exception = assertThrows( + IllegalStateException.class, + () -> datasetClientService.cancelPublishing(testDataset) + ); + + assertEquals( + "Cannot cancel publishing for dataset 1: current status is PUBLISHED, expected PUBLISHING", + exception.getMessage() + ); + verify(csvDatasetService, never()).save(any()); + } + + @Test + @DisplayName("Should still mark as error even if remote cleanup fails") + void shouldMarkAsErrorEvenIfRemoteCleanupFails() { + // Given + when(eurekaClientService.findByName("INTERFERENCE")).thenReturn(testServiceMetadata); + // Remote service will fail (mock client throws exception) + // The service should still mark the dataset as ERROR_IN_PUBLISHING + + // When + datasetClientService.cancelPublishing(testDataset); + + // Then + assertEquals(ERROR_IN_PUBLISHING, testDataset.getStatus()); + verify(csvDatasetService, times(1)).save(testDataset); + } + } + + @Nested + @DisplayName("Status Transition Tests") + class StatusTransitionTests { + + @Test + @DisplayName("getCompletedStatus should return PUBLISHED for PUBLISHING status") + void shouldReturnPublishedForPublishingStatus() throws Exception { + // Use reflection to test private method + String result = ReflectionTestUtils.invokeMethod( + datasetClientService, + "getCompletedStatus", + PUBLISHING + ); + + assertEquals(PUBLISHED, result); + } + + @Test + @DisplayName("getCompletedStatus should return DRAFT for UNPUBLISHING status") + void shouldReturnDraftForUnpublishingStatus() throws Exception { + String result = ReflectionTestUtils.invokeMethod( + datasetClientService, + "getCompletedStatus", + UNPUBLISHING + ); + + assertEquals(DRAFT, result); + } + + @Test + @DisplayName("getErrorStatus should return ERROR_IN_PUBLISHING for PUBLISHING status") + void shouldReturnErrorInPublishingForPublishingStatus() throws Exception { + String result = ReflectionTestUtils.invokeMethod( + datasetClientService, + "getErrorStatus", + PUBLISHING + ); + + assertEquals(ERROR_IN_PUBLISHING, result); + } + + @Test + @DisplayName("getErrorStatus should return ERROR_IN_UNPUBLISHING for UNPUBLISHING status") + void shouldReturnErrorInUnpublishingForUnpublishingStatus() throws Exception { + String result = ReflectionTestUtils.invokeMethod( + datasetClientService, + "getErrorStatus", + UNPUBLISHING + ); + + assertEquals(ERROR_IN_UNPUBLISHING, result); + } + } + + @Nested + @DisplayName("Timeout Detection Tests") + class TimeoutDetectionTests { + + @Test + @DisplayName("Should detect stuck job when lastModifiedDate exceeds timeout") + void shouldDetectStuckJobWhenExceedsTimeout() throws Exception { + // Given - dataset was last modified 31 minutes ago (exceeds 30 min timeout) + ZonedDateTime thirtyOneMinutesAgo = ZonedDateTime.now().minusMinutes(31); + testDataset = new CSVDatasetWithLastModified(thirtyOneMinutesAgo); + testDataset.setStatus(PUBLISHING); + + // When + Boolean result = ReflectionTestUtils.invokeMethod( + datasetClientService, + "isJobStuck", + testDataset + ); + + // Then + assertEquals(true, result); + } + + @Test + @DisplayName("Should not detect stuck job when within timeout") + void shouldNotDetectStuckJobWhenWithinTimeout() throws Exception { + // Given - dataset was last modified 10 minutes ago (within 30 min timeout) + ZonedDateTime tenMinutesAgo = ZonedDateTime.now().minusMinutes(10); + testDataset = new CSVDatasetWithLastModified(tenMinutesAgo); + testDataset.setStatus(PUBLISHING); + + // When + Boolean result = ReflectionTestUtils.invokeMethod( + datasetClientService, + "isJobStuck", + testDataset + ); + + // Then + assertEquals(false, result); + } + + @Test + @DisplayName("Should not detect stuck job when lastModifiedDate is null") + void shouldNotDetectStuckJobWhenLastModifiedDateIsNull() throws Exception { + // Given - no last modified date + testDataset = new CSVDatasetWithLastModified(null); + testDataset.setStatus(PUBLISHING); + + // When + Boolean result = ReflectionTestUtils.invokeMethod( + datasetClientService, + "isJobStuck", + testDataset + ); + + // Then + assertEquals(false, result); + } + + @Test + @DisplayName("Should detect stuck job based on remote job createdDate") + void shouldDetectStuckJobBasedOnRemoteJobDate() throws Exception { + // Given + DatasetJobStatus jobStatus = new DatasetJobStatus(); + jobStatus.setCreatedDate(ZonedDateTime.now().minusMinutes(45)); + + // When + Boolean result = ReflectionTestUtils.invokeMethod( + datasetClientService, + "isJobStuck", + testDataset, + jobStatus + ); + + // Then + assertEquals(true, result); + } + } + + @Nested + @DisplayName("markAsErrorDueToTimeout Tests") + class MarkAsErrorDueToTimeoutTests { + + @Test + @DisplayName("Should mark PUBLISHING dataset as ERROR_IN_PUBLISHING on timeout") + void shouldMarkPublishingAsErrorInPublishing() throws Exception { + // Given + testDataset.setStatus(PUBLISHING); + + // When + ReflectionTestUtils.invokeMethod( + datasetClientService, + "markAsErrorDueToTimeout", + testDataset, + PUBLISHING + ); + + // Then + assertEquals(ERROR_IN_PUBLISHING, testDataset.getStatus()); + } + + @Test + @DisplayName("Should mark UNPUBLISHING dataset as ERROR_IN_UNPUBLISHING on timeout") + void shouldMarkUnpublishingAsErrorInUnpublishing() throws Exception { + // Given + testDataset.setStatus(UNPUBLISHING); + + // When + ReflectionTestUtils.invokeMethod( + datasetClientService, + "markAsErrorDueToTimeout", + testDataset, + UNPUBLISHING + ); + + // Then + assertEquals(ERROR_IN_UNPUBLISHING, testDataset.getStatus()); + } + } + + @Nested + @DisplayName("Save Dataset Tests") + class SaveDatasetTests { + + @Test + @DisplayName("Should save CSVDataset using csvDatasetService") + void shouldSaveCSVDatasetCorrectly() throws Exception { + // Given + CSVDataset csvDataset = new CSVDataset(); + + // When + ReflectionTestUtils.invokeMethod( + datasetClientService, + "saveDataset", + csvDataset + ); + + // Then + verify(csvDatasetService, times(1)).save(csvDataset); + verify(tetsimDatasetService, never()).save(any()); + } + } + + /** + * Helper class to create a CSVDataset with a specific lastModifiedDate. + * This is needed because CSVDataset's lastModifiedDate is managed by JPA. + */ + private static class CSVDatasetWithLastModified extends CSVDataset { + private final ZonedDateTime lastModified; + + CSVDatasetWithLastModified(ZonedDateTime lastModified) { + this.lastModified = lastModified; + } + + @Override + public Optional getLastModifiedDate() { + return Optional.ofNullable(lastModified); + } + } +} diff --git a/mockserver-init.json b/mockserver-init.json new file mode 100644 index 00000000..dc5d3789 --- /dev/null +++ b/mockserver-init.json @@ -0,0 +1,127 @@ +[ + { + "httpRequest": { + "method": "GET", + "path": "/health" + }, + "httpResponse": { + "statusCode": 200, + "headers": { + "Content-Type": ["application/json"] + }, + "body": { + "status": "UP" + } + } + }, + { + "httpRequest": { + "method": "POST", + "path": "/datasets" + }, + "httpResponse": { + "statusCode": 200, + "headers": { + "Content-Type": ["application/json"] + }, + "body": { + "jobId": "test-job-123", + "status": "PROCESSING", + "message": "Dataset upload started" + } + } + }, + { + "httpRequest": { + "method": "GET", + "path": "/jobs/code/tcdi-.*" + }, + "httpResponse": { + "statusCode": 200, + "headers": { + "Content-Type": ["application/json"] + }, + "body": { + "jobId": "test-job-123", + "status": "COMPLETED", + "message": "Dataset processed successfully", + "createdDate": "2025-01-01T00:00:00Z" + } + } + }, + { + "httpRequest": { + "method": "DELETE", + "path": "/datasets/tcdi-.*" + }, + "httpResponse": { + "statusCode": 200, + "headers": { + "Content-Type": ["application/json"] + }, + "body": { + "jobId": "delete-job-456", + "status": "COMPLETED", + "message": "Dataset deleted successfully" + } + } + }, + { + "httpRequest": { + "method": "GET", + "path": "/template/download" + }, + "httpResponse": { + "statusCode": 200, + "headers": { + "Content-Type": ["text/csv"], + "Content-Disposition": ["attachment; filename=template.csv"] + }, + "body": "Country,Year,Score,Ranking\nExample,2025,50,25" + } + }, + { + "httpRequest": { + "method": "GET", + "path": "/dimensions" + }, + "httpResponse": { + "statusCode": 200, + "headers": { + "Content-Type": ["application/json"] + }, + "body": [ + { + "name": "country", + "label": "Country" + }, + { + "name": "year", + "label": "Year" + } + ] + } + }, + { + "httpRequest": { + "method": "GET", + "path": "/measures" + }, + "httpResponse": { + "statusCode": 200, + "headers": { + "Content-Type": ["application/json"] + }, + "body": [ + { + "name": "score", + "label": "Score" + }, + { + "name": "ranking", + "label": "Ranking" + } + ] + } + } +]